pr-shepherd 1.1.0 → 1.1.1

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 (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/checks/classify.d.mts +3 -1
  3. package/bin/checks/classify.mjs +8 -1
  4. package/bin/cli/triage-formatter.mjs +5 -1
  5. package/bin/commands/check-status.d.mts +6 -1
  6. package/bin/commands/check-status.mjs +9 -4
  7. package/bin/commands/check.mjs +4 -2
  8. package/bin/commands/iterate/escalate.mjs +4 -2
  9. package/bin/commands/poll-summary-explicit-instructions.d.mts +1 -1
  10. package/bin/commands/poll-summary-explicit-instructions.mjs +9 -7
  11. package/bin/commands/poll-summary-instructions.d.mts +2 -2
  12. package/bin/commands/poll-summary-instructions.mjs +16 -15
  13. package/bin/commands/poll-summary.mjs +15 -14
  14. package/bin/commands/ready-mergeability.d.mts +1 -1
  15. package/bin/commands/ready-mergeability.mjs +2 -2
  16. package/bin/commands/stack-drain.d.mts +1 -1
  17. package/bin/commands/stack-drain.mjs +3 -3
  18. package/bin/commands/stack-work.d.mts +4 -1
  19. package/bin/commands/stack-work.mjs +10 -2
  20. package/bin/commands/tick.mjs +1 -0
  21. package/bin/github/batch-parser-helpers.mjs +2 -0
  22. package/bin/github/batch-raw-types.d.mts +1 -0
  23. package/bin/github/github-fetch.d.mts +6 -2
  24. package/bin/github/github-fetch.mjs +37 -2
  25. package/bin/github/github-timeouts.d.mts +8 -0
  26. package/bin/github/github-timeouts.mjs +8 -0
  27. package/bin/github/gql/batch-pr-page.gql +1 -0
  28. package/bin/github/gql/batch-pr.gql +1 -0
  29. package/bin/github/gql/commit-check-contexts.gql +1 -0
  30. package/bin/github/gql/poll-summary-check-contexts.gql +1 -0
  31. package/bin/github/http-utils.mjs +3 -0
  32. package/bin/github/poll-summary-checks.mjs +1 -0
  33. package/bin/github/poll-summary-command.d.mts +7 -1
  34. package/bin/github/poll-summary-command.mjs +29 -0
  35. package/bin/github/poll-summary-projector.mjs +2 -1
  36. package/bin/github/poll-summary-raw.d.mts +1 -0
  37. package/bin/github/poll-summary-readiness.mjs +2 -0
  38. package/bin/github/poll-summary-route.mjs +2 -0
  39. package/bin/github/poll-summary-unreported.d.mts +7 -0
  40. package/bin/github/poll-summary-unreported.mjs +16 -0
  41. package/bin/github/rest-check-read.mjs +3 -1
  42. package/bin/github/rest-text.mjs +3 -2
  43. package/bin/github/stack-handoff-page.mjs +1 -0
  44. package/bin/reporters/agent.mjs +2 -0
  45. package/bin/types/github.d.mts +6 -0
  46. package/bin/types/report.d.mts +5 -1
  47. package/package.json +1 -1
  48. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  49. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  50. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  51. package/plugins/pr-shepherd/.mcp.json +1 -1
  52. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "1.1.0",
4
+ "version": "1.1.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -7,7 +7,9 @@
7
7
  * to PR readiness.
8
8
  * 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
9
9
  * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
- * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
10
+ * 3. A check that is not `COMPLETED` while its check suite is `COMPLETED` is stuck and
11
+ * classifies as `failing`.
12
+ * 4. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
11
13
  * run of the same workflow exists on the same commit and event, or when an
12
14
  * exact matching check from a lower-ID run started and succeeded later.
13
15
  * GitHub can start jobs out of workflow-run creation order.
@@ -7,7 +7,9 @@
7
7
  * to PR readiness.
8
8
  * 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
9
9
  * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
- * 3. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
10
+ * 3. A check that is not `COMPLETED` while its check suite is `COMPLETED` is stuck and
11
+ * classifies as `failing`.
12
+ * 4. Reclassify `CANCELLED` checks as "superseded" (non-blocking) when a newer
11
13
  * run of the same workflow exists on the same commit and event, or when an
12
14
  * exact matching check from a lower-ID run started and succeeded later.
13
15
  * GitHub can start jobs out of workflow-run creation order.
@@ -76,6 +78,11 @@ function classify(check, relevantEvents) {
76
78
  const { status, conclusion } = check;
77
79
  // Not yet finished.
78
80
  if (status !== "COMPLETED") {
81
+ // The enclosing suite finished while this run did not: nothing will update it again, so
82
+ // waiting would only end in the stall timeout. Surface it as failing work (rerun the
83
+ // workflow) with the raw status and suite status shown.
84
+ if (check.suiteStatus === "COMPLETED")
85
+ return { ...check, category: "failing" };
79
86
  return { ...check, category: "in_progress" };
80
87
  }
81
88
  // Skipped / neutral — report but don't block.
@@ -74,7 +74,11 @@ export function formatTriageResult(header, result, opts = {}) {
74
74
  : ch.detailsUrl
75
75
  ? `external \`${ch.detailsUrl}\``
76
76
  : "(no runId)";
77
- const conclusionTag = ch.conclusion !== null ? ` [conclusion: ${ch.conclusion}]` : "";
77
+ const conclusionTag = ch.status !== undefined
78
+ ? ` [status: ${ch.status}, suite: ${ch.suiteStatus}]`
79
+ : ch.conclusion !== null
80
+ ? ` [conclusion: ${ch.conclusion}]`
81
+ : "";
78
82
  const attemptTag = ch.runAttempt !== undefined ? ` [attempt: ${ch.runAttempt}]` : "";
79
83
  const scopeTag = ch.scope
80
84
  ? ` [scope: ${ch.scope}${ch.commitOid ? `, commit: ${ch.commitOid}` : ""}]`
@@ -1,3 +1,8 @@
1
1
  import type { MergeStatusResult, ShepherdStatus } from "../types.mts";
2
2
  import type { CiVerdict } from "../checks/classify.mts";
3
- export declare function computeStatus(verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, mergeStatus: MergeStatusResult, changesRequestedReviews: number, hasUnreportedRequired?: boolean): ShepherdStatus;
3
+ /**
4
+ * `awaitingChecks` is true when required checks have not reported, or when the PR has no relevant
5
+ * check yet while a relevant Actions workflow suite is still running: the suite exists before
6
+ * its check runs, so a CLEAN or DRAFT merge state is not yet proof that CI passed.
7
+ */
8
+ export declare function computeStatus(verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, mergeStatus: MergeStatusResult, changesRequestedReviews: number, awaitingChecks?: boolean): ShepherdStatus;
@@ -1,4 +1,9 @@
1
- export function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews, hasUnreportedRequired = false) {
1
+ /**
2
+ * `awaitingChecks` is true when required checks have not reported, or when the PR has no relevant
3
+ * check yet while a relevant Actions workflow suite is still running: the suite exists before
4
+ * its check runs, so a CLEAN or DRAFT merge state is not yet proof that CI passed.
5
+ */
6
+ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews, awaitingChecks = false) {
2
7
  // Merge conflicts are always terminal regardless of CI state.
3
8
  if (mergeStatus.status === "CONFLICTS")
4
9
  return "FAILING";
@@ -26,7 +31,7 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
26
31
  changesRequestedReviews === 0 &&
27
32
  (mergeStatus.status === "BLOCKED" || mergeStatus.status === "UNSTABLE") &&
28
33
  !mergeStatus.blockingBotReviewInProgress &&
29
- !hasUnreportedRequired) {
34
+ !awaitingChecks) {
30
35
  return "READY";
31
36
  }
32
37
  if (mergeStatus.status === "BLOCKED" ||
@@ -40,9 +45,9 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
40
45
  // DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
41
46
  if ((mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT") &&
42
47
  verdict.allPassed &&
43
- !hasUnreportedRequired)
48
+ !awaitingChecks)
44
49
  return "READY";
45
- if (hasUnreportedRequired && (mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT"))
50
+ if (awaitingChecks && (mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT"))
46
51
  return "PENDING";
47
52
  return "UNKNOWN";
48
53
  }
@@ -261,7 +261,9 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
261
261
  canGenerateItemMutation(review.id, review.body, batchData.viewerAuthorization?.viewerCanAdminister, "dismiss") ||
262
262
  visibleChangesRequestedIds.has(review.id));
263
263
  }).length;
264
- let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount, unreported.hasUnreportedRequired);
264
+ const awaitingChecks = unreported.hasUnreportedRequired ||
265
+ (!verdict.hasChecks && unreported.actionsWorkflowInProgress === true);
266
+ let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount, awaitingChecks);
265
267
  status = enforceTransportReadiness(status, batchData);
266
268
  // Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
267
269
  // before deciding what to persist below — deferWhileQueued must see the same final,
@@ -269,7 +271,7 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
269
271
  // snapshot. A conflict newly discovered by this REST read is exactly the kind of check-driven
270
272
  // signal that keeps a queued PR out of the deferral path.
271
273
  if (status === "READY" && !didRefreshMergeability) {
272
- const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount, unreported.hasUnreportedRequired);
274
+ const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount, awaitingChecks);
273
275
  batchData = refreshed.batchData;
274
276
  mergeStatus = refreshed.mergeStatus;
275
277
  status = refreshed.status;
@@ -43,10 +43,12 @@ function renderEscalateAnnotation(annotation) {
43
43
  function renderEscalateCheck(check) {
44
44
  const workflowPrefix = check.workflowName ? `${check.workflowName} › ` : "";
45
45
  const jobLabel = check.jobName ?? check.name;
46
- const conclusion = check.conclusion ?? "UNKNOWN";
46
+ const conclusionTag = check.status !== undefined
47
+ ? `[status: ${check.status}, suite: ${check.suiteStatus}]`
48
+ : `[conclusion: ${check.conclusion ?? "UNKNOWN"}]`;
47
49
  const attempt = check.runAttempt !== undefined ? ` [attempt: ${check.runAttempt}]` : "";
48
50
  const lines = [
49
- `- ${renderCheckTarget(check)} — \`${workflowPrefix}${jobLabel}\` [conclusion: ${conclusion}]${attempt}${renderCheckScope(check)}`,
51
+ `- ${renderCheckTarget(check)} — \`${workflowPrefix}${jobLabel}\` ${conclusionTag}${attempt}${renderCheckScope(check)}`,
50
52
  ];
51
53
  if (check.failedStep)
52
54
  lines.push(` > failed step: ${check.failedStep}`);
@@ -3,4 +3,4 @@ import type { PollSummaryResult } from "../types.mts";
3
3
  export declare function isHandedOffSelection(result: PollSummaryResult): boolean;
4
4
  /** A final full-review probe can finish this selection without another aggregate read. */
5
5
  export declare function onlyDraftVerificationSelection(result: PollSummaryResult): boolean;
6
- export declare function explicitInstructions(result: PollSummaryResult): string[];
6
+ export declare function explicitInstructions(result: PollSummaryResult, rerunCommand?: string): string[];
@@ -12,7 +12,10 @@ export function onlyDraftVerificationSelection(result) {
12
12
  item.action === "escalate" ||
13
13
  isDraftReadinessVerification(item)));
14
14
  }
15
- export function explicitInstructions(result) {
15
+ /** Rerunning is mandatory while any selected row is non-terminal; a finished one-PR poll is not the end. */
16
+ const KEEP_WATCHING = "Do not end the turn while any selected row is non-terminal: a one-PR poll returning `CANCEL` or `ESCALATE` finishes only that row, and rows that are `CANCEL`, `ESCALATE`, or handed off do not count.";
17
+ export function explicitInstructions(result, rerunCommand) {
18
+ const again = rerunCommand ? `\`${rerunCommand}\`` : "this aggregate command";
16
19
  if (result.reason === "all_terminal")
17
20
  return [
18
21
  result.prs.some((item) => item.reasons.includes("draft-auto-mark-ready-disabled"))
@@ -21,27 +24,26 @@ export function explicitInstructions(result) {
21
24
  ];
22
25
  if (result.quotaWarning && result.reason !== "actionable") {
23
26
  return [
24
- buildQuotaAwareContinuation(result.quotaWarning, "1. This aggregate selection is non-terminal. Before continuing,"),
27
+ buildQuotaAwareContinuation(result.quotaWarning, `1. This aggregate selection is non-terminal. Run ${again} again at the cadence below. ${KEEP_WATCHING} Before continuing,`),
25
28
  ];
26
29
  }
27
30
  if (result.reason === "waiting" || result.reason === "timeout") {
28
- return ["1. Run this aggregate command again when the caller is ready to recheck."];
31
+ return [`1. Run ${again} again when the caller is ready to recheck. ${KEEP_WATCHING}`];
29
32
  }
30
33
  const instructions = [
31
34
  "1. Choose each non-WAIT, non-CANCEL row that can proceed independently and run or delegate its exact `pollCommand`.",
32
35
  "2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
33
- "3. Run this aggregate poll again after selected work completes; one row's `ESCALATE` does not stop work on other rows.",
36
+ `3. Run ${again} again after selected work completes; one row's \`ESCALATE\` does not stop work on other rows. ${KEEP_WATCHING}`,
34
37
  ];
35
38
  if (onlyDraftVerificationSelection(result)) {
36
- instructions[2] =
37
- "3. If every draft verification probe returns `[CANCEL]` with reason `auto-mark-ready-disabled`, stop this selection and report any `ESCALATE` rows; leave the drafts unchanged. If any probe returns `[ESCALATE]`, report that handoff and stop after the other probes finish. Otherwise follow the probes' instructions and rerun this aggregate selector when that work completes.";
39
+ instructions[2] = `3. If every draft verification probe returns \`[CANCEL]\` with reason \`auto-mark-ready-disabled\`, stop this selection and report any \`ESCALATE\` rows; leave the drafts unchanged. If any probe returns \`[ESCALATE]\`, report that handoff and stop after the other probes finish. Otherwise follow the probes' instructions and rerun ${again} when that work completes.`;
38
40
  }
39
41
  else if (isHandedOffSelection(result)) {
40
42
  instructions[2] =
41
43
  "3. Stop after reporting each `ESCALATE` handoff; every selected row is `CANCEL` or `ESCALATE`, so do not run this aggregate poll again.";
42
44
  }
43
45
  else if (result.quotaWarning) {
44
- instructions[2] = buildQuotaAwareContinuation(result.quotaWarning, "3. After selected work completes,");
46
+ instructions[2] = buildQuotaAwareContinuation(result.quotaWarning, `3. After selected work completes, run ${again} again at the cadence below; one row's \`ESCALATE\` does not stop work on other rows. ${KEEP_WATCHING}`);
45
47
  }
46
48
  return instructions;
47
49
  }
@@ -1,8 +1,8 @@
1
1
  import type { PollSummaryItem, PollSummaryResult } from "../types.mts";
2
2
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
3
- export declare function withPollSummaryInstructions(result: PollSummaryResult, mergeRequested: boolean): PollSummaryResult;
3
+ export declare function withPollSummaryInstructions(result: PollSummaryResult, mergeRequested: boolean, rerunCommand?: string): PollSummaryResult;
4
4
  /** The projected summary, plus the idle layers when the stack plan can only wait on them. */
5
- export declare function planPollSummary(result: PollSummaryResult, mergeRequested: boolean): {
5
+ export declare function planPollSummary(result: PollSummaryResult, mergeRequested: boolean, rerunCommand?: string): {
6
6
  result: PollSummaryResult;
7
7
  idle?: PollSummaryItem[];
8
8
  };
@@ -2,15 +2,15 @@
2
2
  import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
3
3
  import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
4
4
  import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planPrefixDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
5
- import { appendStackRerunInstruction, onlyDraftReadinessVerification, splitStackWork, } from "./stack-work.mjs";
5
+ import { appendStackRerunInstruction, onlyDraftReadinessVerification, splitStackWork, stackRerunTarget, } from "./stack-work.mjs";
6
6
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
7
- export function withPollSummaryInstructions(result, mergeRequested) {
8
- return planPollSummary(result, mergeRequested).result;
7
+ export function withPollSummaryInstructions(result, mergeRequested, rerunCommand) {
8
+ return planPollSummary(result, mergeRequested, rerunCommand).result;
9
9
  }
10
10
  /** The projected summary, plus the idle layers when the stack plan can only wait on them. */
11
- export function planPollSummary(result, mergeRequested) {
11
+ export function planPollSummary(result, mergeRequested, rerunCommand) {
12
12
  if (result.selection.kind !== "stack") {
13
- return { result: { ...result, instructions: explicitInstructions(result) } };
13
+ return { result: { ...result, instructions: explicitInstructions(result, rerunCommand) } };
14
14
  }
15
15
  const prs = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
16
16
  const staleChildren = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
@@ -60,7 +60,7 @@ export function planPollSummary(result, mergeRequested) {
60
60
  return layer;
61
61
  }),
62
62
  };
63
- const planned = planStack(projected, mergeRequested);
63
+ const planned = planStack(projected, mergeRequested, rerunCommand);
64
64
  const instructions = [...planned.instructions];
65
65
  if (result.quotaWarning && planned.action === "shepherd") {
66
66
  instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
@@ -82,7 +82,8 @@ export function planPollSummary(result, mergeRequested) {
82
82
  ...(planned.idle && { idle: planned.idle }),
83
83
  };
84
84
  }
85
- function planStack(result, mergeRequested) {
85
+ function planStack(result, mergeRequested, rerunCommand) {
86
+ const again = stackRerunTarget(rerunCommand);
86
87
  const open = result.prs.filter((item) => item.state === "OPEN");
87
88
  const gaps = result.stackAncestry ?? [];
88
89
  const staleChildren = new Set(gaps.map((gap) => gap.childPr));
@@ -93,7 +94,7 @@ function planStack(result, mergeRequested) {
93
94
  const agentWork = runnableCandidates.length > 0;
94
95
  // Only owned work defers a human handoff: rerunning cannot advance another author's layer.
95
96
  const ownedWork = runnableCandidates.some((item) => item.owned);
96
- const drain = planPrefixDrain(result, mergeRequested);
97
+ const drain = planPrefixDrain(result, mergeRequested, rerunCommand);
97
98
  if (drain)
98
99
  return drain;
99
100
  const handoffs = findHumanHandoffs(result);
@@ -108,9 +109,9 @@ function planStack(result, mergeRequested) {
108
109
  if (ownedWork) {
109
110
  const rerun = onlyDraftReadinessVerification(work.sessions, work.idle) &&
110
111
  work.sessions.every((item) => item.owned)
111
- ? "If every listed one-PR probe returns `[CANCEL]` with reason `auto-mark-ready-disabled`, report the human handoff above and stop this `stack` command. If any probe returns `[ESCALATE]`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun this same `stack` command when that work completes."
112
- : "After the listed one-PR sessions, rerun this same `stack` command. Stop for the human handoff only when no autonomous shepherding remains.";
113
- appendStackRerunInstruction(instructions, runnableCandidates, rerun);
112
+ ? `If every listed one-PR probe returns \`[CANCEL]\` with reason \`auto-mark-ready-disabled\`, report the human handoff above and stop this \`stack\` command. If any probe returns \`[ESCALATE]\`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun ${again} when that work completes.`
113
+ : `After the listed one-PR sessions, rerun ${again}. Stop for the human handoff only when no autonomous shepherding remains.`;
114
+ appendStackRerunInstruction(instructions, runnableCandidates, rerun, false);
114
115
  }
115
116
  return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
116
117
  }
@@ -129,7 +130,7 @@ function planStack(result, mergeRequested) {
129
130
  instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${ownedWork ? "After autonomous shepherding, ask" : "Stop and ask"} for direction.`);
130
131
  }
131
132
  if (ownedWork) {
132
- appendStackRerunInstruction(instructions, runnableCandidates, "After the listed one-PR sessions, rerun this same `stack` command. Stop for the human handoff only when no autonomous shepherding remains.");
133
+ appendStackRerunInstruction(instructions, runnableCandidates, `After the listed one-PR sessions, rerun ${again}. Stop for the human handoff only when no autonomous shepherding remains.`, false);
133
134
  }
134
135
  return { action: ownedWork ? "shepherd" : "escalate", stackMergeable: false, instructions };
135
136
  }
@@ -149,10 +150,10 @@ function planStack(result, mergeRequested) {
149
150
  appendAutonomousInstructions(instructions, work.sessions);
150
151
  if (onlyDraftReadinessVerification(work.sessions, work.idle) &&
151
152
  work.sessions.every((item) => item.owned)) {
152
- instructions.push(`${instructions.length + 1}. If every listed one-PR probe returns \`[CANCEL]\` with reason \`auto-mark-ready-disabled\`, stop shepherding this stack and leave those drafts unchanged. If any probe returns \`[ESCALATE]\`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun this same \`stack\` command when that work completes.`);
153
+ instructions.push(`${instructions.length + 1}. If every listed one-PR probe returns \`[CANCEL]\` with reason \`auto-mark-ready-disabled\`, stop shepherding this stack and leave those drafts unchanged. If any probe returns \`[ESCALATE]\`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun ${again} when that work completes.`);
153
154
  return { action: "shepherd", stackMergeable: false, instructions };
154
155
  }
155
- const action = appendStackRerunInstruction(instructions, work.sessions, "After the selected one-PR sessions, rerun this same `stack` command.");
156
+ const action = appendStackRerunInstruction(instructions, work.sessions, `After the selected one-PR sessions, rerun ${again}.`);
156
157
  return { action, stackMergeable: false, instructions };
157
158
  }
158
159
  if (open.some((item) => item.isInMergeQueue)) {
@@ -176,7 +177,7 @@ function planStack(result, mergeRequested) {
176
177
  const instructions = [];
177
178
  const sessions = open.filter((item) => item.action !== "cancel");
178
179
  appendAutonomousInstructions(instructions, sessions);
179
- const action = appendStackRerunInstruction(instructions, sessions, "After the selected one-PR sessions, rerun this same `stack` command.");
180
+ const action = appendStackRerunInstruction(instructions, sessions, `After the selected one-PR sessions, rerun ${again}.`);
180
181
  return { action, stackMergeable, instructions };
181
182
  }
182
183
  return retargetWaitPlan(open[0]);
@@ -4,6 +4,7 @@ import { ShepherdError } from "../exit-codes.mjs";
4
4
  import { getRepoInfo } from "../github/client.mjs";
5
5
  import { getGithubTransport } from "../github/transport.mjs";
6
6
  import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
7
+ import { aggregateRerunCommand } from "../github/poll-summary-command.mjs";
7
8
  import { fetchPollSummary } from "../github/poll-summary.mjs";
8
9
  import { writeStackHandoffs } from "../github/stack-handoff.mjs";
9
10
  import { sleep } from "../util/sleep.mjs";
@@ -19,7 +20,7 @@ export function runPollSummary(opts) {
19
20
  return withApiTelemetryScope(async () => {
20
21
  const tick = await readPollSummaryTick(opts);
21
22
  await handOffRoutedLayers(tick);
22
- return attachUsage(tick.result, opts.merge);
23
+ return attachUsage(tick.result, opts);
23
24
  });
24
25
  }
25
26
  export function runAggregatePoll(opts) {
@@ -60,7 +61,7 @@ async function readPollSummaryTick(opts) {
60
61
  prs: fetched.prs,
61
62
  ...(fetched.stackAncestry?.length && { stackAncestry: fetched.stackAncestry }),
62
63
  ...(fetched.allowedMergeMethods && { allowedMergeMethods: fetched.allowedMergeMethods }),
63
- }, opts.merge === true);
64
+ }, opts.merge === true, aggregateRerunCommand(`${repo.owner}/${repo.name}`, fetched.selection, opts));
64
65
  const result = await applyStackStallGuard(planned, repo, opts.stallTimeoutSeconds ?? loadConfig().iterate["stall-timeout-minutes"] * 60);
65
66
  return { result, repo, ...(fetched.handoffSource && { handoffSource: fetched.handoffSource }) };
66
67
  }
@@ -104,7 +105,7 @@ async function runAggregatePollCore(opts) {
104
105
  stackMergeable: true,
105
106
  instructions: ["1. Stop — every stack layer is merged."],
106
107
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
107
- }, opts.merge);
108
+ }, opts);
108
109
  }
109
110
  return attachUsage({
110
111
  ...explicit,
@@ -116,7 +117,7 @@ async function runAggregatePollCore(opts) {
116
117
  `1. Native stack #${last.selection.stackNumber} disappeared before every tracked layer merged. Stop and ask the stack owner to reconcile the remaining PRs; do not claim the stack is complete.`,
117
118
  ],
118
119
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
119
- }, opts.merge);
120
+ }, opts);
120
121
  }
121
122
  const early = await rateLimitRetry.wait(error, {
122
123
  untilTerminal: opts.untilTerminal === true,
@@ -127,7 +128,7 @@ async function runAggregatePollCore(opts) {
127
128
  onAllTerminal: (pulls) => aggregateCancelFromPulls(last, opts.targetRepository, pulls),
128
129
  });
129
130
  if (early) {
130
- return presentProbedCancel(pendingQuotaWarning ? { ...early, quotaWarning: pendingQuotaWarning } : early, opts.merge);
131
+ return presentProbedCancel(pendingQuotaWarning ? { ...early, quotaWarning: pendingQuotaWarning } : early, opts);
131
132
  }
132
133
  continue;
133
134
  }
@@ -148,14 +149,14 @@ async function runAggregatePollCore(opts) {
148
149
  ...last,
149
150
  reason: "all_terminal",
150
151
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
151
- }, opts.merge);
152
+ }, opts);
152
153
  }
153
154
  if (immediate) {
154
155
  return attachUsage({
155
156
  ...last,
156
157
  reason: "actionable",
157
158
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
158
- }, opts.merge);
159
+ }, opts);
159
160
  }
160
161
  if (hasFix) {
161
162
  if (debounceMs === 0 || Date.now() >= (debounceUntil ??= Date.now() + debounceMs)) {
@@ -164,13 +165,13 @@ async function runAggregatePollCore(opts) {
164
165
  ...last,
165
166
  reason: "actionable",
166
167
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
167
- }, opts.merge);
168
+ }, opts);
168
169
  }
169
170
  }
170
171
  else {
171
172
  debounceUntil = null;
172
173
  if (opts.untilTerminal && pendingQuotaWarning) {
173
- return attachUsage({ ...last, quotaWarning: pendingQuotaWarning }, opts.merge);
174
+ return attachUsage({ ...last, quotaWarning: pendingQuotaWarning }, opts);
174
175
  }
175
176
  }
176
177
  const elapsedMs = Date.now() - start;
@@ -180,7 +181,7 @@ async function runAggregatePollCore(opts) {
180
181
  if (!opts.untilTerminal && debounceUntil === null) {
181
182
  const remainingMs = timeoutMs - elapsedMs;
182
183
  if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
183
- return attachUsage({ ...last, reason: "timeout" }, opts.merge);
184
+ return attachUsage({ ...last, reason: "timeout" }, opts);
184
185
  }
185
186
  }
186
187
  const statusSignature = summaryStatusSignature(last);
@@ -199,15 +200,15 @@ function writeAggregateRuleAutoResolve(result) {
199
200
  if (item.ruleAutoResolve?.summary)
200
201
  process.stderr.write(`#${item.pr} ${item.ruleAutoResolve.summary}\n`);
201
202
  }
202
- function presentProbedCancel(result, merge) {
203
+ function presentProbedCancel(result, opts) {
203
204
  // The stack planner already cancels an all-merged stack and escalates a
204
205
  // closed-unmerged layer. Do not rewrite that into a successful stop.
205
- return attachUsage({ ...result, reason: "all_terminal" }, merge);
206
+ return attachUsage({ ...result, reason: "all_terminal" }, opts);
206
207
  }
207
208
  function isMissingStack(error) {
208
209
  return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
209
210
  }
210
- function attachUsage(result, mergeRequested) {
211
+ function attachUsage(result, opts) {
211
212
  const apiUsage = summarizeApiTelemetry();
212
213
  const enriched = apiUsage ? { ...result, apiUsage } : result;
213
214
  // A disappeared native stack is an aggregate-level terminal handoff. Keep
@@ -216,5 +217,5 @@ function attachUsage(result, mergeRequested) {
216
217
  if (enriched.selection.kind === "stack" && enriched.nextAction === "escalate") {
217
218
  return enriched;
218
219
  }
219
- return withPollSummaryInstructions(enriched, mergeRequested === true);
220
+ return withPollSummaryInstructions(enriched, opts.merge === true, aggregateRerunCommand(enriched.repo, enriched.selection, opts));
220
221
  }
@@ -10,6 +10,6 @@ export declare function refreshUnknownMergeability(prNumber: number, repo: RepoI
10
10
  batchData: BatchPrData;
11
11
  didRefresh: boolean;
12
12
  }>;
13
- export declare function refreshReadyMergeability(prNumber: number, repo: RepoInfo, batchData: BatchPrData, verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, changesRequestedCount: number, hasUnreportedRequired?: boolean): Promise<ReadyMergeabilityRefresh>;
13
+ export declare function refreshReadyMergeability(prNumber: number, repo: RepoInfo, batchData: BatchPrData, verdict: CiVerdict, unresolvedThreads: number, unresolvedComments: number, changesRequestedCount: number, awaitingChecks?: boolean): Promise<ReadyMergeabilityRefresh>;
14
14
  export declare function isBlockedByFilteredCheck(mergeStatus: MergeStatusResult, verdict: CiVerdict): boolean;
15
15
  export {};
@@ -8,10 +8,10 @@ export async function refreshUnknownMergeability(prNumber, repo, batchData) {
8
8
  }
9
9
  return { batchData: await readMergeability(prNumber, repo, batchData), didRefresh: true };
10
10
  }
11
- export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments, changesRequestedCount, hasUnreportedRequired = false) {
11
+ export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments, changesRequestedCount, awaitingChecks = false) {
12
12
  const refreshedBatchData = await readMergeability(prNumber, repo, batchData);
13
13
  const mergeStatus = deriveMergeStatus(refreshedBatchData);
14
- const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedCount, hasUnreportedRequired);
14
+ const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedCount, awaitingChecks);
15
15
  return { batchData: refreshedBatchData, mergeStatus, status };
16
16
  }
17
17
  export function isBlockedByFilteredCheck(mergeStatus, verdict) {
@@ -8,7 +8,7 @@ export interface StackPlan {
8
8
  idle?: PollSummaryItem[];
9
9
  instructions: string[];
10
10
  }
11
- export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean): StackPlan | undefined;
11
+ export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean, rerunCommand?: string): StackPlan | undefined;
12
12
  /**
13
13
  * A merge-requested, fully ready stack whose bottom open layer still targets
14
14
  * the merged layer below it until GitHub retargets it onto the stack base.
@@ -2,7 +2,7 @@ import { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-hando
2
2
  export { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-handoffs.mjs";
3
3
  import { stackMergeFlag } from "./stack-merge-flag.mjs";
4
4
  import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
5
- import { splitStackWork } from "./stack-work.mjs";
5
+ import { splitStackWork, stackRerunTarget } from "./stack-work.mjs";
6
6
  import { getGithubTransport } from "../github/transport.mjs";
7
7
  import { buildMergeCommandPlan, renderMergeCommand } from "./iterate/merge.mjs";
8
8
  function readyPrefixTop(result) {
@@ -28,7 +28,7 @@ function readyPrefixTop(result) {
28
28
  }
29
29
  return top;
30
30
  }
31
- export function planPrefixDrain(result, mergeRequested) {
31
+ export function planPrefixDrain(result, mergeRequested, rerunCommand) {
32
32
  if (!mergeRequested)
33
33
  return undefined;
34
34
  const top = readyPrefixTop(result);
@@ -92,7 +92,7 @@ export function planPrefixDrain(result, mergeRequested) {
92
92
  const handoffs = findHumanHandoffs(result);
93
93
  if (handoffs)
94
94
  appendHumanHandoffInstructions(instructions, handoffs, false);
95
- instructions.push(`${instructions.length + 1}. After the merge attempt, rerun this same \`stack --merge\` command; GitHub retargets the next layer onto \`${top.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
95
+ instructions.push(`${instructions.length + 1}. After the merge attempt, rerun ${rerunCommand ? stackRerunTarget(rerunCommand) : "this same `stack --merge` command"}; GitHub retargets the next layer onto \`${top.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
96
96
  return {
97
97
  action: "merge",
98
98
  stackMergeable: gaps.length === 0 && open.every(isStackLayerReady),
@@ -17,8 +17,11 @@ export declare function onlyDraftReadinessVerification(sessions: PollSummaryItem
17
17
  * A stale-ancestry child keeps its session, which returns the ancestry repair.
18
18
  */
19
19
  export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
20
+ /** The literal stack command to rerun, or the generic name when the caller's command is unknown. */
21
+ export declare function stackRerunTarget(command?: string): string;
20
22
  /**
21
23
  * The closing step after routed sessions: rerun the stack command, or stop when nothing is owned.
22
24
  * Returns the plan action: a stop is a handoff, so no rerun, quota cadence, or wake-up follows it.
25
+ * `keepWatching` is false when a human handoff may end the run before every layer is terminal.
23
26
  */
24
- export declare function appendStackRerunInstruction(instructions: string[], layers: PollSummaryItem[], rerun: string): "shepherd" | "escalate";
27
+ export declare function appendStackRerunInstruction(instructions: string[], layers: PollSummaryItem[], rerun: string, keepWatching?: boolean): "shepherd" | "escalate";
@@ -35,12 +35,20 @@ function isProbed(item) {
35
35
  function onlyUnownedWork(layers) {
36
36
  return layers.length > 0 && layers.every((item) => !item.owned);
37
37
  }
38
+ /** The turn must outlive every non-terminal layer; a one-PR session ending does not end the stack. */
39
+ const STACK_KEEP_WATCHING = "Keep watching until every layer is terminal.";
40
+ /** The literal stack command to rerun, or the generic name when the caller's command is unknown. */
41
+ export function stackRerunTarget(command) {
42
+ return command ? `\`${command}\`` : "this same `stack` command";
43
+ }
38
44
  /**
39
45
  * The closing step after routed sessions: rerun the stack command, or stop when nothing is owned.
40
46
  * Returns the plan action: a stop is a handoff, so no rerun, quota cadence, or wake-up follows it.
47
+ * `keepWatching` is false when a human handoff may end the run before every layer is terminal.
41
48
  */
42
- export function appendStackRerunInstruction(instructions, layers, rerun) {
49
+ export function appendStackRerunInstruction(instructions, layers, rerun, keepWatching = true) {
43
50
  const stop = onlyUnownedWork(layers);
44
- instructions.push(`${instructions.length + 1}. ${stop ? "No owned layer needs a session. Report this overview and stop." : rerun}`);
51
+ const step = keepWatching ? `${rerun} ${STACK_KEEP_WATCHING}` : rerun;
52
+ instructions.push(`${instructions.length + 1}. ${stop ? "No owned layer needs a session. Report this overview and stop." : step}`);
45
53
  return stop ? "escalate" : "shepherd";
46
54
  }
@@ -86,6 +86,7 @@ async function withAggregateNextCheck(result, opts) {
86
86
  function withoutPollingCadence(step) {
87
87
  return step
88
88
  .replace(/ Recheck(?: them)? at the configured polling cadence\./g, "")
89
+ .replace(/Do not end the turn while any selected row is non-terminal: /g, "Keep watching every non-terminal selected row through the wake-up below: ")
89
90
  .replace("rerun that command at the configured cadence to resume its UUID", "rerun that command after the wake-up below to resume its UUID");
90
91
  }
91
92
  function aggregateNextCheck(result, stallDeadlineSeconds) {
@@ -28,6 +28,7 @@ export function mapCheckRunNode(node) {
28
28
  const summary = extractCheckRunSummary(node.title, node.summary);
29
29
  const rawCreatedAt = node.checkSuite?.workflowRun?.createdAt ?? node.checkSuite?.createdAt;
30
30
  const rawUpdatedAt = node.checkSuite?.workflowRun?.updatedAt ?? node.checkSuite?.updatedAt;
31
+ const suiteStatus = node.checkSuite?.status || undefined;
31
32
  const createdAtUnix = rawCreatedAt ? parseCreatedAt(rawCreatedAt) : undefined;
32
33
  const startedAtUnix = node.startedAt ? parseCreatedAt(node.startedAt) : undefined;
33
34
  const completedAtUnix = node.completedAt ? parseCreatedAt(node.completedAt) : undefined;
@@ -43,6 +44,7 @@ export function mapCheckRunNode(node) {
43
44
  runId,
44
45
  ...(workflowName !== undefined && { workflowName }),
45
46
  ...(workflowId !== undefined && { workflowId }),
47
+ ...(suiteStatus !== undefined && { suiteStatus }),
46
48
  ...(createdAtUnix !== undefined && { createdAtUnix }),
47
49
  ...(startedAtUnix !== undefined && { startedAtUnix }),
48
50
  ...(completedAtUnix !== undefined && { completedAtUnix }),
@@ -234,6 +234,7 @@ export type RawContextNode = {
234
234
  }>;
235
235
  };
236
236
  checkSuite: {
237
+ status?: string | null;
237
238
  createdAt?: string;
238
239
  updatedAt?: string;
239
240
  workflowRun: {
@@ -1,2 +1,6 @@
1
- /** Attach proxy routing to GitHub requests without changing the host's dispatcher. */
2
- export declare function githubFetch(input: string | URL, init?: RequestInit): Promise<Response>;
1
+ /**
2
+ * Attach proxy routing to GitHub requests without changing the host's dispatcher.
3
+ * The request, including reading its body, is bounded by `timeoutMs`; a stall
4
+ * rejects with an ETIMEDOUT error that the transport retry treats as transient.
5
+ */
6
+ export declare function githubFetch(input: string | URL, init?: RequestInit, timeoutMs?: number): Promise<Response>;
@@ -1,5 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createCapturedProxyAgent } from "./captured-proxy-agent.mjs";
3
+ import { GITHUB_API_TIMEOUT_MS } from "./github-timeouts.mjs";
4
+ import { redactUrl } from "./http-utils.mjs";
3
5
  import { loadUndiciKeepingLegacyDispatcher } from "./legacy-dispatcher.mjs";
4
6
  const require = createRequire(import.meta.url);
5
7
  let proxyAgent;
@@ -9,8 +11,41 @@ function loadUndici() {
9
11
  undiciModule ??= loadUndiciKeepingLegacyDispatcher(() => require("undici"));
10
12
  return undiciModule;
11
13
  }
12
- /** Attach proxy routing to GitHub requests without changing the host's dispatcher. */
13
- export async function githubFetch(input, init) {
14
+ /**
15
+ * Attach proxy routing to GitHub requests without changing the host's dispatcher.
16
+ * The request, including reading its body, is bounded by `timeoutMs`; a stall
17
+ * rejects with an ETIMEDOUT error that the transport retry treats as transient.
18
+ */
19
+ export async function githubFetch(input, init, timeoutMs = GITHUB_API_TIMEOUT_MS) {
20
+ const deadline = AbortSignal.timeout(timeoutMs);
21
+ const signal = init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline;
22
+ try {
23
+ return await bufferBody(await routedFetch(input, { ...init, signal }));
24
+ }
25
+ catch (err) {
26
+ if (deadline.aborted && !init?.signal?.aborted) {
27
+ const method = init?.method ?? "GET";
28
+ throw Object.assign(new Error(`GitHub request timed out after ${timeoutMs / 1000}s: ${method} ${redactUrl(String(input))}`, { cause: err }), { code: "ETIMEDOUT" });
29
+ }
30
+ throw err;
31
+ }
32
+ }
33
+ /**
34
+ * Read a streamed body inside the deadline and the callers' transport retry, so a
35
+ * body that stalls after the headers is retried like a stalled request. Callers
36
+ * read every body in full anyway.
37
+ */
38
+ async function bufferBody(response) {
39
+ if (typeof response.body?.getReader !== "function")
40
+ return response;
41
+ const bytes = await response.arrayBuffer();
42
+ return new Response(bytes.byteLength > 0 ? bytes : null, {
43
+ status: response.status,
44
+ statusText: response.statusText,
45
+ headers: response.headers,
46
+ });
47
+ }
48
+ async function routedFetch(input, init) {
14
49
  const httpProxy = process.env["http_proxy"] ?? process.env["HTTP_PROXY"] ?? "";
15
50
  const httpsProxy = process.env["https_proxy"] ?? process.env["HTTPS_PROXY"] ?? "";
16
51
  if (!httpProxy && !httpsProxy)
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Deadline for one GitHub REST or GraphQL request, headers and body included.
3
+ * Without it a half-open connection waits for undici's 300s headers/body
4
+ * defaults (10s for connect) and the tick prints nothing meanwhile.
5
+ */
6
+ export declare const GITHUB_API_TIMEOUT_MS = 60000;
7
+ /** Deadline for Actions log downloads, which can be several megabytes. */
8
+ export declare const GITHUB_LOG_TIMEOUT_MS = 180000;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Deadline for one GitHub REST or GraphQL request, headers and body included.
3
+ * Without it a half-open connection waits for undici's 300s headers/body
4
+ * defaults (10s for connect) and the tick prints nothing meanwhile.
5
+ */
6
+ export const GITHUB_API_TIMEOUT_MS = 60_000;
7
+ /** Deadline for Actions log downloads, which can be several megabytes. */
8
+ export const GITHUB_LOG_TIMEOUT_MS = 180_000;
@@ -196,6 +196,7 @@ query BatchPrPage(
196
196
  }
197
197
  }
198
198
  checkSuite {
199
+ status
199
200
  createdAt
200
201
  updatedAt
201
202
  workflowRun {
@@ -356,6 +356,7 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
356
356
  }
357
357
  }
358
358
  checkSuite {
359
+ status
359
360
  createdAt
360
361
  updatedAt
361
362
  workflowRun {
@@ -53,6 +53,7 @@ query CommitCheckContexts($owner: String!, $repo: String!, $oid: String!, $curso
53
53
  }
54
54
  }
55
55
  checkSuite {
56
+ status
56
57
  createdAt
57
58
  updatedAt
58
59
  workflowRun {
@@ -15,6 +15,7 @@ fragment PollSummaryCheckContexts on StatusCheckRollupContextConnection {
15
15
  startedAt
16
16
  completedAt
17
17
  checkSuite {
18
+ status
18
19
  createdAt
19
20
  updatedAt
20
21
  workflowRun {
@@ -15,6 +15,9 @@ const TRANSPORT_ERROR_CODES = new Set([
15
15
  export function isTransportError(err) {
16
16
  if (!(err instanceof Error))
17
17
  return false;
18
+ // The request deadline aborts a stalled body read with a TimeoutError.
19
+ if (err.name === "TimeoutError")
20
+ return true;
18
21
  const code = err.code;
19
22
  if (code && TRANSPORT_ERROR_CODES.has(code))
20
23
  return true;
@@ -93,6 +93,7 @@ function checkRuns(rollup, scope) {
93
93
  ...(run?.workflow?.databaseId != null && {
94
94
  workflowId: String(run.workflow.databaseId),
95
95
  }),
96
+ ...(context.checkSuite?.status && { suiteStatus: context.checkSuite.status }),
96
97
  ...(context.startedAt && { startedAtUnix: parseCreatedAt(context.startedAt) }),
97
98
  ...(context.completedAt && { completedAtUnix: parseCreatedAt(context.completedAt) }),
98
99
  ...(scope && { scope }),
@@ -1,2 +1,8 @@
1
- import type { PollSummaryCommandOptions, PollSummaryItem } from "../types.mts";
1
+ import type { PollSummaryCommandOptions, PollSummaryItem, PollSummarySelection } from "../types.mts";
2
2
  export declare function pollCommandFields(repo: string, pr: number, isDraft: boolean, opts: PollSummaryCommandOptions): Pick<PollSummaryItem, "pollCommand" | "pollProbe">;
3
+ /**
4
+ * The literal command that re-runs this aggregate selection with the options the caller used.
5
+ * Intervals and timeouts are omitted: they are loop cadence, not selection. Options equal to the
6
+ * configured defaults are omitted too, since the parser fills them from config.
7
+ */
8
+ export declare function aggregateRerunCommand(repo: string, selection: PollSummarySelection, opts: PollSummaryCommandOptions): string;
@@ -24,3 +24,32 @@ export function pollCommandFields(repo, pr, isDraft, opts) {
24
24
  const pollCommand = buildPrShepherdCommand(args).text;
25
25
  return boundedDraft ? { pollCommand, pollProbe: true } : { pollCommand };
26
26
  }
27
+ /**
28
+ * The literal command that re-runs this aggregate selection with the options the caller used.
29
+ * Intervals and timeouts are omitted: they are loop cadence, not selection. Options equal to the
30
+ * configured defaults are omitted too, since the parser fills them from config.
31
+ */
32
+ export function aggregateRerunCommand(repo, selection, opts) {
33
+ const args = selection.kind === "stack"
34
+ ? ["stack", formatPrUrl(repo, selection.anchor), opts.tick ? "--once" : "--until-terminal"]
35
+ : [
36
+ ...(opts.tick ? ["iterate"] : []),
37
+ ...selection.requested.map((pr) => formatPrUrl(repo, pr)),
38
+ ...(opts.tick ? [] : ["--until-terminal"]),
39
+ ];
40
+ if (opts.merge)
41
+ args.push("--merge");
42
+ // The parser always fills these from config, so a default value adds nothing to the command.
43
+ const iterate = loadConfig().iterate;
44
+ if (opts.readyDelaySeconds !== undefined &&
45
+ opts.readyDelaySeconds !== iterate["ready-delay-minutes"] * 60)
46
+ args.push("--ready-delay", `${opts.readyDelaySeconds}s`);
47
+ if (opts.stallTimeoutSeconds !== undefined &&
48
+ opts.stallTimeoutSeconds !== iterate["stall-timeout-minutes"] * 60)
49
+ args.push("--stall-timeout", `${opts.stallTimeoutSeconds}s`);
50
+ if (opts.instructions)
51
+ args.push("--instructions", opts.instructions);
52
+ if (opts.noAutoMarkReady)
53
+ args.push("--no-auto-mark-ready");
54
+ return buildPrShepherdCommand(args).text;
55
+ }
@@ -12,7 +12,7 @@ import { isCurrentSummaryReady } from "./poll-summary-readiness.mjs";
12
12
  import { applyOpenCheckBlockers } from "./poll-summary-check-blockers.mjs";
13
13
  import { normalizePollSummaryState, routePollSummary } from "./poll-summary-route.mjs";
14
14
  import { hydrateReadyAnnotationProbe, readyFingerprint } from "./poll-summary-annotation-probe.mjs";
15
- import { applyUnreportedRequiredChecks, summaryRequiredContexts, } from "./poll-summary-unreported.mjs";
15
+ import { applyPendingWorkflowSuites, applyUnreportedRequiredChecks, summaryRequiredContexts, } from "./poll-summary-unreported.mjs";
16
16
  import { aliasThreadSeenMarkers } from "./rest-identities.mjs";
17
17
  import { restQueueRequirement } from "./poll-summary-rest-queue.mjs";
18
18
  import { detectBlockingReviewer } from "./poll-summary-blocking-reviewer.mjs";
@@ -25,6 +25,7 @@ applyRuleAutoResolve = false) {
25
25
  const required = summaryRequiredContexts(raw, mergeTargetContexts);
26
26
  const checks = summarizePollSummaryChecks(raw, required);
27
27
  applyUnreportedRequiredChecks(raw, checks, required);
28
+ applyPendingWorkflowSuites(raw, checks);
28
29
  const { review, ruleAutoResolve } = await evaluatePollSummaryReview(raw, seen, viewerCanAdminister, applyRuleAutoResolve && normalizePollSummaryState(raw.state) === "OPEN"
29
30
  ? { owner: repo.owner, repo: repo.name, pr: raw.number }
30
31
  : undefined);
@@ -46,6 +46,7 @@ type RawCheckContext = {
46
46
  totalCount: number;
47
47
  };
48
48
  checkSuite: {
49
+ status?: string | null;
49
50
  createdAt?: string | null;
50
51
  updatedAt?: string | null;
51
52
  workflowRun: {
@@ -1,6 +1,7 @@
1
1
  import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
2
2
  import { parseBranchRules } from "./batch-parsers-rules.mjs";
3
3
  import { rulesComplete } from "./fingerprint-fields.mjs";
4
+ import { hasNoRelevantChecks } from "./poll-summary-unreported.mjs";
4
5
  import { isKnownMergeStateStatus, transportReadinessGaps } from "./transport-evidence.mjs";
5
6
  /**
6
7
  * Fresh compact evidence required before a READY receipt can be used.
@@ -42,5 +43,6 @@ export function isCurrentSummaryReady(raw, checks, review, options = {}) {
42
43
  (sourceChecks.failing ?? 0) === 0 &&
43
44
  (sourceChecks.inProgress ?? 0) === 0 &&
44
45
  (checks.unreportedRequired?.length ?? 0) === 0 &&
46
+ !(checks.actionsWorkflowInProgress === true && hasNoRelevantChecks(checks)) &&
45
47
  (review.actionable ?? 0) === 0);
46
48
  }
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../config/load.mjs";
2
+ import { hasNoRelevantChecks } from "./poll-summary-unreported.mjs";
2
3
  import { isKnownMergeStateStatus, transportReadinessGaps } from "./transport-evidence.mjs";
3
4
  import { canGenerateGithubMutation } from "./mutation-policy.mjs";
4
5
  import { getGithubTransport, isCcrTransport } from "./transport.mjs";
@@ -25,6 +26,7 @@ export function routePollSummary(raw, checks, review, opts) {
25
26
  return { action: "triage", reasons: ["unreported-required-checks"] };
26
27
  }
27
28
  if ((checks.inProgress ?? 0) > 0 ||
29
+ (checks.actionsWorkflowInProgress === true && hasNoRelevantChecks(checks)) ||
28
30
  !isKnownMergeStateStatus(raw.mergeStateStatus) ||
29
31
  raw.mergeable === "UNKNOWN" ||
30
32
  raw.mergeStateStatus === "UNKNOWN")
@@ -7,3 +7,10 @@ export declare function trunkRequiredContexts(ordered: readonly RawSummaryPr[],
7
7
  export declare function summaryRequiredContexts(raw: RawSummaryPr, mergeTargetContexts: readonly string[] | undefined): string[];
8
8
  /** Record missing required contexts. A running Actions workflow does not clear the names. */
9
9
  export declare function applyUnreportedRequiredChecks(raw: RawSummaryPr, checks: PollSummaryChecks, required: readonly string[]): void;
10
+ /** No relevant check yet. A skipped, filtered, ignored or superseded check is not relevant. */
11
+ export declare function hasNoRelevantChecks(checks: PollSummaryChecks): boolean;
12
+ /**
13
+ * Flag a running Actions workflow suite when the head has no relevant check yet. Suites exist
14
+ * before their check runs, so CLEAN with no checks is not proof that CI passed.
15
+ */
16
+ export declare function applyPendingWorkflowSuites(raw: RawSummaryPr, checks: PollSummaryChecks): void;
@@ -37,6 +37,22 @@ export function applyUnreportedRequiredChecks(raw, checks, required) {
37
37
  checks.actionsWorkflowInProgress = true;
38
38
  }
39
39
  }
40
+ /** No relevant check yet. A skipped, filtered, ignored or superseded check is not relevant. */
41
+ export function hasNoRelevantChecks(checks) {
42
+ return (checks.passing ?? 0) + (checks.failing ?? 0) + (checks.inProgress ?? 0) === 0;
43
+ }
44
+ /**
45
+ * Flag a running Actions workflow suite when the head has no relevant check yet. Suites exist
46
+ * before their check runs, so CLEAN with no checks is not proof that CI passed.
47
+ */
48
+ export function applyPendingWorkflowSuites(raw, checks) {
49
+ if (checks.actionsWorkflowInProgress === true || !hasNoRelevantChecks(checks))
50
+ return;
51
+ const suites = raw.commits.nodes[0]?.commit.checkSuites?.nodes ?? [];
52
+ if (actionsWorkflowInProgress(suites, new Set(loadConfig().checks["ci-trigger-events"]))) {
53
+ checks.actionsWorkflowInProgress = true;
54
+ }
55
+ }
40
56
  function reportedNames(raw) {
41
57
  const names = new Set();
42
58
  const rollups = [
@@ -3,8 +3,9 @@ import { recordRestIdentity } from "./rest-identities.mjs";
3
3
  import { readRest as rest } from "./rest-reader-core.mjs";
4
4
  export async function readRestCommitChecks(oid, repo, pr = 0) {
5
5
  const prefix = restRepoPath(repo);
6
- const checks = await readRestPages(`${prefix}/commits/${encodeURIComponent(oid)}/check-runs?filter=latest`, (body) => restCollection(body, "check_runs"));
6
+ // Suites first: a COMPLETED suite read before the runs means a run still not COMPLETED is stuck.
7
7
  const suites = await readRestPages(`${prefix}/commits/${encodeURIComponent(oid)}/check-suites`, (body) => restCollection(body, "check_suites"));
8
+ const checks = await readRestPages(`${prefix}/commits/${encodeURIComponent(oid)}/check-runs?filter=latest`, (body) => restCollection(body, "check_runs"));
8
9
  const statuses = await readRestPages(`${prefix}/commits/${encodeURIComponent(oid)}/statuses`);
9
10
  const workflowRuns = await readRestPages(`${prefix}/actions/runs?head_sha=${encodeURIComponent(oid)}`, (body) => restCollection(body, "workflow_runs"));
10
11
  for (const suite of suites.nodes) {
@@ -64,6 +65,7 @@ export async function readRestCommitChecks(oid, repo, pr = 0) {
64
65
  annotations: { totalCount: check.output.annotations_count, nodes: [] },
65
66
  checkSuite: suite
66
67
  ? {
68
+ status: suite.status.toUpperCase(),
67
69
  createdAt: suite.created_at,
68
70
  updatedAt: suite.updated_at,
69
71
  workflowRun: run
@@ -2,6 +2,7 @@ import { appendEntry, nextEntry } from "../log/log-file.mjs";
2
2
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
3
  import { GitHubRequestError } from "./errors.mjs";
4
4
  import { githubFetch } from "./github-fetch.mjs";
5
+ import { GITHUB_LOG_TIMEOUT_MS } from "./github-timeouts.mjs";
5
6
  import { makeAuthHeaders } from "./http-auth.mjs";
6
7
  import { requestWithTokenRetry } from "./http-request.mjs";
7
8
  import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody, } from "./http-utils.mjs";
@@ -19,7 +20,7 @@ export async function restText(path, onRateLimit) {
19
20
  const auth = await makeAuthHeaders();
20
21
  authSource = auth.source;
21
22
  credentialFingerprint = auth.fingerprint;
22
- return githubFetch(url, { method: "GET", headers: auth.headers, redirect: "manual" });
23
+ return githubFetch(url, { method: "GET", headers: auth.headers, redirect: "manual" }, GITHUB_LOG_TIMEOUT_MS);
23
24
  }, t0, (response, durationMs) => recordIntermediateResponse({
24
25
  n,
25
26
  kind: "restText",
@@ -105,7 +106,7 @@ async function followRestTextRedirect(res, entry) {
105
106
  const logUrl = redactUrl(location);
106
107
  appendEntry(formatRequestEntry({ n: n2, kind: "restText", method: "GET", url: logUrl }));
107
108
  const t1 = performance.now();
108
- const redirectRes = await githubFetch(location);
109
+ const redirectRes = await githubFetch(location, undefined, GITHUB_LOG_TIMEOUT_MS);
109
110
  appendEntry(formatResponseEntry({
110
111
  n: n2,
111
112
  kind: "restText",
@@ -90,6 +90,7 @@ export function batchResponseFromSummary(layer, source, runs) {
90
90
  ...(run.annotations && { annotations: run.annotations }),
91
91
  checkSuite: suite
92
92
  ? {
93
+ ...(suite.status && { status: suite.status }),
93
94
  ...(suite.createdAt && { createdAt: suite.createdAt }),
94
95
  ...(suite.updatedAt && { updatedAt: suite.updatedAt }),
95
96
  workflowRun: workflowRun
@@ -59,6 +59,8 @@ export function toAgentCheck(c) {
59
59
  runId: c.runId,
60
60
  detailsUrl: c.detailsUrl,
61
61
  conclusion: c.conclusion,
62
+ ...(c.status !== "COMPLETED" &&
63
+ c.suiteStatus !== undefined && { status: c.status, suiteStatus: c.suiteStatus }),
62
64
  ...(c.runAttempt !== undefined && c.runAttempt > 1 && { runAttempt: c.runAttempt }),
63
65
  ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
64
66
  ...(c.jobName !== undefined && { jobName: c.jobName }),
@@ -57,6 +57,12 @@ export interface CheckRun {
57
57
  summary?: string;
58
58
  /** Workflow display name for GitHub Actions check runs, when GraphQL exposes it. */
59
59
  workflowName?: string;
60
+ /**
61
+ * Raw status of the check suite this run belongs to. Absent when GitHub did not report one
62
+ * (status contexts, synthetic checks, or a transport that cannot read it). A `COMPLETED`
63
+ * suite with a run that is not `COMPLETED` is a stuck check.
64
+ */
65
+ suiteStatus?: string;
60
66
  workflowId?: string;
61
67
  /** True when GraphQL reported at least one CheckRun annotation. Omitted when false. */
62
68
  hasAnnotations?: boolean;
@@ -1,4 +1,4 @@
1
- import type { AuthorType, ReviewThread, PrComment, Review, MergeStatusResult, ViewerAuthorization, CheckConclusion, SuggestionBlock } from "./github.mts";
1
+ import type { AuthorType, ReviewThread, PrComment, Review, MergeStatusResult, ViewerAuthorization, CheckConclusion, CheckStatus, SuggestionBlock } from "./github.mts";
2
2
  import type { ClassifiedCheck, RelatedFailedJob, TriagedCheck } from "./check-classification.mts";
3
3
  import type { AgentThreadComment } from "./agent-thread.mts";
4
4
  import type { CheckAnnotation } from "./check-annotations.mts";
@@ -196,6 +196,10 @@ export interface AgentCheck {
196
196
  detailsUrl: string | null;
197
197
  /** Raw GitHub check conclusion; may be null for some completed checks from upstream data. */
198
198
  conclusion: CheckConclusion;
199
+ /** Raw check status, present only for a stuck check: not `COMPLETED` while its suite is. */
200
+ status?: CheckStatus;
201
+ /** Raw check-suite status (`COMPLETED`), present only with `status` for a stuck check. */
202
+ suiteStatus?: string;
199
203
  /** Workflow display name (e.g. `"CI"`). Populated on a best-effort basis when available from the jobs API. */
200
204
  workflowName?: string;
201
205
  /** Name of the matched job (e.g. `"tests (ubuntu)"`). Distinct from check name for matrix builds. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "1.1.0",
4
+ "version": "1.1.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@1.1.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@1.1.1", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@1.1.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@1.1.1", "pr-shepherd-mcp"]
5
5
  }
6
6
  }
@@ -13,6 +13,7 @@ Apply when a step says `Playbook: "CI failure triage"`. Use the excerpts and tag
13
13
  - Actions failure with a run id: read the excerpt. Fix a real test or build failure in code and never rerun it, even with a printed `rerun:`. Run a printed `rerun:` only for a transient cause; without one, finish the other work. Then iterate without waiting.
14
14
  - An empty excerpt is not evidence of a transient failure. A gate-job excerpt naming failed jobs (for example `test-playwright: failure`) is test-failure evidence even without a stack trace: read the `Other failed jobs in this run` tails first, and only when a named job's tail is absent or truncated run `gh run view <runId> --log-failed -R <owner/repo>`. Rerun only when those logs show a transient cause.
15
15
  - `CANCELLED` or `STARTUP_FAILURE` (no excerpt): run a printed `rerun:`, else finish the other work; iterate without waiting.
16
+ - `[status: …, suite: COMPLETED]` (stuck run): the workflow finished but this check never completed, so there is nothing to fix in code. Run a printed `rerun:`, else finish the other work; iterate without waiting.
16
17
  - `ACTION_REQUIRED`: finish the other work and iterate. Shepherd escalates if manual approval is still required.
17
18
  - `external` with a URL: inspect the provider or reproduce locally, fix, and iterate. The URL alone is not `[ESCALATE]`.
18
19
  - `(no runId)` without a URL: keep the metadata. Shepherd escalates when nothing else remains.