pr-shepherd 0.53.1 → 0.54.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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli/args.mjs +3 -0
  4. package/bin/cli/check-blocker-handler.d.mts +2 -0
  5. package/bin/cli/check-blocker-handler.mjs +82 -0
  6. package/bin/cli/help-command-pages.d.mts +22 -3
  7. package/bin/cli/help-command-pages.mjs +21 -2
  8. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  9. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  10. package/bin/cli/help-top-page.d.mts +1 -1
  11. package/bin/cli/help-top-page.mjs +3 -1
  12. package/bin/cli/help.d.mts +23 -4
  13. package/bin/cli/help.mjs +2 -0
  14. package/bin/cli/poll-handler.mjs +5 -1
  15. package/bin/cli-parser.mjs +4 -0
  16. package/bin/commands/apply-check-blocker.d.mts +22 -0
  17. package/bin/commands/apply-check-blocker.mjs +36 -0
  18. package/bin/commands/check-annotations.d.mts +1 -1
  19. package/bin/commands/check-annotations.mjs +27 -17
  20. package/bin/commands/check-blocker-ref.d.mts +3 -0
  21. package/bin/commands/check-blocker-ref.mjs +50 -0
  22. package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
  23. package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
  24. package/bin/commands/iterate/check-instructions.d.mts +2 -0
  25. package/bin/commands/iterate/check-instructions.mjs +4 -0
  26. package/bin/commands/iterate/fix-code.d.mts +2 -0
  27. package/bin/commands/iterate/fix-code.mjs +31 -15
  28. package/bin/commands/iterate/index.mjs +22 -8
  29. package/bin/commands/poll-quota.d.mts +2 -0
  30. package/bin/commands/poll-quota.mjs +25 -12
  31. package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
  32. package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
  33. package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
  34. package/bin/commands/poll-rate-limit-delay.mjs +7 -0
  35. package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
  36. package/bin/commands/poll-rate-limit-wait.mjs +125 -0
  37. package/bin/commands/poll-summary.mjs +21 -9
  38. package/bin/commands/poll.mjs +22 -17
  39. package/bin/config/load.d.mts +2 -0
  40. package/bin/config/load.mjs +24 -2
  41. package/bin/config.json +1 -0
  42. package/bin/github/check-annotation-cache.d.mts +8 -0
  43. package/bin/github/check-annotation-cache.mjs +23 -0
  44. package/bin/github/check-annotation-pages.d.mts +11 -0
  45. package/bin/github/check-annotation-pages.mjs +36 -0
  46. package/bin/github/check-annotation-shape.d.mts +21 -0
  47. package/bin/github/check-annotation-shape.mjs +50 -0
  48. package/bin/github/check-annotations-batch.d.mts +17 -0
  49. package/bin/github/check-annotations-batch.mjs +92 -0
  50. package/bin/github/check-annotations.d.mts +8 -11
  51. package/bin/github/check-annotations.mjs +14 -103
  52. package/bin/github/gql/batch-pr.gql +1 -1
  53. package/bin/github/gql/check-run-annotations-batch.gql +41 -0
  54. package/bin/github/pagination.d.mts +1 -1
  55. package/bin/github/pagination.mjs +1 -1
  56. package/bin/github/poll-summary-check-blockers.d.mts +16 -0
  57. package/bin/github/poll-summary-check-blockers.mjs +25 -0
  58. package/bin/github/poll-summary-checks.d.mts +2 -0
  59. package/bin/github/poll-summary-checks.mjs +35 -20
  60. package/bin/github/poll-summary-projector.mjs +5 -0
  61. package/bin/github/queries.d.mts +6 -0
  62. package/bin/github/queries.mjs +6 -0
  63. package/bin/state/check-blockers.d.mts +30 -0
  64. package/bin/state/check-blockers.mjs +88 -0
  65. package/package.json +1 -1
  66. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  67. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  68. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -0,0 +1,50 @@
1
+ const NAME = /^[A-Za-z0-9_.-]+$/;
2
+ const SHORTHAND = /^(?:(issue):)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#([1-9]\d*)$/;
3
+ /** Parse `--blocked-by`. Returns null for anything other than the four accepted forms. */
4
+ export function parseBlockedByRef(raw) {
5
+ const text = raw.trim();
6
+ const short = SHORTHAND.exec(text);
7
+ if (short?.[2] && short[3] && short[4]) {
8
+ const number = Number(short[4]);
9
+ if (!Number.isSafeInteger(number))
10
+ return null;
11
+ return {
12
+ owner: short[2],
13
+ name: short[3],
14
+ number,
15
+ kind: short[1] === "issue" ? "issue" : "pull",
16
+ };
17
+ }
18
+ return parseUrl(text);
19
+ }
20
+ function parseUrl(text) {
21
+ let url;
22
+ try {
23
+ url = new URL(text);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ if (url.protocol !== "https:" && url.protocol !== "http:")
29
+ return null;
30
+ if (url.hostname !== "github.com" && url.hostname !== "www.github.com")
31
+ return null;
32
+ if (url.search !== "" || url.hash !== "")
33
+ return null;
34
+ const parts = url.pathname.split("/").filter(Boolean);
35
+ if (parts.length !== 4)
36
+ return null;
37
+ const [owner, name, type, num] = parts;
38
+ if (!owner || !name || !num || !NAME.test(owner) || !NAME.test(name))
39
+ return null;
40
+ if (!/^[1-9]\d*$/.test(num))
41
+ return null;
42
+ const number = Number(num);
43
+ if (!Number.isSafeInteger(number))
44
+ return null;
45
+ if (type === "pull")
46
+ return { owner, name, number, kind: "pull" };
47
+ if (type === "issues")
48
+ return { owner, name, number, kind: "issue" };
49
+ return null;
50
+ }
@@ -0,0 +1,18 @@
1
+ import type { IterateResult } from "../../types.mts";
2
+ export interface CheckBlockerGate {
3
+ deferredNames: ReadonlySet<string>;
4
+ releasedNames: ReadonlySet<string>;
5
+ /** `owner/name#number` for blockers that are still open. */
6
+ openBlockers: readonly string[];
7
+ }
8
+ interface StateKey {
9
+ owner: string;
10
+ repo: string;
11
+ pr: number;
12
+ }
13
+ export declare function resolveCheckBlockerGate(key: StateKey, failing: readonly {
14
+ name: string;
15
+ }[], mergeStatus?: string): Promise<CheckBlockerGate | null>;
16
+ /** Append the open blocker to a WAIT log. Other actions are unchanged. */
17
+ export declare function annotateBlockedWait(result: IterateResult, gate: CheckBlockerGate | null): IterateResult;
18
+ export {};
@@ -0,0 +1,115 @@
1
+ import { graphql } from "../../github/client.mjs";
2
+ import { pollRateLimitRetryAfterMs } from "../poll-quota.mjs";
3
+ import { clearCheckBlocker, formatCheckBlockerRef, readCheckBlockers, } from "../../state/check-blockers.mjs";
4
+ const RATE_LIMIT = "cost limit nodeCount remaining resetAt used";
5
+ const PULL_QUERY = `query CheckBlockerPull($owner: String!, $name: String!, $number: Int!) {
6
+ _shepherdRateLimit: rateLimit { ${RATE_LIMIT} }
7
+ repository(owner: $owner, name: $name) {
8
+ pullRequest(number: $number) { state merged }
9
+ }
10
+ }`;
11
+ const ISSUE_QUERY = `query CheckBlockerIssue($owner: String!, $name: String!, $number: Int!) {
12
+ _shepherdRateLimit: rateLimit { ${RATE_LIMIT} }
13
+ repository(owner: $owner, name: $name) {
14
+ issue(number: $number) { state }
15
+ }
16
+ }`;
17
+ /**
18
+ * Match failing checks to stored blockers. No records, or none of them name a
19
+ * failing check, skips GitHub. Rate-limit errors propagate; other lookup
20
+ * failures leave that check as a normal failure.
21
+ */
22
+ /** Behind or conflicting branches still need the update-branch instruction. */
23
+ const KEEP_RELEASED_STATUS = new Set(["BEHIND", "CONFLICTS", "DIRTY", "UNKNOWN"]);
24
+ export async function resolveCheckBlockerGate(key, failing, mergeStatus) {
25
+ const records = await readCheckBlockers(key);
26
+ if (records.length === 0 || failing.length === 0)
27
+ return null;
28
+ const failingNames = new Set(failing.map((check) => check.name));
29
+ const matched = records.filter((record) => failingNames.has(record.checkName));
30
+ if (matched.length === 0)
31
+ return null;
32
+ const lookups = new Map();
33
+ for (const record of matched) {
34
+ const id = blockerKey(record.blocker);
35
+ if (lookups.has(id))
36
+ continue;
37
+ lookups.set(id, await lookupBlocker(record.blocker));
38
+ }
39
+ const deferredNames = new Set();
40
+ const releasedNames = new Set();
41
+ const openBlockers = [];
42
+ const seen = new Set();
43
+ for (const record of matched) {
44
+ const status = lookups.get(blockerKey(record.blocker));
45
+ if (status === "deferred") {
46
+ deferredNames.add(record.checkName);
47
+ const label = formatCheckBlockerRef(record.blocker);
48
+ if (!seen.has(label)) {
49
+ seen.add(label);
50
+ openBlockers.push(label);
51
+ }
52
+ }
53
+ else if (status === "released") {
54
+ if (mergeStatus !== undefined && !KEEP_RELEASED_STATUS.has(mergeStatus)) {
55
+ // The branch is already current, so update-branch would no-op and a
56
+ // later unrelated failure must be a normal check again.
57
+ await clearCheckBlocker(key, record.checkName);
58
+ }
59
+ else {
60
+ releasedNames.add(record.checkName);
61
+ }
62
+ }
63
+ }
64
+ if (deferredNames.size === 0 && releasedNames.size === 0)
65
+ return null;
66
+ return { deferredNames, releasedNames, openBlockers };
67
+ }
68
+ /** Append the open blocker to a WAIT log. Other actions are unchanged. */
69
+ export function annotateBlockedWait(result, gate) {
70
+ if (result.action !== "wait" || gate === null || gate.openBlockers.length === 0)
71
+ return result;
72
+ const note = `blocked by ${gate.openBlockers.join(", ")}`;
73
+ if (result.log.includes(note))
74
+ return result;
75
+ return { ...result, log: `${result.log} — ${note}` };
76
+ }
77
+ async function lookupBlocker(blocker) {
78
+ try {
79
+ const vars = { owner: blocker.owner, name: blocker.name, number: blocker.number };
80
+ if (blocker.kind === "issue") {
81
+ const { data } = await graphql(ISSUE_QUERY, vars);
82
+ const issue = data.repository?.issue;
83
+ if (!issue)
84
+ return ignore(blocker, "not found");
85
+ if (issue.state === "OPEN")
86
+ return "deferred";
87
+ if (issue.state === "CLOSED")
88
+ return "released";
89
+ return ignore(blocker, `unexpected state ${issue.state}`);
90
+ }
91
+ const { data } = await graphql(PULL_QUERY, vars);
92
+ const pull = data.repository?.pullRequest;
93
+ if (!pull)
94
+ return ignore(blocker, "not found");
95
+ if (pull.state === "OPEN" && pull.merged !== true)
96
+ return "deferred";
97
+ if (pull.state === "MERGED" || pull.state === "CLOSED" || pull.merged === true) {
98
+ return "released";
99
+ }
100
+ return ignore(blocker, `unexpected state ${pull.state}`);
101
+ }
102
+ catch (err) {
103
+ if (pollRateLimitRetryAfterMs(err) !== null)
104
+ throw err;
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ return ignore(blocker, message);
107
+ }
108
+ }
109
+ function ignore(blocker, message) {
110
+ process.stderr.write(`pr-shepherd: check blocker lookup failed for ${formatCheckBlockerRef(blocker)} (ignored): ${message}\n`);
111
+ return "ignored";
112
+ }
113
+ function blockerKey(blocker) {
114
+ return `${blocker.kind}:${formatCheckBlockerRef(blocker)}`;
115
+ }
@@ -33,4 +33,6 @@ export declare function buildRepeatedWorkflowBranchRecoveryInstructions(baseBran
33
33
  export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
34
34
  /** Build the CI-triage pointer; the skill limits follow-up actions to included evidence. */
35
35
  export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
36
+ /** Update the PR branch after an external blocker merges or closes. Never rerun that job. */
37
+ export declare function buildReleasedBlockerInstruction(prNumber: number): string;
36
38
  export declare function buildFixCompletionInstruction(checks: AgentCheck[], hasConflicts?: boolean, hasShaGatedReviewMutations?: boolean, pushesRewrittenStack?: boolean): string;
@@ -80,6 +80,10 @@ export function buildFailingCheckInstructions(checks) {
80
80
  }
81
81
  return instructions;
82
82
  }
83
+ /** Update the PR branch after an external blocker merges or closes. Never rerun that job. */
84
+ export function buildReleasedBlockerInstruction(prNumber) {
85
+ return `Update this PR branch from its base with \`gh pr update-branch ${prNumber} --rebase\`. Do not rerun the job; a rerun retests the old merge ref.`;
86
+ }
83
87
  export function buildFixCompletionInstruction(checks, hasConflicts = false, hasShaGatedReviewMutations = false, pushesRewrittenStack = false) {
84
88
  const push = pushesRewrittenStack
85
89
  ? "push the rewritten stack with `gh stack push`"
@@ -19,6 +19,8 @@ interface HandleFixCodeContext {
19
19
  editedSummaries: Review[];
20
20
  surfacedApprovals: Review[];
21
21
  botUsernames: NormalizedBotUsernames;
22
+ /** Failing checks whose external blocker has merged or closed. */
23
+ releasedCheckNames?: ReadonlySet<string>;
22
24
  ruleAutoResolveThreadIds?: string[];
23
25
  /** Verified stack-repair guidance, when ancestry is stale. */
24
26
  repairInstructions?: string[];
@@ -6,6 +6,7 @@ import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, bui
6
6
  import { buildResolveCommand } from "./classify.mjs";
7
7
  import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thread-mutation-routing.mjs";
8
8
  import { buildFixInstructions } from "./render.mjs";
9
+ import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
9
10
  import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
10
11
  import { applyStallGuard } from "./stall.mjs";
11
12
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
@@ -14,6 +15,7 @@ import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs
14
15
  import { canRerunWorkflows } from "../../checks/conclusions.mjs";
15
16
  import { loadConfig } from "../../config/load.mjs";
16
17
  import { formatPrUrl } from "../../pr-reference.mjs";
18
+ const EMPTY_RELEASED = new Set();
17
19
  function checkRequiresHumanFollowUp(check) {
18
20
  if (check.rerunCommand)
19
21
  return false;
@@ -68,7 +70,7 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
68
70
  return Object.keys(pending).length > 0 ? pending : undefined;
69
71
  }
70
72
  export async function handleFixCode(ctx) {
71
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
73
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, releasedCheckNames = EMPTY_RELEASED, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
72
74
  const prReference = formatPrUrl(report.repo, prNumber);
73
75
  const failingChecks = report.checks.failing;
74
76
  const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
@@ -158,18 +160,22 @@ export async function handleFixCode(ctx) {
158
160
  ? [c.runId]
159
161
  : []));
160
162
  const initialAttemptRunIds = new Set(failingChecks.flatMap((c) => (c.runId !== null && c.runAttempt === 1 ? [c.runId] : [])));
161
- const failingAgentChecks = toAgentChecks(failingChecks).map((c) => rerunAuthorized &&
162
- c.runId &&
163
- actionsRunIds.has(c.runId) &&
164
- // GitHub increments run_attempt after every rerun. Recommend at most one rerun by limiting
165
- // the command to the original attempt; missing attempt metadata is denied conservatively.
166
- initialAttemptRunIds.has(c.runId) &&
167
- // ACTION_REQUIRED means the run is paused pending manual workflow approval; rerunning does
168
- // not grant that approval, so no rerun command applies.
169
- c.conclusion !== "ACTION_REQUIRED" &&
170
- !inProgressWorkflowRunIds.has(c.runId)
171
- ? { ...c, rerunCommand: `gh run rerun ${c.runId} -R ${report.repo}` }
172
- : c);
163
+ const failingAgentChecks = toAgentChecks(failingChecks).map((c) => {
164
+ if (releasedCheckNames.has(c.name))
165
+ return c;
166
+ return rerunAuthorized &&
167
+ c.runId &&
168
+ actionsRunIds.has(c.runId) &&
169
+ // GitHub increments run_attempt after every rerun. Recommend at most one rerun by limiting
170
+ // the command to the original attempt; missing attempt metadata is denied conservatively.
171
+ initialAttemptRunIds.has(c.runId) &&
172
+ // ACTION_REQUIRED means the run is paused pending manual workflow approval; rerunning does
173
+ // not grant that approval, so no rerun command applies.
174
+ c.conclusion !== "ACTION_REQUIRED" &&
175
+ !inProgressWorkflowRunIds.has(c.runId)
176
+ ? { ...c, rerunCommand: `gh run rerun ${c.runId} -R ${report.repo}` }
177
+ : c;
178
+ });
173
179
  const checks = [
174
180
  ...failingAgentChecks,
175
181
  ...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
@@ -184,7 +190,9 @@ export async function handleFixCode(ctx) {
184
190
  const inProgressRunIds = [];
185
191
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
186
192
  const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
187
- const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
193
+ const manualFollowUpChecks = failingAgentChecks.filter((check) => !releasedCheckNames.has(check.name) &&
194
+ !belongsToActiveWorkflowRun(check) &&
195
+ checkRequiresHumanFollowUp(check));
188
196
  const exhaustedAttempts = failingAgentChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
189
197
  const manualExhaustedAttempts = exhaustedAttempts.filter((check) => manualFollowUpChecks.includes(check));
190
198
  const hasBehindBaseRecovery = isBehind && manualExhaustedAttempts.length > 0;
@@ -201,7 +209,9 @@ export async function handleFixCode(ctx) {
201
209
  report.threads.firstLook.length > 0 ||
202
210
  report.comments.firstLook.length > 0 ||
203
211
  checks.some((check) => (check.annotations?.length ?? 0) > 0) ||
204
- failingAgentChecks.some((check) => belongsToActiveWorkflowRun(check) || !checkRequiresHumanFollowUp(check));
212
+ failingAgentChecks.some((check) => releasedCheckNames.has(check.name) ||
213
+ belongsToActiveWorkflowRun(check) ||
214
+ !checkRequiresHumanFollowUp(check));
205
215
  if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
206
216
  const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
207
217
  const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
@@ -277,6 +287,12 @@ export async function handleFixCode(ctx) {
277
287
  ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, report.mergeStatus.mergeRequirements?.stack)
278
288
  : undefined;
279
289
  const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0, stackRebase);
290
+ if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
291
+ const completion = instructions.pop();
292
+ instructions.push(buildReleasedBlockerInstruction(prNumber));
293
+ if (completion !== undefined)
294
+ instructions.push(completion);
295
+ }
280
296
  if (repairInstructions && repairInstructions.length > 0) {
281
297
  instructions.unshift(...repairInstructions);
282
298
  }
@@ -24,6 +24,7 @@ import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
24
24
  import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
25
25
  import { stackDraftHold } from "./parent-first.mjs";
26
26
  import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
27
+ import { annotateBlockedWait, resolveCheckBlockerGate } from "./check-blocker-gate.mjs";
27
28
  export function runIterate(opts) {
28
29
  return withIterateApiUsage(opts, () => runIterateCore(opts));
29
30
  }
@@ -58,6 +59,17 @@ async function runIterateCore(opts) {
58
59
  await clearStallState(stallKey);
59
60
  return buildTerminalCancelResult(report);
60
61
  }
62
+ const blockerGate = await resolveCheckBlockerGate({ owner: repoOwner, repo: repoName, pr: prNumber }, report.checks.failing, report.mergeStatus.status);
63
+ const deferredNames = blockerGate?.deferredNames;
64
+ const reportForWork = deferredNames === undefined || deferredNames.size === 0
65
+ ? report
66
+ : {
67
+ ...report,
68
+ checks: {
69
+ ...report.checks,
70
+ failing: report.checks.failing.filter((check) => !deferredNames.has(check.name)),
71
+ },
72
+ };
61
73
  const { minimizeIds, selfMinimizeIds, firstLookSummaries, editedSummaries, surfacedApprovals } = classifyReviewSummaries({
62
74
  firstLook: report.firstLookSummaries,
63
75
  seen: report.reviewSummaries,
@@ -86,7 +98,7 @@ async function runIterateCore(opts) {
86
98
  report.comments.actionable.length > 0 ||
87
99
  (report.comments.minimizeIds?.length ?? 0) > 0 ||
88
100
  report.changesRequestedReviews.length > 0 ||
89
- hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status) ||
101
+ hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status) ||
90
102
  reviewSummaryIds.length > 0 ||
91
103
  firstLookSummaries.length > 0 ||
92
104
  editedSummaries.length > 0 ||
@@ -109,20 +121,20 @@ async function runIterateCore(opts) {
109
121
  // current already proves the delay elapsed for this exact head, base, and
110
122
  // readiness evidence, so a rerun (e.g. with --merge) does not wait again.
111
123
  const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName, { headSha, alreadyElapsed: receiptCurrent });
112
- const base = buildIterateBase(report, readyState);
124
+ const base = buildIterateBase(reportForWork, readyState);
113
125
  // Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
114
126
  // GitHub itself is already acting on — the queue will eject the PR for these regardless of
115
127
  // what Shepherd does, so they always surface immediately. Only review threads/comments/
116
128
  // changes-requested reviews/review summaries — the categories that would otherwise cause a
117
129
  // Shepherd-initiated push while the PR sits safely in the queue — are eligible for deferral.
118
- const checkDrivenActionableWork = hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status);
130
+ const checkDrivenActionableWork = hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status);
119
131
  const deferWhileQueued = opts.merge === true &&
120
132
  report.mergeQueue?.inQueue === true &&
121
133
  config.actions.workWhileQueued !== true;
122
134
  if (hasActionableWork && !(deferWhileQueued && !checkDrivenActionableWork)) {
123
135
  return handleFixCode({
124
136
  base,
125
- report,
137
+ report: reportForWork,
126
138
  opts: { ...opts, prNumber, neverCancelRuns },
127
139
  headSha,
128
140
  stallKey,
@@ -135,6 +147,7 @@ async function runIterateCore(opts) {
135
147
  editedSummaries,
136
148
  surfacedApprovals,
137
149
  botUsernames,
150
+ releasedCheckNames: blockerGate?.releasedNames,
138
151
  ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
139
152
  });
140
153
  }
@@ -151,11 +164,11 @@ async function runIterateCore(opts) {
151
164
  minimizeApprovals: config.iterate.minimizeApprovals,
152
165
  });
153
166
  if (mergeStateResult)
154
- return mergeStateResult;
167
+ return annotateBlockedWait(mergeStateResult, blockerGate);
155
168
  if (staleAncestry) {
156
169
  return handleFixCode({
157
170
  base,
158
- report,
171
+ report: reportForWork,
159
172
  opts: { ...opts, prNumber, neverCancelRuns },
160
173
  headSha,
161
174
  stallKey,
@@ -168,6 +181,7 @@ async function runIterateCore(opts) {
168
181
  editedSummaries,
169
182
  surfacedApprovals,
170
183
  botUsernames,
184
+ releasedCheckNames: blockerGate?.releasedNames,
171
185
  repairInstructions: staleAncestry.instructions,
172
186
  ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
173
187
  });
@@ -191,7 +205,7 @@ async function runIterateCore(opts) {
191
205
  shouldCancel: false,
192
206
  log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
193
207
  };
194
- return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, report, reviewSummaryIds);
208
+ return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, reportForWork, reviewSummaryIds), blockerGate);
195
209
  }
196
210
  await clearReadyDelay(report.pr, repoOwner, repoName);
197
211
  await clearStallState(stallKey);
@@ -213,7 +227,7 @@ async function runIterateCore(opts) {
213
227
  log: buildWaitLog(base),
214
228
  ...(hold && { stackDraftHold: hold }),
215
229
  };
216
- return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, report, reviewSummaryIds);
230
+ return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, reportForWork, reviewSummaryIds), blockerGate);
217
231
  }
218
232
  async function recordReadyReceipt(key, report) {
219
233
  if (report.status !== "READY" ||
@@ -19,4 +19,6 @@ export interface RateLimitRetry {
19
19
  export declare function pollRateLimitRetryAfterMs(err: unknown): RateLimitRetry | null;
20
20
  /** Stderr line naming the exhausted budget and when the sleep ends. */
21
21
  export declare function formatRateLimitRetryLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
22
+ /** One stderr line before `--until-terminal` gives up and exits 75. */
23
+ export declare function formatRateLimitGiveUpLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
22
24
  export declare function aggregateQuotaWarning(result: PollSummaryResult, bands: GraphqlQuotaWarningBand[], intervalSeconds: number): Promise<PollSummaryResult["quotaWarning"]>;
@@ -1,6 +1,7 @@
1
1
  import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
2
2
  import { GitHubRequestError } from "../github/errors.mjs";
3
3
  import { isRateLimitMessage } from "../comments/rate-limit.mjs";
4
+ import { exhaustedPrimaryLimitDelayMs } from "./poll-rate-limit-delay.mjs";
4
5
  import { selectQuotaWarning } from "./quota-selection.mjs";
5
6
  const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
6
7
  /** Sleep at least `--interval`, and at least the active crossed quota band. */
@@ -45,7 +46,7 @@ export function pollRateLimitRetryAfterMs(err) {
45
46
  return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
46
47
  }
47
48
  if (exhausted && rateLimit !== undefined) {
48
- return { ...details, ms: Math.max(rateLimit.resetAt * 1000 - Date.now(), 0) };
49
+ return { ...details, ms: exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()) };
49
50
  }
50
51
  return { ...details, ms: GRAPHQL_RETRY_AFTER_DEFAULT_MS };
51
52
  }
@@ -58,19 +59,31 @@ function retryResource(err) {
58
59
  (err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
59
60
  return secondary ? "secondary" : "graphql";
60
61
  }
61
- /** Stderr line naming the exhausted budget and when the sleep ends. */
62
- export function formatRateLimitRetryLine(tickLabel, elapsedSeconds, retry) {
63
- const resetAt = retry.resetAt ?? Math.ceil((Date.now() + retry.ms) / 1000);
64
- const clock = `${new Date(resetAt * 1000).toISOString().slice(11, 19)}Z`;
65
- const label = retry.resource === "graphql"
66
- ? "GitHub GraphQL rate limit"
67
- : retry.resource === "secondary"
68
- ? "GitHub secondary rate limit"
69
- : `GitHub REST ${retry.resource} rate limit`;
70
- const counts = retry.remaining !== undefined && retry.limit !== undefined
62
+ function rateLimitResourceLabel(resource) {
63
+ if (resource === "graphql")
64
+ return "GitHub GraphQL rate limit";
65
+ if (resource === "secondary")
66
+ return "GitHub secondary rate limit";
67
+ return `GitHub REST ${resource} rate limit`;
68
+ }
69
+ function rateLimitCounts(retry) {
70
+ return retry.remaining !== undefined && retry.limit !== undefined
71
71
  ? ` (${retry.remaining}/${retry.limit})`
72
72
  : "";
73
- return `[${tickLabel} / +${elapsedSeconds}s] ${label}${counts} — retrying at ${clock} (in ${Math.round(retry.ms / 1000)}s)\n`;
73
+ }
74
+ function rateLimitClock(retry) {
75
+ const resetAt = retry.resetAt ?? Math.ceil((Date.now() + retry.ms) / 1000);
76
+ return `${new Date(resetAt * 1000).toISOString().slice(11, 19)}Z`;
77
+ }
78
+ /** Stderr line naming the exhausted budget and when the sleep ends. */
79
+ export function formatRateLimitRetryLine(tickLabel, elapsedSeconds, retry) {
80
+ const clock = rateLimitClock(retry);
81
+ return `[${tickLabel} / +${elapsedSeconds}s] ${rateLimitResourceLabel(retry.resource)}${rateLimitCounts(retry)} — retrying at ${clock} (in ${Math.round(retry.ms / 1000)}s)\n`;
82
+ }
83
+ /** One stderr line before `--until-terminal` gives up and exits 75. */
84
+ export function formatRateLimitGiveUpLine(tickLabel, elapsedSeconds, retry) {
85
+ const clock = rateLimitClock(retry);
86
+ return `[${tickLabel} / +${elapsedSeconds}s] ${rateLimitResourceLabel(retry.resource)}${rateLimitCounts(retry)} still exhausted at ${clock} after 5 attempts with no progress\n`;
74
87
  }
75
88
  export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
76
89
  const usage = summarizeApiTelemetry();
@@ -0,0 +1,35 @@
1
+ import type { IterateResult, MergeStateStatus, MergeableState, PollSummaryResult } from "../types.mts";
2
+ export interface RateLimitPullTarget {
3
+ owner: string;
4
+ repo: string;
5
+ pr: number;
6
+ }
7
+ export interface RateLimitProbePull {
8
+ pr: number;
9
+ state: "MERGED" | "CLOSED";
10
+ title: string;
11
+ url: string;
12
+ draft: boolean;
13
+ mergeable: MergeableState;
14
+ mergeStateStatus: MergeStateStatus;
15
+ baseRefName: string;
16
+ headRefName: string;
17
+ headRefOid: string;
18
+ }
19
+ export declare function onePrRateLimitTargets(explicit: {
20
+ owner: string;
21
+ name: string;
22
+ } | undefined, pr: number | undefined, repoSlug: string | undefined): RateLimitPullTarget[];
23
+ /** Same fields as the merged/closed iterate cancel path, filled from the REST pull. */
24
+ export declare function onePrCancelFromPulls(explicit: {
25
+ owner: string;
26
+ name: string;
27
+ } | undefined, repoSlug: string | undefined, pulls: RateLimitProbePull[]): IterateResult | undefined;
28
+ export declare function aggregateRateLimitTargets(last: PollSummaryResult | undefined, repo: {
29
+ owner: string;
30
+ name: string;
31
+ } | undefined, prNumbers: number[] | undefined): RateLimitPullTarget[];
32
+ export declare function aggregateCancelFromPulls(last: PollSummaryResult | undefined, repo: {
33
+ owner: string;
34
+ name: string;
35
+ } | undefined, pulls: RateLimitProbePull[]): PollSummaryResult | undefined;
@@ -0,0 +1,101 @@
1
+ export function onePrRateLimitTargets(explicit, pr, repoSlug) {
2
+ if (pr === undefined)
3
+ return [];
4
+ const [slugOwner, slugName] = repoSlug?.split("/") ?? [];
5
+ const owner = explicit?.owner ?? slugOwner;
6
+ const repo = explicit?.name ?? slugName;
7
+ if (!owner || !repo)
8
+ return [];
9
+ return [{ owner, repo, pr }];
10
+ }
11
+ /** Same fields as the merged/closed iterate cancel path, filled from the REST pull. */
12
+ export function onePrCancelFromPulls(explicit, repoSlug, pulls) {
13
+ const pull = pulls[0];
14
+ const slug = explicit ? `${explicit.owner}/${explicit.name}` : repoSlug;
15
+ if (!pull || !slug)
16
+ return undefined;
17
+ const state = pull.state;
18
+ return {
19
+ pr: pull.pr,
20
+ repo: slug,
21
+ status: state,
22
+ state,
23
+ mergeStateStatus: pull.mergeStateStatus,
24
+ mergeStatus: "UNKNOWN",
25
+ reviewDecision: null,
26
+ blockingBotReviewInProgress: false,
27
+ isDraft: pull.draft,
28
+ shouldCancel: true,
29
+ remainingSeconds: 0,
30
+ summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0, superseded: 0 },
31
+ baseBranch: pull.baseRefName,
32
+ branchProtection: null,
33
+ checks: [],
34
+ inProgressChecks: [],
35
+ action: "cancel",
36
+ reason: state === "MERGED" ? "merged" : "closed",
37
+ log: `CANCEL: PR #${pull.pr} is ${state.toLowerCase()} — stopping`,
38
+ };
39
+ }
40
+ export function aggregateRateLimitTargets(last, repo, prNumbers) {
41
+ if (last) {
42
+ return last.prs.flatMap((item) => {
43
+ if (item.state !== "OPEN")
44
+ return [];
45
+ const [owner, name] = item.repo.split("/");
46
+ if (!owner || !name)
47
+ return [];
48
+ return [{ owner, repo: name, pr: item.pr }];
49
+ });
50
+ }
51
+ if (!repo || !prNumbers?.length)
52
+ return [];
53
+ return prNumbers.map((pr) => ({ owner: repo.owner, repo: repo.name, pr }));
54
+ }
55
+ export function aggregateCancelFromPulls(last, repo, pulls) {
56
+ if (last) {
57
+ const byPr = new Map(pulls.map((pull) => [pull.pr, pull]));
58
+ const prs = last.prs.map((item) => applyProbe(item, byPr.get(item.pr)));
59
+ if (prs.some((item) => item.state !== "MERGED" && item.state !== "CLOSED"))
60
+ return undefined;
61
+ return { ...last, prs, reason: "all_terminal" };
62
+ }
63
+ if (!repo)
64
+ return undefined;
65
+ const slug = `${repo.owner}/${repo.name}`;
66
+ const prs = pulls.map((pull) => syntheticItem(slug, pull));
67
+ return {
68
+ mode: "summary",
69
+ repo: slug,
70
+ selection: { kind: "prs", requested: pulls.map((pull) => pull.pr) },
71
+ reason: "all_terminal",
72
+ prs,
73
+ };
74
+ }
75
+ function applyProbe(item, pull) {
76
+ if (!pull)
77
+ return item;
78
+ return {
79
+ ...item,
80
+ action: "cancel",
81
+ state: pull.state,
82
+ reasons: [pull.state === "MERGED" ? "merged" : "closed"],
83
+ };
84
+ }
85
+ function syntheticItem(repo, pull) {
86
+ return {
87
+ pr: pull.pr,
88
+ repo,
89
+ title: pull.title || `PR ${pull.pr}`,
90
+ url: pull.url || `https://github.com/${repo}/pull/${pull.pr}`,
91
+ action: "cancel",
92
+ reasons: [pull.state === "MERGED" ? "merged" : "closed"],
93
+ state: pull.state,
94
+ mergeable: pull.mergeable,
95
+ mergeStateStatus: pull.mergeStateStatus,
96
+ headRefName: pull.headRefName,
97
+ headRefOid: pull.headRefOid,
98
+ baseRefName: pull.baseRefName,
99
+ ...(pull.draft ? { isDraft: true } : {}),
100
+ };
101
+ }
@@ -0,0 +1 @@
1
+ export declare function exhaustedPrimaryLimitDelayMs(resetAtSeconds: number, nowMs: number): number;
@@ -0,0 +1,7 @@
1
+ /** Exhausted primary limit: time until resetAt, plus 5s, plus `resetAt % 5` seconds. */
2
+ const EXHAUSTED_LIMIT_MARGIN_MS = 5_000;
3
+ export function exhaustedPrimaryLimitDelayMs(resetAtSeconds, nowMs) {
4
+ return (Math.max(resetAtSeconds * 1000 - nowMs, 0) +
5
+ EXHAUSTED_LIMIT_MARGIN_MS +
6
+ (resetAtSeconds % 5) * 1000);
7
+ }
@@ -0,0 +1,14 @@
1
+ import type { RateLimitProbePull, RateLimitPullTarget } from "./poll-rate-limit-cancel.mts";
2
+ interface WaitOptions<T> {
3
+ untilTerminal: boolean;
4
+ intervalMs: number;
5
+ tickLabel: string;
6
+ startedAt: number;
7
+ targets: RateLimitPullTarget[];
8
+ onAllTerminal?: (pulls: RateLimitProbePull[]) => T | undefined;
9
+ }
10
+ export declare function createUntilTerminalRateLimitRetry(): {
11
+ reset(): void;
12
+ wait<T>(err: unknown, opts: WaitOptions<T>): Promise<T | undefined>;
13
+ };
14
+ export {};