pr-shepherd 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -303
  3. package/bin/checks/classify.mjs +5 -4
  4. package/bin/checks/triage.mjs +76 -62
  5. package/bin/cli/args.mjs +29 -61
  6. package/bin/cli/exit-codes.mjs +39 -0
  7. package/bin/cli/fix-formatter.mjs +76 -0
  8. package/bin/cli/formatters.mjs +108 -0
  9. package/bin/cli/handlers.mjs +138 -0
  10. package/bin/cli/iterate-formatter.mjs +78 -0
  11. package/bin/cli-parser.iterate-fixtures.mjs +65 -0
  12. package/bin/cli-parser.mjs +110 -0
  13. package/bin/commands/check-status.mjs +35 -0
  14. package/bin/commands/check.mjs +14 -61
  15. package/bin/commands/commit-suggestion.mjs +159 -0
  16. package/bin/commands/iterate/classify.mjs +77 -0
  17. package/bin/commands/iterate/escalate.mjs +124 -0
  18. package/bin/commands/iterate/fix-code.mjs +97 -0
  19. package/bin/commands/iterate/helpers.mjs +103 -0
  20. package/bin/commands/iterate/index.mjs +122 -0
  21. package/bin/commands/iterate/render.mjs +119 -0
  22. package/bin/commands/iterate/stall.mjs +65 -0
  23. package/bin/commands/iterate/steps.mjs +31 -0
  24. package/bin/commands/iterate.mjs +2 -628
  25. package/bin/commands/monitor.mjs +78 -0
  26. package/bin/commands/ready-delay.mjs +3 -4
  27. package/bin/commands/resolve-instructions.mjs +39 -0
  28. package/bin/commands/resolve.mjs +34 -3
  29. package/bin/commands/status.mjs +7 -0
  30. package/bin/comments/resolve.mjs +1 -1
  31. package/bin/config/load.mjs +17 -113
  32. package/bin/config.json +10 -22
  33. package/bin/github/batch-parsers.mjs +140 -0
  34. package/bin/github/batch-raw-types.mjs +2 -0
  35. package/bin/github/batch.mjs +34 -129
  36. package/bin/github/client.mjs +47 -9
  37. package/bin/github/gql/batch-pr.gql +20 -0
  38. package/bin/github/http.mjs +32 -30
  39. package/bin/index.mjs +15 -2
  40. package/bin/merge-status/derive.mjs +11 -11
  41. package/bin/reporters/agent.mjs +13 -4
  42. package/bin/reporters/check-instructions.mjs +65 -0
  43. package/bin/reporters/json.mjs +3 -2
  44. package/bin/reporters/text.mjs +108 -61
  45. package/bin/{cache → state}/fix-attempts.mjs +3 -3
  46. package/bin/state/iterate-stall.mjs +74 -0
  47. package/bin/suggestions/parse.mjs +119 -0
  48. package/bin/suggestions/patch.mjs +52 -0
  49. package/bin/types/github.mjs +2 -0
  50. package/bin/types/iterate.mjs +2 -0
  51. package/bin/types/report.mjs +2 -0
  52. package/bin/types.mjs +3 -1
  53. package/package.json +3 -3
  54. package/plugin/skills/check/SKILL.md +15 -48
  55. package/plugin/skills/monitor/SKILL.md +11 -64
  56. package/plugin/skills/resolve/SKILL.md +10 -76
  57. package/bin/cache/file-cache.mjs +0 -79
  58. package/bin/cli.mjs +0 -298
@@ -0,0 +1,97 @@
1
+ import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
2
+ import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
3
+ import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
4
+ import { classifyComments, buildResolveCommand } from "./classify.mjs";
5
+ import { buildFixInstructions } from "./render.mjs";
6
+ import { applyStallGuard } from "./stall.mjs";
7
+ import { tryCancelRun } from "./helpers.mjs";
8
+ export async function handleFixCode(ctx) {
9
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, surfacedSummaries, } = ctx;
10
+ const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
11
+ const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
12
+ const isNewSha = stored?.headSha !== headSha;
13
+ // Accumulate across shas — only increment when a push is detected (sha changed)
14
+ const currentAttempts = stored ? { ...stored.threadAttempts } : {};
15
+ if (isNewSha) {
16
+ for (const t of report.threads.actionable) {
17
+ currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
18
+ }
19
+ }
20
+ const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, actionableChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
21
+ if (escalateTriggers.triggers.length > 0) {
22
+ const escalateBase = {
23
+ triggers: escalateTriggers.triggers,
24
+ unresolvedThreads: report.threads.actionable.map(toAgentThread),
25
+ ambiguousComments: report.comments.actionable.map(toAgentComment),
26
+ changesRequestedReviews: report.changesRequestedReviews,
27
+ attemptHistory: escalateTriggers.thrashHistory,
28
+ suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
29
+ };
30
+ return {
31
+ ...base,
32
+ action: "escalate",
33
+ escalate: {
34
+ ...escalateBase,
35
+ humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
36
+ },
37
+ };
38
+ }
39
+ // Save updated state (only incremented on sha change)
40
+ await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts: currentAttempts });
41
+ let cancelled = [];
42
+ if (!opts.noAutoCancelActionable) {
43
+ const uniqueRunIds = [
44
+ ...new Set(actionableChecks.map((c) => c.runId).filter((id) => id !== null)),
45
+ ];
46
+ const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
47
+ cancelled = results.filter((id) => id !== null);
48
+ }
49
+ const baseLookup = validateBaseBranch(report.baseBranch);
50
+ const threads = report.threads.actionable.map(toAgentThread);
51
+ const { actionable: actionableComments, noiseIds: noiseCommentIds } = classifyComments(report.comments.actionable.map(toAgentComment));
52
+ const checks = toAgentChecks(actionableChecks);
53
+ const { changesRequestedReviews } = report;
54
+ const hasConflicts = report.mergeStatus.status === "CONFLICTS";
55
+ const allCommentIds = [
56
+ ...actionableComments.map((c) => c.id),
57
+ ...noiseCommentIds,
58
+ ...reviewSummaryIds,
59
+ ];
60
+ const resolveCommand = buildResolveCommand(threads, actionableComments, allCommentIds, changesRequestedReviews, checks, prNumber);
61
+ if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
62
+ const fallbackEscalateBase = {
63
+ triggers: ["base-branch-unknown"],
64
+ unresolvedThreads: threads,
65
+ ambiguousComments: actionableComments,
66
+ changesRequestedReviews,
67
+ suggestion: buildEscalateSuggestion(["base-branch-unknown"], baseLookup.failureReason),
68
+ };
69
+ return {
70
+ ...base,
71
+ action: "escalate",
72
+ escalate: {
73
+ ...fallbackEscalateBase,
74
+ humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prNumber),
75
+ },
76
+ };
77
+ }
78
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length);
79
+ return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
80
+ ...base,
81
+ baseBranch: baseLookup.branch,
82
+ action: "fix_code",
83
+ fix: {
84
+ mode: "rebase-and-push",
85
+ threads,
86
+ actionableComments,
87
+ noiseCommentIds,
88
+ reviewSummaryIds,
89
+ surfacedSummaries,
90
+ checks,
91
+ changesRequestedReviews,
92
+ resolveCommand,
93
+ instructions,
94
+ },
95
+ cancelled,
96
+ }, report, reviewSummaryIds);
97
+ }
@@ -0,0 +1,103 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { rest } from "../../github/http.mjs";
4
+ const execFile = promisify(execFileCb);
5
+ export function buildSummary(report) {
6
+ return {
7
+ passing: report.checks.passing.length,
8
+ skipped: report.checks.skipped.length,
9
+ filtered: report.checks.filtered.length,
10
+ inProgress: report.checks.inProgress.length,
11
+ };
12
+ }
13
+ /**
14
+ * Build the full list of CI checks relevant to PR readiness: triggered by a PR
15
+ * event (or StatusContext with null event), completed, and not skipped/neutral.
16
+ * Includes both passing and failing. Failing entries carry failureKind + errorExcerpt.
17
+ */
18
+ export function buildRelevantChecks(report) {
19
+ const excluded = new Set([null, "SKIPPED", "NEUTRAL"]);
20
+ const passing = report.checks.passing.flatMap((c) => {
21
+ if (excluded.has(c.conclusion))
22
+ return [];
23
+ const conclusion = c.conclusion;
24
+ return [
25
+ {
26
+ name: c.name,
27
+ conclusion,
28
+ runId: c.runId,
29
+ detailsUrl: c.detailsUrl || null,
30
+ summary: c.summary,
31
+ },
32
+ ];
33
+ });
34
+ const failing = report.checks.failing.flatMap((c) => {
35
+ if (excluded.has(c.conclusion))
36
+ return [];
37
+ const conclusion = c.conclusion;
38
+ return [
39
+ {
40
+ name: c.name,
41
+ conclusion,
42
+ runId: c.runId,
43
+ detailsUrl: c.detailsUrl || null,
44
+ failureKind: c.failureKind,
45
+ workflowName: c.workflowName,
46
+ failedStep: c.failedStep,
47
+ summary: c.summary,
48
+ },
49
+ ];
50
+ });
51
+ return [...passing, ...failing];
52
+ }
53
+ export async function getLastCommitTime() {
54
+ try {
55
+ const { stdout } = await execFile("git", ["log", "-1", "--format=%ct", "HEAD"]);
56
+ return parseInt(stdout.trim(), 10);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ // Best-effort: cancelling a completed run is a no-op, not an error.
63
+ export async function tryCancelRun(runId, owner, repo) {
64
+ try {
65
+ await rest("POST", `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`);
66
+ return runId;
67
+ }
68
+ catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ // GitHub returns 409 when the run reached a terminal state — expected, not worth logging.
71
+ if (/409|already completed|cannot cancel a workflow run that is completed/i.test(msg))
72
+ return null;
73
+ process.stderr.write(`pr-shepherd: cancel run ${runId} failed (ignored): ${msg}\n`);
74
+ return null;
75
+ }
76
+ }
77
+ export async function getCurrentHeadSha() {
78
+ try {
79
+ const { stdout } = await execFile("git", ["rev-parse", "HEAD"]);
80
+ return stdout.trim();
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ export function buildCooldownResult(prNumber, readyDelaySeconds) {
87
+ return {
88
+ action: "cooldown",
89
+ pr: prNumber,
90
+ repo: "",
91
+ status: "UNKNOWN",
92
+ state: "UNKNOWN",
93
+ mergeStateStatus: "UNKNOWN",
94
+ copilotReviewInProgress: false,
95
+ isDraft: false,
96
+ shouldCancel: false,
97
+ remainingSeconds: readyDelaySeconds,
98
+ summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
99
+ baseBranch: "",
100
+ checks: [],
101
+ log: "SKIP: CI still starting — waiting for first check to appear",
102
+ };
103
+ }
@@ -0,0 +1,122 @@
1
+ import { runCheck } from "../check.mjs";
2
+ import { updateReadyDelay } from "../ready-delay.mjs";
3
+ import { getCurrentPrNumber } from "../../github/client.mjs";
4
+ import { graphql } from "../../github/http.mjs";
5
+ import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
6
+ import { loadConfig } from "../../config/load.mjs";
7
+ import { getLastCommitTime, getCurrentHeadSha, buildSummary, buildRelevantChecks, buildCooldownResult, } from "./helpers.mjs";
8
+ import { classifyReviewSummaries } from "./classify.mjs";
9
+ import { applyStallGuard } from "./stall.mjs";
10
+ import { buildWaitLog } from "./render.mjs";
11
+ import { handleFixCode } from "./fix-code.mjs";
12
+ import { buildRerunCiResult } from "./steps.mjs";
13
+ export async function runIterate(opts) {
14
+ const config = loadConfig();
15
+ const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
16
+ const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
17
+ const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
18
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
19
+ if (prNumber === null) {
20
+ throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
21
+ }
22
+ const optsWithPr = { ...opts, prNumber };
23
+ const lastCommitTime = await getLastCommitTime();
24
+ const nowSeconds = Math.floor(Date.now() / 1000);
25
+ if (lastCommitTime !== null && nowSeconds - lastCommitTime < cooldownSeconds) {
26
+ return buildCooldownResult(prNumber, readyDelaySeconds);
27
+ }
28
+ const report = await runCheck({
29
+ ...optsWithPr,
30
+ autoResolve: config.actions.autoResolveOutdated,
31
+ });
32
+ if (report.mergeStatus.state !== "OPEN") {
33
+ const state = report.mergeStatus.state.toLowerCase();
34
+ return {
35
+ pr: report.pr,
36
+ repo: report.repo,
37
+ status: report.status,
38
+ mergeStateStatus: report.mergeStatus.mergeStateStatus,
39
+ copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
40
+ isDraft: report.mergeStatus.isDraft,
41
+ shouldCancel: true,
42
+ remainingSeconds: 0,
43
+ state: report.mergeStatus.state,
44
+ summary: buildSummary(report),
45
+ baseBranch: report.baseBranch,
46
+ checks: buildRelevantChecks(report),
47
+ action: "cancel",
48
+ log: `CANCEL: PR #${report.pr} is ${state} — stopping monitor`,
49
+ };
50
+ }
51
+ const [repoOwner, repoName] = report.repo.split("/");
52
+ if (!repoOwner || !repoName) {
53
+ throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
54
+ }
55
+ const isReady = report.status === "READY";
56
+ const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
57
+ const base = {
58
+ pr: report.pr,
59
+ repo: report.repo,
60
+ status: report.status,
61
+ state: report.mergeStatus.state,
62
+ mergeStateStatus: report.mergeStatus.mergeStateStatus,
63
+ copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
64
+ isDraft: report.mergeStatus.isDraft,
65
+ shouldCancel: readyState.shouldCancel,
66
+ remainingSeconds: readyState.remainingSeconds,
67
+ summary: buildSummary(report),
68
+ baseBranch: report.baseBranch,
69
+ checks: buildRelevantChecks(report),
70
+ };
71
+ if (readyState.shouldCancel) {
72
+ return {
73
+ ...base,
74
+ action: "cancel",
75
+ log: `CANCEL: PR #${base.pr} has been ready for review — ready-delay elapsed, stopping monitor`,
76
+ };
77
+ }
78
+ const headSha = (await getCurrentHeadSha()) ?? "unknown";
79
+ const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
80
+ const { minimizeIds: reviewSummaryIds, surfacedSummaries } = classifyReviewSummaries(report.reviewSummaries, report.approvedReviews, config.iterate.minimizeReviewSummaries);
81
+ const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
82
+ const hasActionableWork = report.threads.actionable.length > 0 ||
83
+ report.comments.actionable.length > 0 ||
84
+ report.changesRequestedReviews.length > 0 ||
85
+ actionableChecks.length > 0 ||
86
+ report.mergeStatus.status === "CONFLICTS" ||
87
+ reviewSummaryIds.length > 0 ||
88
+ surfacedSummaries.length > 0;
89
+ if (hasActionableWork) {
90
+ return handleFixCode({
91
+ base,
92
+ report,
93
+ opts,
94
+ headSha,
95
+ stallKey,
96
+ prNumber,
97
+ stallTimeoutSeconds,
98
+ repoOwner,
99
+ repoName,
100
+ reviewSummaryIds,
101
+ surfacedSummaries,
102
+ });
103
+ }
104
+ const transientChecks = report.checks.failing.filter((f) => (f.failureKind === "timeout" || f.failureKind === "cancelled") && f.runId !== null);
105
+ if (transientChecks.length > 0) {
106
+ return buildRerunCiResult(transientChecks, base, prNumber, stallKey, stallTimeoutSeconds, headSha, report, reviewSummaryIds);
107
+ }
108
+ const canMarkReady = report.status === "READY" &&
109
+ report.mergeStatus.isDraft &&
110
+ !report.mergeStatus.copilotReviewInProgress &&
111
+ !readyState.shouldCancel;
112
+ if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
113
+ await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
114
+ return {
115
+ ...base,
116
+ action: "mark_ready",
117
+ markedReady: true,
118
+ log: `MARKED READY: PR #${report.pr} converted from draft to ready for review`,
119
+ };
120
+ }
121
+ return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, { ...base, action: "wait", log: buildWaitLog(base) }, report, reviewSummaryIds);
122
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Render a ResolveCommand as a single-line command string for the monitor loop
3
+ * to print or execute. This is NOT a general-purpose POSIX escaper — it wraps
4
+ * the two known placeholders ($DISMISS_MESSAGE, $HEAD_SHA) and any whitespace-
5
+ * bearing arg in double quotes so multi-word values don't split across flags.
6
+ *
7
+ * Contract for callers substituting placeholders: replace the entire quoted
8
+ * token (including the surrounding `"`) with a properly shell-quoted literal.
9
+ * Do not splice raw text inside the existing quotes — the output would then
10
+ * re-expand `$…` / `$(…)` / embedded `"` and break.
11
+ */
12
+ export function renderResolveCommand(rc) {
13
+ // `$HEAD_SHA` is never in `rc.argv` — it is appended pre-quoted below when
14
+ // `requiresHeadSha`. Only `$DISMISS_MESSAGE` (or whitespace-bearing values)
15
+ // need quoting here.
16
+ const needsQuoting = (arg) => {
17
+ if (arg === "$DISMISS_MESSAGE")
18
+ return true;
19
+ // Assert no characters that would break the naive escaper are present in arg
20
+ if (/["$`\\]/.test(arg)) {
21
+ throw new Error(`Unexpected character in argv arg that needsQuoting can't handle: ${JSON.stringify(arg)}`);
22
+ }
23
+ return /\s/.test(arg);
24
+ };
25
+ const parts = rc.argv.map((a) => (needsQuoting(a) ? `"${a}"` : a));
26
+ if (rc.requiresHeadSha) {
27
+ parts.push("--require-sha", '"$HEAD_SHA"');
28
+ }
29
+ return parts.join(" ");
30
+ }
31
+ export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount) {
32
+ const instructions = [];
33
+ if (threads.length > 0 || actionableComments.length > 0) {
34
+ instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.`);
35
+ }
36
+ // Mirror the truthiness checks in `formatIterateResult` (cli/iterate-formatter.mts) so each
37
+ // AgentCheck maps to the same bullet shape here as there: runId → runId
38
+ // bullet, else detailsUrl → external bullet, else `(no runId)` bullet.
39
+ const checksWithRunId = checks.filter((c) => c.runId);
40
+ const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
41
+ const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
42
+ if (checksWithRunId.length > 0) {
43
+ instructions.push(`For each bullet in \`## Failing checks\` whose backticked locator is a numeric runId (GitHub Actions): run \`gh run view <runId> --log-failed\`, identify the failure, and apply the fix.`);
44
+ }
45
+ if (externalChecks.length > 0) {
46
+ instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — \`gh run view\` cannot fetch logs for external checks.`);
47
+ }
48
+ if (bareChecks.length > 0) {
49
+ instructions.push(`For each bullet in \`## Failing checks\` starting with \`(no runId)\`: there is no run or details URL to inspect. Escalate these to a human — they require manual investigation outside the pr-shepherd flow.`);
50
+ }
51
+ if (reviews.length > 0) {
52
+ instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
53
+ }
54
+ const hasCodeChanges = threads.length > 0 || actionableComments.length > 0 || checks.length > 0 || reviews.length > 0;
55
+ const needsPush = hasCodeChanges || hasConflicts;
56
+ if (hasCodeChanges) {
57
+ instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
58
+ instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
59
+ }
60
+ if (needsPush) {
61
+ const captureHint = resolveCommand.requiresHeadSha
62
+ ? ` — capture \`HEAD_SHA=$(git rev-parse HEAD)\``
63
+ : "";
64
+ if (hasConflicts) {
65
+ instructions.push(`Rebase with conflict resolution: run \`git fetch origin && git rebase origin/${baseBranch}\`. If the rebase halts with conflicts, edit the conflicted files to resolve them, \`git add <files>\`, then \`git rebase --continue\`. Repeat until the rebase completes, then \`git push --force-with-lease\`${captureHint}.`);
66
+ }
67
+ else {
68
+ instructions.push(`Rebase and push: \`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease\`${captureHint}`);
69
+ }
70
+ }
71
+ // Only tell the agent to run `resolve:` if the command actually mutates
72
+ // GitHub state. A CONFLICTS-only flow has nothing to mutate on GitHub.
73
+ if (resolveCommand.hasMutations) {
74
+ const substituteParts = [];
75
+ if (resolveCommand.requiresHeadSha) {
76
+ substituteParts.push(`"$HEAD_SHA" with the pushed commit SHA`);
77
+ }
78
+ if (resolveCommand.requiresDismissMessage) {
79
+ substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
80
+ }
81
+ const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
82
+ instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
83
+ }
84
+ if (needsPush && cancelledCount > 0) {
85
+ instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — the CLI cancelled those runs before your push, and your push has already triggered new runs with different IDs.`);
86
+ }
87
+ if (needsPush) {
88
+ instructions.push(`Stop this iteration — CI needs time to run on the new push before the next tick.`);
89
+ }
90
+ else if (resolveCommand.hasMutations) {
91
+ instructions.push(`Stop this iteration before the next tick.`);
92
+ }
93
+ else {
94
+ instructions.push(`End this iteration.`);
95
+ }
96
+ return instructions;
97
+ }
98
+ export function buildWaitLog(base) {
99
+ const { summary, mergeStateStatus, remainingSeconds } = base;
100
+ const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
101
+ switch (mergeStateStatus) {
102
+ case "BEHIND":
103
+ parts.push("branch is behind base");
104
+ break;
105
+ case "BLOCKED":
106
+ parts.push("blocked by pending reviews or required status checks");
107
+ break;
108
+ case "DRAFT":
109
+ parts.push("PR is a draft");
110
+ break;
111
+ case "UNSTABLE":
112
+ parts.push("some checks are unstable");
113
+ break;
114
+ }
115
+ if (remainingSeconds > 0) {
116
+ parts.push(`${remainingSeconds}s until auto-cancel`);
117
+ }
118
+ return parts.join(" — ");
119
+ }
@@ -0,0 +1,65 @@
1
+ import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
+ import { toAgentThread, toAgentComment } from "../../reporters/agent.mjs";
3
+ import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
4
+ export function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
5
+ const checks = [
6
+ ...report.checks.failing.map((f) => `failing:${f.name}:${f.failureKind ?? ""}`),
7
+ ...report.checks.inProgress.map((p) => `inProgress:${p.name}`),
8
+ ].sort();
9
+ const threads = report.threads.actionable.map((t) => t.id).sort();
10
+ const comments = report.comments.actionable.map((c) => c.id).sort();
11
+ const reviews = report.changesRequestedReviews.map((r) => r.id).sort();
12
+ const summaries = [...reviewSummaryIds].sort();
13
+ return JSON.stringify({
14
+ action,
15
+ headSha,
16
+ status: base.status,
17
+ mergeStateStatus: base.mergeStateStatus,
18
+ state: base.state,
19
+ isDraft: base.isDraft,
20
+ checks,
21
+ threads,
22
+ comments,
23
+ reviews,
24
+ summaries,
25
+ });
26
+ }
27
+ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
28
+ const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
29
+ const nowSeconds = Math.floor(Date.now() / 1000);
30
+ const stored = await readStallState(stallKey);
31
+ if (stored && stored.fingerprint === fingerprint) {
32
+ const ageSeconds = nowSeconds - stored.firstSeenAt;
33
+ if (ageSeconds < 0) {
34
+ // Clock skew: stored timestamp is in the future. Reset to avoid perpetually negative age.
35
+ await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
36
+ }
37
+ else if (stallTimeoutSeconds <= 0) {
38
+ // Stall detection disabled: refresh so re-enabling starts a fresh timer.
39
+ await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
40
+ }
41
+ else if (ageSeconds >= stallTimeoutSeconds) {
42
+ const stalledMinutes = Math.floor(ageSeconds / 60);
43
+ const escalateBase = {
44
+ triggers: ["stall-timeout"],
45
+ unresolvedThreads: report.threads.actionable.map(toAgentThread),
46
+ ambiguousComments: report.comments.actionable.map(toAgentComment),
47
+ changesRequestedReviews: report.changesRequestedReviews,
48
+ suggestion: buildEscalateSuggestion(["stall-timeout"], String(stalledMinutes)),
49
+ };
50
+ return {
51
+ ...base,
52
+ action: "escalate",
53
+ escalate: {
54
+ ...escalateBase,
55
+ humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
56
+ },
57
+ };
58
+ }
59
+ // Within threshold: preserve firstSeenAt, emit the original result.
60
+ return prospectiveResult;
61
+ }
62
+ // Fingerprint changed or no prior state — reset the stall timer.
63
+ await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
64
+ return prospectiveResult;
65
+ }
@@ -0,0 +1,31 @@
1
+ import { applyStallGuard } from "./stall.mjs";
2
+ export async function buildRerunCiResult(transientChecks, base, prNumber, stallKey, stallTimeoutSeconds, headSha, report, reviewSummaryIds) {
3
+ const runMap = new Map();
4
+ for (const c of transientChecks) {
5
+ if (c.runId === null)
6
+ continue;
7
+ const existing = runMap.get(c.runId);
8
+ if (existing) {
9
+ existing.checkNames.push(c.name);
10
+ }
11
+ else {
12
+ runMap.set(c.runId, {
13
+ runId: c.runId,
14
+ checkNames: [c.name],
15
+ failureKind: c.failureKind,
16
+ workflowName: c.workflowName,
17
+ });
18
+ }
19
+ }
20
+ const reran = [...runMap.values()];
21
+ const runSummaries = reran.map(({ runId, checkNames, failureKind, workflowName }) => {
22
+ const prefix = workflowName ? `${workflowName} › ` : "";
23
+ return `${runId} (${prefix}${checkNames.join(", ")} — ${failureKind})`;
24
+ });
25
+ return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
26
+ ...base,
27
+ action: "rerun_ci",
28
+ reran,
29
+ log: `RERUN NEEDED — ${reran.length} CI run${reran.length === 1 ? "" : "s"}: ${runSummaries.join(", ")}`,
30
+ }, report, reviewSummaryIds);
31
+ }