pr-shepherd 0.53.1 → 0.54.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 (88) 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/iterate-branch-segment.d.mts +3 -0
  15. package/bin/cli/iterate-branch-segment.mjs +13 -0
  16. package/bin/cli/iterate-formatter.mjs +5 -13
  17. package/bin/cli/iterate-lean.mjs +1 -0
  18. package/bin/cli/poll-handler.mjs +5 -1
  19. package/bin/cli-parser.mjs +4 -0
  20. package/bin/commands/apply-check-blocker.d.mts +22 -0
  21. package/bin/commands/apply-check-blocker.mjs +36 -0
  22. package/bin/commands/check-annotations.d.mts +1 -1
  23. package/bin/commands/check-annotations.mjs +27 -17
  24. package/bin/commands/check-blocker-ref.d.mts +3 -0
  25. package/bin/commands/check-blocker-ref.mjs +50 -0
  26. package/bin/commands/check.mjs +1 -0
  27. package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
  28. package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
  29. package/bin/commands/iterate/check-instructions.d.mts +2 -0
  30. package/bin/commands/iterate/check-instructions.mjs +4 -0
  31. package/bin/commands/iterate/conflicting-head-ci.d.mts +18 -0
  32. package/bin/commands/iterate/conflicting-head-ci.mjs +32 -0
  33. package/bin/commands/iterate/fix-code.d.mts +2 -0
  34. package/bin/commands/iterate/fix-code.mjs +61 -16
  35. package/bin/commands/iterate/index.mjs +22 -8
  36. package/bin/commands/iterate/native-stack-rebase.d.mts +8 -2
  37. package/bin/commands/iterate/native-stack-rebase.mjs +14 -4
  38. package/bin/commands/iterate/stack-trunk-conflict.d.mts +13 -0
  39. package/bin/commands/iterate/stack-trunk-conflict.mjs +51 -0
  40. package/bin/commands/poll-quota.d.mts +2 -0
  41. package/bin/commands/poll-quota.mjs +25 -12
  42. package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
  43. package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
  44. package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
  45. package/bin/commands/poll-rate-limit-delay.mjs +7 -0
  46. package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
  47. package/bin/commands/poll-rate-limit-wait.mjs +125 -0
  48. package/bin/commands/poll-summary.mjs +21 -9
  49. package/bin/commands/poll.mjs +22 -17
  50. package/bin/config/load.d.mts +2 -0
  51. package/bin/config/load.mjs +24 -2
  52. package/bin/config.json +1 -0
  53. package/bin/github/batch-parse-suites.d.mts +2 -0
  54. package/bin/github/batch-parse-suites.mjs +5 -0
  55. package/bin/github/batch.d.mts +2 -0
  56. package/bin/github/batch.mjs +2 -1
  57. package/bin/github/check-annotation-cache.d.mts +8 -0
  58. package/bin/github/check-annotation-cache.mjs +23 -0
  59. package/bin/github/check-annotation-pages.d.mts +11 -0
  60. package/bin/github/check-annotation-pages.mjs +36 -0
  61. package/bin/github/check-annotation-shape.d.mts +21 -0
  62. package/bin/github/check-annotation-shape.mjs +50 -0
  63. package/bin/github/check-annotations-batch.d.mts +17 -0
  64. package/bin/github/check-annotations-batch.mjs +92 -0
  65. package/bin/github/check-annotations.d.mts +8 -11
  66. package/bin/github/check-annotations.mjs +14 -103
  67. package/bin/github/gql/batch-pr.gql +1 -1
  68. package/bin/github/gql/check-run-annotations-batch.gql +41 -0
  69. package/bin/github/gql/upper-layer-conflict-target.gql +36 -0
  70. package/bin/github/pagination.d.mts +1 -1
  71. package/bin/github/pagination.mjs +1 -1
  72. package/bin/github/poll-summary-check-blockers.d.mts +16 -0
  73. package/bin/github/poll-summary-check-blockers.mjs +25 -0
  74. package/bin/github/poll-summary-checks.d.mts +2 -0
  75. package/bin/github/poll-summary-checks.mjs +35 -20
  76. package/bin/github/poll-summary-projector.mjs +5 -0
  77. package/bin/github/queries.d.mts +11 -0
  78. package/bin/github/queries.mjs +11 -0
  79. package/bin/state/check-blockers.d.mts +30 -0
  80. package/bin/state/check-blockers.mjs +88 -0
  81. package/bin/state/conflicting-head-seen.d.mts +13 -0
  82. package/bin/state/conflicting-head-seen.mjs +44 -0
  83. package/bin/types/iterate.d.mts +5 -0
  84. package/bin/types/report.d.mts +2 -0
  85. package/package.json +1 -1
  86. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  87. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  88. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,4 +1,5 @@
1
- import { fetchCheckRunAnnotations, } from "../github/check-annotations.mjs";
1
+ import { fetchCheckRunAnnotationsBatch } from "../github/check-annotations-batch.mjs";
2
+ import { pollRateLimitRetryAfterMs } from "./poll-quota.mjs";
2
3
  function shouldFetchCheckAnnotations(check) {
3
4
  return check.id != null && check.status === "COMPLETED" && check.hasAnnotations === true;
4
5
  }
@@ -58,30 +59,39 @@ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber,
58
59
  };
59
60
  }
60
61
  async function attachUnseenCheckAnnotations(checks, seenMap, prNumber, cacheOpts) {
61
- const checksWithAnnotations = [];
62
- for (const check of checks) {
63
- // eslint-disable-next-line no-await-in-loop
64
- checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber, cacheOpts));
65
- }
66
- return checksWithAnnotations;
67
- }
68
- async function attachForCheck(check, seenMap, prNumber, cacheOpts) {
69
- if (check.id == null)
70
- return check;
71
- let annotations;
62
+ if (checks.length === 0)
63
+ return checks;
64
+ const ids = checks.flatMap((check) => (check.id == null ? [] : [check.id]));
65
+ let batch;
72
66
  try {
73
- annotations = await fetchCheckRunAnnotations(check.id, cacheOpts);
67
+ batch = await fetchCheckRunAnnotationsBatch(ids, cacheOpts);
74
68
  }
75
69
  catch (err) {
76
- const msg = err instanceof Error ? err.message : String(err);
77
- process.stderr.write(`pr-shepherd: annotation fetch failed for PR #${prNumber} check "${check.name}" (ignored): ${msg}\n`);
78
- return check;
70
+ if (pollRateLimitRetryAfterMs(err) !== null)
71
+ throw err;
72
+ writeAnnotationFailureSummary(prNumber, ids.length, err);
73
+ return checks;
79
74
  }
80
- const unseen = annotations.filter((a) => !seenMap.has(a.id));
75
+ const firstFailure = batch.failures[0];
76
+ if (firstFailure !== undefined) {
77
+ writeAnnotationFailureSummary(prNumber, batch.failures.length, firstFailure.error);
78
+ }
79
+ return checks.map((check) => withUnseenAnnotations(check, batch.annotations, seenMap));
80
+ }
81
+ function withUnseenAnnotations(check, annotations, seenMap) {
82
+ const fetched = check.id == null ? undefined : annotations.get(check.id);
83
+ if (fetched === undefined)
84
+ return check;
85
+ const unseen = fetched.filter((annotation) => !seenMap.has(annotation.id));
81
86
  if (unseen.length === 0)
82
87
  return check;
83
88
  return { ...check, annotations: unseen };
84
89
  }
90
+ function writeAnnotationFailureSummary(prNumber, count, err) {
91
+ const noun = count === 1 ? "check" : "checks";
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ process.stderr.write(`pr-shepherd: annotation fetch failed for ${count} ${noun} on PR #${prNumber} (ignored): ${message}\n`);
94
+ }
85
95
  export function annotationMarkerBody(a) {
86
96
  return JSON.stringify({
87
97
  path: a.path,
@@ -0,0 +1,3 @@
1
+ import type { CheckBlockerRef } from "../state/check-blockers.mts";
2
+ /** Parse `--blocked-by`. Returns null for anything other than the four accepted forms. */
3
+ export declare function parseBlockedByRef(raw: string): CheckBlockerRef | null;
@@ -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
+ }
@@ -259,6 +259,7 @@ export async function runCheck(opts) {
259
259
  status,
260
260
  baseBranch: batchData.baseRefName,
261
261
  ...(batchData.baseRefOid && { baseRefOid: batchData.baseRefOid }),
262
+ ...(result.headCheckSuitesEmpty && { headCheckSuitesEmpty: true }),
262
263
  mergeStatus,
263
264
  checks: {
264
265
  passing: merged.passing,
@@ -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`"
@@ -0,0 +1,18 @@
1
+ export declare function conflictingHeadCiNote(input: {
2
+ hasConflicts: boolean;
3
+ headCheckSuitesEmpty: boolean;
4
+ checkRunCount: number;
5
+ /** Unix seconds when Shepherd first saw this head. Omit when that time is unknown. */
6
+ firstSeenAtUnix: number | null | undefined;
7
+ nowMs: number;
8
+ }): string | undefined;
9
+ export declare function countReportedChecks(checks: {
10
+ passing: readonly unknown[];
11
+ failing: readonly unknown[];
12
+ inProgress: readonly unknown[];
13
+ skipped: readonly unknown[];
14
+ filtered: readonly unknown[];
15
+ ignored?: readonly unknown[];
16
+ }): number;
17
+ /** Place the missing-CI note with the conflict step, ahead of later repair lines. */
18
+ export declare function insertConflictingHeadCiNote(instructions: string[], note: string | undefined): void;
@@ -0,0 +1,32 @@
1
+ /** How long a conflicting head may sit with no CI before Shepherd says none will start. */
2
+ const CONFLICTING_HEAD_CI_GRACE_MS = 2 * 60 * 1000;
3
+ const NO_CI_NOTE = "GitHub did not start pull_request workflows for this conflicting head: no check suites or workflow runs have appeared for at least 2 minutes. Missing CI is a consequence of the conflict, not an outage, and pushing this same head again will not start them.";
4
+ export function conflictingHeadCiNote(input) {
5
+ if (!input.hasConflicts || !input.headCheckSuitesEmpty || input.checkRunCount > 0) {
6
+ return undefined;
7
+ }
8
+ if (typeof input.firstSeenAtUnix !== "number")
9
+ return undefined;
10
+ const ageMs = input.nowMs - input.firstSeenAtUnix * 1000;
11
+ if (!Number.isFinite(ageMs) || ageMs < CONFLICTING_HEAD_CI_GRACE_MS)
12
+ return undefined;
13
+ return NO_CI_NOTE;
14
+ }
15
+ export function countReportedChecks(checks) {
16
+ return (checks.passing.length +
17
+ checks.failing.length +
18
+ checks.inProgress.length +
19
+ checks.skipped.length +
20
+ checks.filtered.length +
21
+ (checks.ignored?.length ?? 0));
22
+ }
23
+ /** Place the missing-CI note with the conflict step, ahead of later repair lines. */
24
+ export function insertConflictingHeadCiNote(instructions, note) {
25
+ if (!note)
26
+ return;
27
+ const conflictAt = instructions.findIndex((step) => step.includes("merge conflicts"));
28
+ if (conflictAt >= 0)
29
+ instructions.splice(conflictAt + 1, 0, note);
30
+ else
31
+ instructions.unshift(note);
32
+ }
@@ -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,7 +6,11 @@ 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";
11
+ import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
12
+ import { conflictingHeadCiNote, countReportedChecks, insertConflictingHeadCiNote, } from "./conflicting-head-ci.mjs";
13
+ import { conflictingHeadFirstSeenUnix } from "../../state/conflicting-head-seen.mjs";
10
14
  import { applyStallGuard } from "./stall.mjs";
11
15
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
12
16
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
@@ -14,6 +18,7 @@ import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs
14
18
  import { canRerunWorkflows } from "../../checks/conclusions.mjs";
15
19
  import { loadConfig } from "../../config/load.mjs";
16
20
  import { formatPrUrl } from "../../pr-reference.mjs";
21
+ const EMPTY_RELEASED = new Set();
17
22
  function checkRequiresHumanFollowUp(check) {
18
23
  if (check.rerunCommand)
19
24
  return false;
@@ -68,7 +73,7 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
68
73
  return Object.keys(pending).length > 0 ? pending : undefined;
69
74
  }
70
75
  export async function handleFixCode(ctx) {
71
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
76
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, releasedCheckNames = EMPTY_RELEASED, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
72
77
  const prReference = formatPrUrl(report.repo, prNumber);
73
78
  const failingChecks = report.checks.failing;
74
79
  const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
@@ -158,18 +163,22 @@ export async function handleFixCode(ctx) {
158
163
  ? [c.runId]
159
164
  : []));
160
165
  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);
166
+ const failingAgentChecks = toAgentChecks(failingChecks).map((c) => {
167
+ if (releasedCheckNames.has(c.name))
168
+ return c;
169
+ return rerunAuthorized &&
170
+ c.runId &&
171
+ actionsRunIds.has(c.runId) &&
172
+ // GitHub increments run_attempt after every rerun. Recommend at most one rerun by limiting
173
+ // the command to the original attempt; missing attempt metadata is denied conservatively.
174
+ initialAttemptRunIds.has(c.runId) &&
175
+ // ACTION_REQUIRED means the run is paused pending manual workflow approval; rerunning does
176
+ // not grant that approval, so no rerun command applies.
177
+ c.conclusion !== "ACTION_REQUIRED" &&
178
+ !inProgressWorkflowRunIds.has(c.runId)
179
+ ? { ...c, rerunCommand: `gh run rerun ${c.runId} -R ${report.repo}` }
180
+ : c;
181
+ });
173
182
  const checks = [
174
183
  ...failingAgentChecks,
175
184
  ...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
@@ -184,7 +193,9 @@ export async function handleFixCode(ctx) {
184
193
  const inProgressRunIds = [];
185
194
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
186
195
  const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
187
- const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
196
+ const manualFollowUpChecks = failingAgentChecks.filter((check) => !releasedCheckNames.has(check.name) &&
197
+ !belongsToActiveWorkflowRun(check) &&
198
+ checkRequiresHumanFollowUp(check));
188
199
  const exhaustedAttempts = failingAgentChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
189
200
  const manualExhaustedAttempts = exhaustedAttempts.filter((check) => manualFollowUpChecks.includes(check));
190
201
  const hasBehindBaseRecovery = isBehind && manualExhaustedAttempts.length > 0;
@@ -201,7 +212,9 @@ export async function handleFixCode(ctx) {
201
212
  report.threads.firstLook.length > 0 ||
202
213
  report.comments.firstLook.length > 0 ||
203
214
  checks.some((check) => (check.annotations?.length ?? 0) > 0) ||
204
- failingAgentChecks.some((check) => belongsToActiveWorkflowRun(check) || !checkRequiresHumanFollowUp(check));
215
+ failingAgentChecks.some((check) => releasedCheckNames.has(check.name) ||
216
+ belongsToActiveWorkflowRun(check) ||
217
+ !checkRequiresHumanFollowUp(check));
205
218
  if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
206
219
  const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
207
220
  const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
@@ -272,17 +285,49 @@ export async function handleFixCode(ctx) {
272
285
  }
273
286
  const firstLookThreads = report.threads.firstLook;
274
287
  const firstLookComments = report.comments.firstLook;
288
+ const stack = report.mergeStatus.mergeRequirements?.stack;
289
+ const headRef = report.headSha;
290
+ // Only an upper layer can be dirty against trunk while already containing its parent.
291
+ const trunkConflict = hasConflicts && stack && headRef && baseLookup.branch !== stack.baseRefName
292
+ ? await lookupUpperLayerTrunkConflict({
293
+ owner: repoOwner,
294
+ name: repoName,
295
+ pr: prNumber,
296
+ headRef,
297
+ trunk: stack.baseRefName,
298
+ })
299
+ : undefined;
275
300
  // Conflicts, and a behind branch whose rerun already failed, both ask for a branch update.
276
301
  const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0)
277
- ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, report.mergeStatus.mergeRequirements?.stack)
302
+ ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
278
303
  : undefined;
279
304
  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);
305
+ if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
306
+ const completion = instructions.pop();
307
+ instructions.push(buildReleasedBlockerInstruction(prNumber));
308
+ if (completion !== undefined)
309
+ instructions.push(completion);
310
+ }
280
311
  if (repairInstructions && repairInstructions.length > 0) {
281
312
  instructions.unshift(...repairInstructions);
282
313
  }
314
+ const checkRunCount = countReportedChecks(report.checks);
315
+ const suitesEmpty = report.headCheckSuitesEmpty === true;
316
+ const nowMs = Date.now();
317
+ const firstSeenAtUnix = hasConflicts && suitesEmpty && checkRunCount === 0 && report.headSha
318
+ ? await conflictingHeadFirstSeenUnix({ owner: repoOwner, repo: repoName, pr: prNumber }, report.headSha, nowMs)
319
+ : undefined;
320
+ insertConflictingHeadCiNote(instructions, conflictingHeadCiNote({
321
+ hasConflicts,
322
+ headCheckSuitesEmpty: suitesEmpty,
323
+ checkRunCount,
324
+ firstSeenAtUnix,
325
+ nowMs,
326
+ }));
283
327
  const prospectiveResult = {
284
328
  ...base,
285
329
  baseBranch: baseLookup.branch,
330
+ ...(trunkConflict && { stackTrunkConflict: trunkConflict.trunk }),
286
331
  action: "fix_code",
287
332
  fix: {
288
333
  threads,
@@ -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" ||
@@ -7,6 +7,8 @@ export type NativeStackRebaseStart = {
7
7
  parentBranch: string;
8
8
  } | {
9
9
  bottomPr: number;
10
+ } | {
11
+ trunk: string;
10
12
  };
11
13
  /**
12
14
  * One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
@@ -22,12 +24,16 @@ export declare function buildNativeStackRebaseInstruction(repo: string, stackNum
22
24
  * workflow keeps failing); undefined outside a stack.
23
25
  * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
24
26
  * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
25
- * layer is an upper layer whose parent is its own PR base branch.
27
+ * layer is an upper layer whose parent is its own PR base branch. `trunkConflict` means that
28
+ * upper layer already contains its parent, so the rebase starts at the bottom open layer.
26
29
  */
27
30
  export declare function buildNativeStackLayerRebase(repo: string, pr: {
28
31
  number: number;
29
32
  baseBranch: string;
30
- }, stack: StackStatus | undefined): string | undefined;
33
+ }, stack: StackStatus | undefined, trunkConflict?: {
34
+ trunk: string;
35
+ bottomPr?: number;
36
+ }): string | undefined;
31
37
  /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
32
38
  export declare function buildConflictInstruction(stackRebase: string | undefined): string;
33
39
  /** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
@@ -12,7 +12,9 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
12
12
  `check out the parent stack branch \`${start.parentBranch}\``,
13
13
  "gh stack rebase --upstack --no-trunk",
14
14
  ]
15
- : [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"];
15
+ : "bottomPr" in start
16
+ ? [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"]
17
+ : [`check out the bottom open layer whose base is \`${start.trunk}\``, "gh stack rebase"];
16
18
  const prepare = `if \`gh stack\` does not track stack #${stackNumber} locally, import it with \`gh stack checkout ${stackNumber}\`, then confirm every layer's local branch is at its PR's head commit — a stale local layer would overwrite that PR's newer commits on push`;
17
19
  return `From a clean checkout of \`${repo}\`, ${prepare}. Then ${checkout} and run \`${command}\`; if it stops on a conflict, resolve it and run \`gh stack rebase --continue\`.`;
18
20
  }
@@ -21,12 +23,20 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
21
23
  * workflow keeps failing); undefined outside a stack.
22
24
  * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
23
25
  * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
24
- * layer is an upper layer whose parent is its own PR base branch.
26
+ * layer is an upper layer whose parent is its own PR base branch. `trunkConflict` means that
27
+ * upper layer already contains its parent, so the rebase starts at the bottom open layer.
25
28
  */
26
- export function buildNativeStackLayerRebase(repo, pr, stack) {
29
+ export function buildNativeStackLayerRebase(repo, pr, stack, trunkConflict) {
27
30
  if (!stack)
28
31
  return undefined;
29
- return buildNativeStackRebaseInstruction(repo, stack.number, pr.baseBranch === stack.baseRefName ? { bottomPr: pr.number } : { parentBranch: pr.baseBranch });
32
+ const start = trunkConflict
33
+ ? trunkConflict.bottomPr !== undefined
34
+ ? { bottomPr: trunkConflict.bottomPr }
35
+ : { trunk: trunkConflict.trunk }
36
+ : pr.baseBranch === stack.baseRefName
37
+ ? { bottomPr: pr.number }
38
+ : { parentBranch: pr.baseBranch };
39
+ return buildNativeStackRebaseInstruction(repo, stack.number, start);
30
40
  }
31
41
  /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
32
42
  export function buildConflictInstruction(stackRebase) {