pr-shepherd 0.51.0 → 0.52.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +25 -17
  3. package/bin/checks/log-excerpt.d.mts +1 -0
  4. package/bin/checks/log-excerpt.mjs +192 -0
  5. package/bin/checks/triage.mjs +2 -121
  6. package/bin/cli/iterate-lean.mjs +0 -3
  7. package/bin/cli/poll-summary-emitter.mjs +2 -3
  8. package/bin/cli/poll-summary-formatter.mjs +9 -2
  9. package/bin/commands/check.mjs +1 -0
  10. package/bin/commands/iterate/escalate.mjs +0 -13
  11. package/bin/commands/iterate/fix-code.d.mts +2 -0
  12. package/bin/commands/iterate/fix-code.mjs +14 -42
  13. package/bin/commands/iterate/index.mjs +140 -3
  14. package/bin/commands/iterate/merge-state.mjs +33 -18
  15. package/bin/commands/iterate/parent-first.d.mts +9 -0
  16. package/bin/commands/iterate/parent-first.mjs +57 -0
  17. package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
  18. package/bin/commands/iterate/stale-ancestry.mjs +37 -0
  19. package/bin/commands/poll-summary-instructions.mjs +179 -99
  20. package/bin/commands/poll-summary.mjs +28 -4
  21. package/bin/exit-codes.d.mts +2 -0
  22. package/bin/exit-codes.mjs +2 -0
  23. package/bin/github/batch-parsers.mjs +1 -0
  24. package/bin/github/batch-raw-rules.d.mts +3 -0
  25. package/bin/github/gql/poll-summary-fragment.gql +54 -0
  26. package/bin/github/gql/pr-merge-policy.gql +3 -0
  27. package/bin/github/poll-summary-fingerprint.d.mts +8 -0
  28. package/bin/github/poll-summary-fingerprint.mjs +28 -0
  29. package/bin/github/poll-summary-projector.mjs +67 -14
  30. package/bin/github/poll-summary-queue-removal.d.mts +5 -0
  31. package/bin/github/poll-summary-queue-removal.mjs +16 -0
  32. package/bin/github/poll-summary-raw.d.mts +33 -0
  33. package/bin/github/poll-summary-readiness.d.mts +6 -0
  34. package/bin/github/poll-summary-readiness.mjs +25 -0
  35. package/bin/github/poll-summary.d.mts +3 -0
  36. package/bin/github/poll-summary.mjs +5 -0
  37. package/bin/state/ready-receipts.d.mts +45 -0
  38. package/bin/state/ready-receipts.mjs +86 -0
  39. package/bin/types/escalate.d.mts +2 -3
  40. package/bin/types/github.d.mts +2 -0
  41. package/bin/types/poll-summary.d.mts +12 -2
  42. package/bin/types/report.d.mts +2 -0
  43. package/package.json +4 -4
  44. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  45. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  46. package/plugins/pr-shepherd/.mcp.json +1 -1
  47. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  48. package/bin/state/bot-cr-seen.d.mts +0 -51
  49. package/bin/state/bot-cr-seen.mjs +0 -100
@@ -16,6 +16,14 @@ import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mj
16
16
  import { buildIterateBase } from "./base.mjs";
17
17
  import { markReadyIfAuthorized } from "./mark-ready.mjs";
18
18
  import { withIterateApiUsage } from "./run.mjs";
19
+ import { fetchRawSummaryPr } from "../../github/poll-summary.mjs";
20
+ import { summarizePollSummaryPr } from "../../github/poll-summary-projector.mjs";
21
+ import { fingerprintRawSummaryPr } from "../../github/poll-summary-fingerprint.mjs";
22
+ import { currentQueueRemovalEvent } from "../../github/poll-summary-queue-removal.mjs";
23
+ import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
24
+ import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
25
+ import { parentBlocksMarkReady } from "./parent-first.mjs";
26
+ import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
19
27
  export function runIterate(opts) {
20
28
  return withIterateApiUsage(opts, () => runIterateCore(opts));
21
29
  }
@@ -80,8 +88,19 @@ async function runIterateCore(opts) {
80
88
  editedSummaries.length > 0 ||
81
89
  (config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
82
90
  const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
83
- const isCleanReadyState = report.status === "READY" && !hasActionableWork && !activeMerge;
91
+ const staleAncestry = await findStaleNativeStackAncestry(report, {
92
+ owner: repoOwner,
93
+ name: repoName,
94
+ });
95
+ const isCleanReadyState = report.status === "READY" &&
96
+ !report.mergeStatus.isDraft &&
97
+ !hasActionableWork &&
98
+ !activeMerge &&
99
+ staleAncestry === null;
84
100
  const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
101
+ if (report.mergeStatus.mergeRequirements?.stack) {
102
+ await invalidateStaleReadyReceipt({ owner: repoOwner, repo: repoName, pr: report.pr }, report, hasActionableWork);
103
+ }
85
104
  const base = buildIterateBase(report, readyState);
86
105
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
87
106
  // Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
@@ -126,13 +145,50 @@ async function runIterateCore(opts) {
126
145
  });
127
146
  if (mergeStateResult)
128
147
  return mergeStateResult;
148
+ if (staleAncestry) {
149
+ return handleFixCode({
150
+ base,
151
+ report,
152
+ opts: { ...opts, prNumber, neverCancelRuns },
153
+ headSha,
154
+ stallKey,
155
+ prNumber,
156
+ stallTimeoutSeconds,
157
+ repoOwner,
158
+ repoName,
159
+ reviewSummaryIds,
160
+ firstLookSummaries,
161
+ editedSummaries,
162
+ surfacedApprovals,
163
+ botUsernames,
164
+ repairInstructions: staleAncestry.instructions,
165
+ ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
166
+ });
167
+ }
129
168
  const canMarkReady = report.status === "READY" &&
130
169
  report.mergeStatus.isDraft &&
131
170
  !report.mergeStatus.blockingBotReviewInProgress;
132
- const markReadyResult = await markReadyIfAuthorized(canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady, base, report);
171
+ const blockedByParent = canMarkReady
172
+ ? await parentBlocksMarkReady(report, { owner: repoOwner, name: repoName })
173
+ : false;
174
+ const markReadyResult = await markReadyIfAuthorized(canMarkReady && !blockedByParent && !opts.noAutoMarkReady && config.actions.autoMarkReady, base, report);
133
175
  if (markReadyResult)
134
176
  return markReadyResult;
135
- if (readyState.shouldCancel) {
177
+ if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
178
+ const needsStackReceipt = report.mergeStatus.mergeRequirements?.stack !== undefined;
179
+ const receiptWritten = needsStackReceipt
180
+ ? await recordReadyReceipt({ owner: repoOwner, repo: repoName, pr: report.pr }, report)
181
+ : true;
182
+ if (!receiptWritten) {
183
+ const receiptWait = {
184
+ ...base,
185
+ action: "wait",
186
+ shouldCancel: false,
187
+ remainingSeconds: readyDelaySeconds,
188
+ log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
189
+ };
190
+ return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, report, reviewSummaryIds);
191
+ }
136
192
  await clearStallState(stallKey);
137
193
  const mergeResult = buildReadyMergeOutcome(opts.merge, true, base, report);
138
194
  if (mergeResult)
@@ -147,3 +203,84 @@ async function runIterateCore(opts) {
147
203
  }
148
204
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, { ...base, action: "wait", log: buildWaitLog(base) }, report, reviewSummaryIds);
149
205
  }
206
+ async function recordReadyReceipt(key, report) {
207
+ if (report.status !== "READY" ||
208
+ report.mergeStatus.state !== "OPEN" ||
209
+ report.mergeStatus.isDraft ||
210
+ !report.headSha ||
211
+ !report.baseRefOid)
212
+ return false;
213
+ try {
214
+ const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
215
+ const fingerprint = fingerprintRawSummaryPr(raw);
216
+ if (fingerprint === null ||
217
+ raw.state !== "OPEN" ||
218
+ raw.isDraft ||
219
+ raw.headRefOid !== report.headSha ||
220
+ raw.baseRefOid !== report.baseRefOid)
221
+ return false;
222
+ const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
223
+ if (!isCurrentSummaryReady(raw, summary.checks ?? {}, summary.review ?? {}))
224
+ return false;
225
+ const removalEvent = currentQueueRemovalEvent(raw);
226
+ await writeReadyReceipt({
227
+ version: 1,
228
+ ...key,
229
+ headRefOid: raw.headRefOid,
230
+ baseRefOid: raw.baseRefOid,
231
+ status: "READY",
232
+ isDraft: false,
233
+ readinessFingerprint: fingerprint,
234
+ ...(removalEvent?.id && {
235
+ acknowledgedQueueRemovalId: removalEvent.id,
236
+ }),
237
+ recordedAtUnix: Math.floor(Date.now() / 1000),
238
+ });
239
+ return true;
240
+ }
241
+ catch {
242
+ // The receipt is supplementary evidence. A transient summary fetch or
243
+ // state-directory failure must not turn a completed one-PR poll into a
244
+ // different Shepherd action.
245
+ return false;
246
+ }
247
+ }
248
+ async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
249
+ const receipt = await readReadyReceipt(key);
250
+ if (!receipt)
251
+ return;
252
+ const retainQueuedReceipt = report.mergeQueue?.inQueue === true &&
253
+ report.mergeStatus.state === "OPEN" &&
254
+ !report.mergeStatus.isDraft &&
255
+ Boolean(report.headSha && report.baseRefOid);
256
+ if (report.mergeStatus.state !== "OPEN" ||
257
+ report.mergeStatus.isDraft ||
258
+ !report.headSha ||
259
+ !report.baseRefOid ||
260
+ (!retainQueuedReceipt && (report.status !== "READY" || hasActionableWork))) {
261
+ await clearReadyReceipt(key);
262
+ return;
263
+ }
264
+ try {
265
+ const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
266
+ // Queue predecessors can advance the target branch without changing this
267
+ // PR's source. Compare against the receipt's base only while both fresh
268
+ // views still place the PR in the queue.
269
+ const queuedBaseAdvanced = retainQueuedReceipt && raw.isInMergeQueue;
270
+ const fingerprint = fingerprintRawSummaryPr(queuedBaseAdvanced ? { ...raw, baseRefOid: receipt.baseRefOid } : raw);
271
+ if (fingerprint === null ||
272
+ !isReadyReceiptCurrent(receipt, {
273
+ headRefOid: raw.headRefOid,
274
+ baseRefOid: queuedBaseAdvanced ? receipt.baseRefOid : raw.baseRefOid,
275
+ readinessFingerprint: fingerprint,
276
+ status: "READY",
277
+ isDraft: raw.isDraft,
278
+ })) {
279
+ await clearReadyReceipt(key);
280
+ }
281
+ }
282
+ catch {
283
+ // Fail closed: an unreadable current snapshot cannot validate old evidence.
284
+ await clearReadyReceipt(key);
285
+ }
286
+ }
@@ -2,26 +2,41 @@ import { clearStallState } from "../../state/iterate-stall.mjs";
2
2
  import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
3
3
  import { buildMergeCommandPlan } from "./merge.mjs";
4
4
  import { formatPrUrl } from "../../pr-reference.mjs";
5
- /** A stacked PR's merge is human-only: `gh pr merge` targets the PR's own base, which for a
6
- * mid-stack layer is an unmerged parent branch, and auto-merge is unsupported on stacks. */
7
- function buildStackedEscalateResult(base, report, stack) {
8
- const escalateBase = {
9
- triggers: ["stacked-pr"],
10
- unresolvedThreads: [],
11
- ambiguousComments: [],
12
- changesRequestedReviews: [],
13
- stack,
14
- suggestion: buildEscalateSuggestion(["stacked-pr"], String(report.pr)),
15
- };
5
+ /**
6
+ * A one-PR poll cannot prove every native-stack layer is ready or linear. Route it through the
7
+ * aggregate stack selector, which does that reconciliation before emitting the whole-stack merge.
8
+ */
9
+ function buildStackedRouteResult(base, report, stack) {
10
+ const prUrl = formatPrUrl(report.repo, report.pr);
16
11
  return {
17
12
  ...base,
18
- action: "escalate",
19
- escalate: {
20
- ...escalateBase,
21
- humanMessage: buildEscalateHumanMessage(escalateBase, formatPrUrl(report.repo, report.pr), {
22
- merge: true,
23
- }),
13
+ action: "fix_code",
14
+ fix: {
15
+ threads: [],
16
+ resolutionOnlyThreads: [],
17
+ actionableComments: [],
18
+ reviewSummaryIds: [],
19
+ firstLookSummaries: [],
20
+ editedSummaries: [],
21
+ surfacedApprovals: [],
22
+ checks: [],
23
+ changesRequestedReviews: [],
24
+ resolveCommand: {
25
+ argv: ["pr-shepherd", "apply", "review", prUrl],
26
+ requiresHeadSha: false,
27
+ requiresDismissMessage: false,
28
+ hasMutations: false,
29
+ },
30
+ instructions: [
31
+ `PR #${report.pr} is layer ${stack.position} of ${stack.size} in native stack #${stack.number}; do not run \`gh pr merge\` for this layer.`,
32
+ `Run \`pr-shepherd --stack ${prUrl} --until-terminal --merge\` to reconcile the complete stack and run its emitted whole-stack merge command.`,
33
+ ],
34
+ inProgressRunIds: [],
35
+ protectedRuns: [],
36
+ firstLookThreads: [],
37
+ firstLookComments: [],
24
38
  },
39
+ cancelled: [],
25
40
  };
26
41
  }
27
42
  export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
@@ -29,7 +44,7 @@ export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
29
44
  return null;
30
45
  const stack = report.mergeStatus.mergeRequirements?.stack;
31
46
  if (stack)
32
- return buildStackedEscalateResult(base, report, stack);
47
+ return buildStackedRouteResult(base, report, stack);
33
48
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
34
49
  report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
35
50
  return {
@@ -0,0 +1,9 @@
1
+ import type { RepoInfo } from "../../github/client.mts";
2
+ import type { ShepherdReport } from "../../types.mts";
3
+ /**
4
+ * Draft children may only be converted after their immediate parent has
5
+ * independently completed a one-PR ready-delay and the stack boundary is
6
+ * still linear. A failed or incomplete stack read blocks this mutation but
7
+ * does not block ordinary review/CI work in the caller.
8
+ */
9
+ export declare function parentBlocksMarkReady(report: ShepherdReport, repo: RepoInfo): Promise<boolean>;
@@ -0,0 +1,57 @@
1
+ import { fetchPollSummary } from "../../github/poll-summary.mjs";
2
+ /**
3
+ * Draft children may only be converted after their immediate parent has
4
+ * independently completed a one-PR ready-delay and the stack boundary is
5
+ * still linear. A failed or incomplete stack read blocks this mutation but
6
+ * does not block ordinary review/CI work in the caller.
7
+ */
8
+ export async function parentBlocksMarkReady(report, repo) {
9
+ const stack = report.mergeStatus.mergeRequirements?.stack;
10
+ if (!stack || stack.position === 1)
11
+ return false;
12
+ if (stack.position < 1)
13
+ return true;
14
+ try {
15
+ const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
16
+ const child = summary.prs.find((item) => item.pr === report.pr);
17
+ const lowerLayers = summary.prs
18
+ .filter((item) => (item.stack?.position ?? Number.MAX_SAFE_INTEGER) < stack.position)
19
+ .sort((left, right) => (left.stack?.position ?? Number.MAX_SAFE_INTEGER) -
20
+ (right.stack?.position ?? Number.MAX_SAFE_INTEGER));
21
+ if (!child || child.state !== "OPEN" || lowerLayers.length !== stack.position - 1)
22
+ return true;
23
+ // Any stale boundary up through the child means at least one lower layer
24
+ // is no longer the base it was reviewed against. Ignore gaps above this
25
+ // child because they do not affect its immediate promotion boundary.
26
+ const checkedLayers = new Set([report.pr, ...lowerLayers.map((item) => item.pr)]);
27
+ if (summary.stackAncestry?.some((gap) => checkedLayers.has(gap.childPr)))
28
+ return true;
29
+ for (const parent of lowerLayers) {
30
+ // A merged parent is already satisfied; GitHub may have retargeted the
31
+ // child to the trunk as part of the merge.
32
+ if (parent.state === "MERGED")
33
+ continue;
34
+ if (parent.state !== "OPEN")
35
+ return true;
36
+ if (parent.isDraft || parent.mergeable === "CONFLICTING")
37
+ return true;
38
+ if (["DIRTY", "BEHIND", "UNKNOWN"].includes(parent.mergeStateStatus))
39
+ return true;
40
+ // A receipt only establishes readiness after a merge-queue removal once
41
+ // the one-PR session has observed and acknowledged that exact removal.
42
+ // The aggregate projection preserves an unacknowledged removal here, so
43
+ // do not let its otherwise-current receipt promote a child draft.
44
+ if (parent.queueRemoval)
45
+ return true;
46
+ // A parent that looks ready but has not completed its own one-PR receipt
47
+ // is not sufficient evidence for a child draft transition.
48
+ if (parent.readyReceipt !== true)
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+ catch {
54
+ // Never convert a child draft based on an unverifiable parent.
55
+ return true;
56
+ }
57
+ }
@@ -0,0 +1,22 @@
1
+ import type { RepoInfo } from "../../github/client.mts";
2
+ import type { PollSummaryStackAncestry } from "../../types.mts";
3
+ import type { ShepherdReport } from "../../types/report.mts";
4
+ /**
5
+ * A verified stale boundary for the PR being shepherded.
6
+ *
7
+ * The aggregate stack read is authoritative for this check: it compares the
8
+ * child's recorded base OID with the current head OID of its immediate open
9
+ * parent. GitHub can report both PRs CLEAN while this boundary is stale.
10
+ */
11
+ export interface StaleNativeStackAncestry extends PollSummaryStackAncestry {
12
+ instructions: string[];
13
+ }
14
+ /**
15
+ * Find a stale immediate parent boundary for one PR.
16
+ *
17
+ * This helper is intentionally read-only. A null result means either the PR is
18
+ * not a non-root native-stack layer, its boundary is current, or the boundary
19
+ * could not be verified. Callers must not emit a repair command from an
20
+ * unverified snapshot.
21
+ */
22
+ export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo): Promise<StaleNativeStackAncestry | null>;
@@ -0,0 +1,37 @@
1
+ import { fetchPollSummary } from "../../github/poll-summary.mjs";
2
+ /**
3
+ * Find a stale immediate parent boundary for one PR.
4
+ *
5
+ * This helper is intentionally read-only. A null result means either the PR is
6
+ * not a non-root native-stack layer, its boundary is current, or the boundary
7
+ * could not be verified. Callers must not emit a repair command from an
8
+ * unverified snapshot.
9
+ */
10
+ export async function findStaleNativeStackAncestry(report, repo) {
11
+ const stack = report.mergeStatus.mergeRequirements?.stack;
12
+ if (!stack || stack.position <= 1)
13
+ return null;
14
+ try {
15
+ const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
16
+ const ancestry = summary.stackAncestry?.find((gap) => gap.childPr === report.pr);
17
+ if (!ancestry)
18
+ return null;
19
+ return {
20
+ ...ancestry,
21
+ instructions: buildStaleNativeStackAncestryInstructions(repo, ancestry),
22
+ };
23
+ }
24
+ catch {
25
+ // A stale repair is safe only when both OIDs were observed together. Let
26
+ // the caller retain the ordinary WAIT/ESCALATE path on an unreadable stack.
27
+ return null;
28
+ }
29
+ }
30
+ /** Build the one-PR repair guidance after a stale boundary was verified. */
31
+ function buildStaleNativeStackAncestryInstructions(repo, ancestry) {
32
+ return [
33
+ `PR #${ancestry.childPr} records base \`${ancestry.childBaseRefName}\` at \`${ancestry.childBaseRefOid}\`, but its open parent PR #${ancestry.parentPr} currently ends at \`${ancestry.parentHeadRefName}\` \`${ancestry.parentHeadRefOid}\`.`,
34
+ `From a clean checkout of \`${repo.owner}/${repo.name}\`, check out the parent stack branch \`${ancestry.parentHeadRefName}\`.`,
35
+ "Run `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten stack with `gh stack push`.",
36
+ ];
37
+ }