pr-shepherd 0.55.1 → 0.56.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 (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -9
  3. package/bin/api.d.mts +5 -0
  4. package/bin/api.mjs +12 -1
  5. package/bin/checks/job-log.d.mts +9 -0
  6. package/bin/checks/job-log.mjs +30 -0
  7. package/bin/checks/jobs-types.d.mts +14 -0
  8. package/bin/checks/jobs-types.mjs +7 -0
  9. package/bin/checks/related-jobs.d.mts +11 -0
  10. package/bin/checks/related-jobs.mjs +32 -0
  11. package/bin/checks/triage-budget.d.mts +17 -0
  12. package/bin/checks/triage-budget.mjs +49 -0
  13. package/bin/checks/triage.d.mts +6 -3
  14. package/bin/checks/triage.mjs +89 -61
  15. package/bin/cli/api-usage-formatter.mjs +2 -2
  16. package/bin/cli/fix-formatter.mjs +2 -0
  17. package/bin/cli/help-command-pages.d.mts +1 -1
  18. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  19. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  20. package/bin/cli/help.d.mts +1 -1
  21. package/bin/cli/iterate-checks-formatter.mjs +2 -0
  22. package/bin/cli/iterate-instructions.mjs +1 -1
  23. package/bin/cli/related-jobs-format.d.mts +3 -0
  24. package/bin/cli/related-jobs-format.mjs +16 -0
  25. package/bin/commands/check-execution-context.d.mts +12 -0
  26. package/bin/commands/check-execution-context.mjs +38 -0
  27. package/bin/commands/check-fingerprint.mjs +14 -8
  28. package/bin/commands/check-unreported.d.mts +3 -2
  29. package/bin/commands/check-unreported.mjs +4 -4
  30. package/bin/commands/check.d.mts +2 -1
  31. package/bin/commands/check.mjs +21 -6
  32. package/bin/commands/commit-suggestion-instruction.mjs +2 -1
  33. package/bin/commands/iterate/check-instructions.d.mts +9 -5
  34. package/bin/commands/iterate/check-instructions.mjs +15 -35
  35. package/bin/commands/iterate/escalate.mjs +3 -0
  36. package/bin/commands/iterate/fix-code.mjs +6 -2
  37. package/bin/commands/iterate/helpers.mjs +1 -0
  38. package/bin/commands/iterate/index.mjs +20 -8
  39. package/bin/commands/iterate/native-stack-rebase.mjs +3 -2
  40. package/bin/commands/iterate/render.mjs +13 -5
  41. package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
  42. package/bin/commands/iterate/stale-ancestry.mjs +3 -2
  43. package/bin/commands/iterate/unreported-required.mjs +1 -1
  44. package/bin/commands/playbook-pointer.d.mts +2 -0
  45. package/bin/commands/playbook-pointer.mjs +4 -0
  46. package/bin/commands/poll-quota.d.mts +2 -0
  47. package/bin/commands/poll-quota.mjs +18 -25
  48. package/bin/commands/poll-rate-limit-wait.mjs +13 -8
  49. package/bin/commands/poll-summary-instructions.mjs +1 -1
  50. package/bin/commands/ready-delay.d.mts +2 -0
  51. package/bin/commands/ready-delay.mjs +18 -0
  52. package/bin/commands/resolve-mutate.mjs +8 -9
  53. package/bin/commands/shepherd-journal.d.mts +1 -1
  54. package/bin/commands/shepherd-journal.mjs +3 -2
  55. package/bin/commands/stack-drain.mjs +2 -1
  56. package/bin/github/batch-raw-types.d.mts +2 -0
  57. package/bin/github/batch-receipt-evidence.d.mts +5 -0
  58. package/bin/github/batch-receipt-evidence.mjs +61 -0
  59. package/bin/github/batch.d.mts +4 -0
  60. package/bin/github/batch.mjs +42 -8
  61. package/bin/github/errors.d.mts +3 -0
  62. package/bin/github/errors.mjs +15 -6
  63. package/bin/github/gql/batch-pr-page.gql +1 -0
  64. package/bin/github/gql/batch-pr.gql +1 -0
  65. package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
  66. package/bin/github/gql/reply-thread-comments.gql +25 -0
  67. package/bin/github/gql/reply-thread-transcripts.gql +31 -0
  68. package/bin/github/merge-queue-checks.d.mts +2 -1
  69. package/bin/github/merge-queue-checks.mjs +18 -8
  70. package/bin/github/merge-target-rules.d.mts +2 -1
  71. package/bin/github/merge-target-rules.mjs +4 -4
  72. package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
  73. package/bin/github/poll-summary-annotation-probe.mjs +7 -0
  74. package/bin/github/queries.d.mts +5 -0
  75. package/bin/github/queries.mjs +5 -0
  76. package/bin/github/rate-limit-kind.d.mts +13 -0
  77. package/bin/github/rate-limit-kind.mjs +25 -0
  78. package/bin/github/reply-thread-transcripts.d.mts +3 -0
  79. package/bin/github/reply-thread-transcripts.mjs +89 -0
  80. package/bin/github/rest-http.mjs +1 -0
  81. package/bin/github/rest-text.d.mts +2 -1
  82. package/bin/github/rest-text.mjs +5 -2
  83. package/bin/github/thread-comments.d.mts +5 -1
  84. package/bin/github/thread-comments.mjs +30 -6
  85. package/bin/mcp/server.mjs +32 -5
  86. package/bin/quota-warning.mjs +2 -2
  87. package/bin/reporters/agent.mjs +1 -0
  88. package/bin/threads/transcript.d.mts +1 -0
  89. package/bin/threads/transcript.mjs +4 -1
  90. package/bin/types/check-classification.d.mts +10 -0
  91. package/bin/types/report.d.mts +4 -1
  92. package/package.json +2 -2
  93. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  94. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  95. package/plugins/pr-shepherd/.mcp.json +1 -1
  96. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +57 -70
  97. package/plugins/pr-shepherd/skills/pr-shepherd/references/branch-update.md +9 -0
  98. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +21 -0
  99. package/plugins/pr-shepherd/skills/pr-shepherd/references/journal.md +7 -0
  100. package/plugins/pr-shepherd/skills/pr-shepherd/references/review-mutations.md +9 -0
  101. package/plugins/pr-shepherd/skills/pr-shepherd/references/stack-merge.md +7 -0
  102. package/plugins/pr-shepherd/skills/pr-shepherd/references/suggestion-patches.md +10 -0
@@ -38,8 +38,15 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
38
38
  const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
39
39
  instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
40
40
  }
41
- if (hasConflicts && !hasRepeatedWorkflowBranchRecovery) {
42
- instructions.push(buildConflictInstruction(stackRebase));
41
+ // A conflicting native stack layer follows the printed gh-stack route, so it omits the hint.
42
+ const branchUpdateHint = buildBehindBaseHintInstruction(baseBranch, behindBaseHint, {
43
+ isBehind,
44
+ hasConflicts: hasConflicts && !stackRebase,
45
+ });
46
+ // The conflict hint belongs with the conflict step; otherwise it precedes the push step.
47
+ const hintWithConflictStep = hasConflicts && !hasRepeatedWorkflowBranchRecovery;
48
+ if (hintWithConflictStep) {
49
+ instructions.push(buildConflictInstruction(stackRebase), ...branchUpdateHint);
43
50
  }
44
51
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
45
52
  if (firstLookTotal > 0) {
@@ -77,14 +84,15 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
77
84
  const staleClause = buildCrStaleClause(changesRequestedReviews);
78
85
  instructions.push(`Read every body under \`## Changes-requested reviews\` and apply any warranted change.${staleClause}`);
79
86
  }
80
- instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
87
+ if (!hintWithConflictStep)
88
+ instructions.push(...branchUpdateHint);
81
89
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
82
90
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
83
91
  if (hasConflicts || hasRepeatedWorkflowBranchRecovery) {
84
92
  instructions.push(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix));
85
93
  }
86
94
  else if (hasNonConflictHints) {
87
- instructions.push("If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate immediately with the same options. If you did not change code, do not commit and continue with the remaining steps.");
95
+ instructions.push("If you changed code, commit any remaining changes and push to the PR head branch. If you did not, do not commit.");
88
96
  }
89
97
  if (viewerCanUpdate &&
90
98
  (hasReviewMutations ||
@@ -96,6 +104,6 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
96
104
  }
97
105
  if (resolveOnlyCommand?.hasMutations)
98
106
  instructions.push("Run the `resolve-only:` command shown above.");
99
- instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha, stackRebase !== undefined));
107
+ instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction());
100
108
  return instructions;
101
109
  }
@@ -1,6 +1,7 @@
1
1
  import type { RepoInfo } from "../../github/client.mts";
2
2
  import type { PollSummaryStackAncestry } from "../../types.mts";
3
3
  import type { ShepherdReport } from "../../types/report.mts";
4
+ import type { CheckExecutionContext } from "../check-execution-context.mts";
4
5
  /**
5
6
  * A verified stale boundary for the PR being shepherded.
6
7
  *
@@ -19,4 +20,4 @@ export interface StaleNativeStackAncestry extends PollSummaryStackAncestry {
19
20
  * could not be verified. Callers must not emit a repair command from an
20
21
  * unverified snapshot.
21
22
  */
22
- export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo): Promise<StaleNativeStackAncestry | null>;
23
+ export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo, context?: CheckExecutionContext): Promise<StaleNativeStackAncestry | null>;
@@ -8,12 +8,13 @@ import { buildNativeStackRebaseInstruction } from "./native-stack-rebase.mjs";
8
8
  * could not be verified. Callers must not emit a repair command from an
9
9
  * unverified snapshot.
10
10
  */
11
- export async function findStaleNativeStackAncestry(report, repo) {
11
+ export async function findStaleNativeStackAncestry(report, repo, context) {
12
12
  const stack = report.mergeStatus.mergeRequirements?.stack;
13
13
  if (!stack || stack.position <= 1)
14
14
  return null;
15
15
  try {
16
- const topology = await readStackTopology(report.pr, repo);
16
+ const topology = await (context?.readStackTopology(report.pr, repo) ??
17
+ readStackTopology(report.pr, repo));
17
18
  const ancestry = stackAncestryGaps(topology.ordered).find((gap) => gap.childPr === report.pr);
18
19
  if (!ancestry)
19
20
  return null;
@@ -98,7 +98,7 @@ export function buildUnreportedFixResult(base, report, instructions) {
98
98
  requiresDismissMessage: false,
99
99
  hasMutations: false,
100
100
  },
101
- instructions: [...instructions, buildFixCompletionInstruction([])],
101
+ instructions: [...instructions, buildFixCompletionInstruction()],
102
102
  inProgressRunIds: [],
103
103
  protectedRuns: [],
104
104
  firstLookThreads: [],
@@ -0,0 +1,2 @@
1
+ /** Name an on-demand pr-shepherd skill reference. The skill maps the name to a file. */
2
+ export declare function playbookPointer(name: string): string;
@@ -0,0 +1,4 @@
1
+ /** Name an on-demand pr-shepherd skill reference. The skill maps the name to a file. */
2
+ export function playbookPointer(name) {
3
+ return `Playbook: "${name}".`;
4
+ }
@@ -1,4 +1,5 @@
1
1
  import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import { type RateLimitKind } from "../github/rate-limit-kind.mts";
2
3
  import type { ApiResourceUsage, GraphqlApiUsage, PollSummaryResult } from "../types.mts";
3
4
  /** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
4
5
  export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: {
@@ -8,6 +9,7 @@ export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], us
8
9
  export interface RateLimitRetry {
9
10
  ms: number;
10
11
  resource: string;
12
+ kind: RateLimitKind;
11
13
  remaining?: number;
12
14
  limit?: number;
13
15
  resetAt?: number;
@@ -1,6 +1,6 @@
1
1
  import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
2
2
  import { GitHubRequestError } from "../github/errors.mjs";
3
- import { isRateLimitMessage } from "../comments/rate-limit.mjs";
3
+ import { rateLimitKind } from "../github/rate-limit-kind.mjs";
4
4
  import { exhaustedPrimaryLimitDelayMs } from "./poll-rate-limit-delay.mjs";
5
5
  import { selectQuotaWarning } from "./quota-selection.mjs";
6
6
  const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
@@ -28,36 +28,29 @@ export function quotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
28
28
  export function pollRateLimitRetryAfterMs(err) {
29
29
  if (!(err instanceof GitHubRequestError))
30
30
  return null;
31
- const rateLimitMessage = isRateLimitMessage(err.message) ||
32
- (err.graphqlErrors?.some((error) => isRateLimitMessage(error.message)) ?? false);
33
- const exhausted = err.rateLimit !== undefined && err.rateLimit.remaining <= 0;
34
- const retryable = err.status === 429 || err.retryAfterSeconds !== undefined || rateLimitMessage || exhausted;
35
- if (!retryable)
31
+ const kind = rateLimitKind(err);
32
+ if (kind === null)
36
33
  return null;
37
- const resource = retryResource(err);
34
+ const resource = kind === "secondary" ? "secondary" : (err.rateLimit?.resource ?? "graphql");
38
35
  const rateLimit = err.rateLimit;
39
36
  const details = {
37
+ kind,
40
38
  resource,
41
- ...(rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
42
- ...(rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
43
- ...(rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
39
+ ...(kind === "primary" &&
40
+ rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
41
+ ...(kind === "primary" && rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
42
+ ...(kind === "primary" && rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
44
43
  };
45
- if (err.retryAfterSeconds !== undefined) {
46
- return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
44
+ if (kind === "primary" && rateLimit !== undefined) {
45
+ return {
46
+ ...details,
47
+ ms: Math.max(exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()), Math.max(err.retryAfterSeconds ?? 0, 0) * 1000),
48
+ };
47
49
  }
48
- if (exhausted && rateLimit !== undefined) {
49
- return { ...details, ms: exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()) };
50
- }
51
- return { ...details, ms: GRAPHQL_RETRY_AFTER_DEFAULT_MS };
52
- }
53
- function retryResource(err) {
54
- if (err.rateLimit?.resource)
55
- return err.rateLimit.resource;
56
- const secondary = err.status === 429 ||
57
- err.retryAfterSeconds !== undefined ||
58
- /secondary/i.test(err.message) ||
59
- (err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
60
- return secondary ? "secondary" : "graphql";
50
+ return {
51
+ ...details,
52
+ ms: Math.max(GRAPHQL_RETRY_AFTER_DEFAULT_MS, Math.max(err.retryAfterSeconds ?? 0, 0) * 1000),
53
+ };
61
54
  }
62
55
  function rateLimitResourceLabel(resource) {
63
56
  if (resource === "graphql")
@@ -2,7 +2,8 @@ import { rest } from "../github/rest-http.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
3
  import { formatRateLimitGiveUpLine, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, } from "./poll-quota.mjs";
4
4
  const MAX_NO_PROGRESS_ATTEMPTS = 5;
5
- const NO_PROGRESS_BACKOFF_MS = [15_000, 30_000, 60_000];
5
+ const PRIMARY_NO_PROGRESS_BACKOFF_MS = [15_000, 30_000, 60_000];
6
+ const SECONDARY_NO_PROGRESS_BACKOFF_MS = [60_000, 120_000, 240_000, 480_000, 960_000];
6
7
  const MERGE_STATES = new Set([
7
8
  "BEHIND",
8
9
  "BLOCKED",
@@ -26,8 +27,10 @@ export function createUntilTerminalRateLimitRetry() {
26
27
  },
27
28
  };
28
29
  }
29
- function backoffMs(attempt) {
30
- return NO_PROGRESS_BACKOFF_MS[Math.min(attempt, NO_PROGRESS_BACKOFF_MS.length) - 1] ?? 60_000;
30
+ function backoffMs(attempt, kind) {
31
+ const backoff = kind === "secondary" ? SECONDARY_NO_PROGRESS_BACKOFF_MS : PRIMARY_NO_PROGRESS_BACKOFF_MS;
32
+ const index = kind === "secondary" ? attempt : attempt - 1;
33
+ return backoff[Math.min(index, backoff.length - 1)] ?? backoff.at(-1);
31
34
  }
32
35
  async function waitForRateLimit(state, err, opts) {
33
36
  const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
@@ -35,14 +38,16 @@ async function waitForRateLimit(state, err, opts) {
35
38
  throw err;
36
39
  const elapsed = Math.round((Date.now() - opts.startedAt) / 1000);
37
40
  const progressed = state.armed &&
41
+ retry.kind === "primary" &&
38
42
  retry.resetAt !== undefined &&
39
43
  (state.lastResetAt === undefined || retry.resetAt > state.lastResetAt);
40
44
  let sleepMs = retry.ms;
41
45
  if (!state.armed || progressed) {
42
46
  state.armed = true;
43
47
  state.noProgress = 0;
44
- if (retry.resetAt !== undefined)
48
+ if (retry.kind === "primary" && retry.resetAt !== undefined) {
45
49
  state.lastResetAt = retry.resetAt;
50
+ }
46
51
  }
47
52
  else {
48
53
  state.noProgress += 1;
@@ -52,13 +57,13 @@ async function waitForRateLimit(state, err, opts) {
52
57
  }
53
58
  // Never sleep less than GitHub asked for. A short no-progress backoff must
54
59
  // not undercut an explicit Retry-After or the secondary-limit default.
55
- sleepMs = Math.max(retry.ms, backoffMs(state.noProgress));
60
+ sleepMs = Math.max(retry.ms, backoffMs(state.noProgress, retry.kind));
56
61
  }
57
62
  process.stderr.write(formatRateLimitRetryLine(opts.tickLabel, elapsed, { ...retry, ms: sleepMs }));
58
- return sleepRateLimit(sleepMs, opts, retry.resource);
63
+ return sleepRateLimit(sleepMs, opts, retry.kind, retry.resource);
59
64
  }
60
- async function sleepRateLimit(ms, opts, resource) {
61
- const canProbe = resource !== "core" && opts.targets.length > 0;
65
+ async function sleepRateLimit(ms, opts, kind, resource) {
66
+ const canProbe = kind === "primary" && resource === "graphql" && opts.targets.length > 0;
62
67
  if (ms <= opts.intervalMs || opts.intervalMs <= 0) {
63
68
  await sleep(ms);
64
69
  return undefined;
@@ -137,7 +137,7 @@ function planStack(result, mergeRequested) {
137
137
  stackMergeable: true,
138
138
  waiting: true,
139
139
  instructions: [
140
- "1. The stack is in the merge queue. Recheck at the configured polling cadence; finish only after every layer is merged, and route any ejected layer to its one-PR session.",
140
+ "1. The queued layers are waiting on the merge queue. Recheck them at the configured polling cadence. Do not rewrite a queued layer. This wait does not block work on a layer that is not in the queue. Route any ejected layer to its one-PR session.",
141
141
  ],
142
142
  };
143
143
  }
@@ -33,4 +33,6 @@ export declare function updateReadyDelay(prNumber: number, isReady: boolean, rea
33
33
  }): Promise<ReadyDelayState>;
34
34
  /** Delete the ready-delay marker once an elapsed delay has been consumed. */
35
35
  export declare function clearReadyDelay(prNumber: number, owner: string, repo: string): Promise<void>;
36
+ /** Read-only hint for including compact receipt evidence in the next full PR snapshot. */
37
+ export declare function readyDelayElapsed(prNumber: number, owner: string, repo: string, readyDelaySeconds: number): Promise<boolean>;
36
38
  export {};
@@ -54,6 +54,24 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
54
54
  export async function clearReadyDelay(prNumber, owner, repo) {
55
55
  await safeUnlink(readySincePath(prNumber, owner, repo));
56
56
  }
57
+ /** Read-only hint for including compact receipt evidence in the next full PR snapshot. */
58
+ export async function readyDelayElapsed(prNumber, owner, repo, readyDelaySeconds) {
59
+ const markerPath = readySincePath(prNumber, owner, repo);
60
+ let raw;
61
+ try {
62
+ raw = await readFile(markerPath, "utf8");
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ const [since, head] = raw.trim().split(" ");
68
+ const readySince = Number(since);
69
+ const now = Math.floor(Date.now() / 1000);
70
+ return Boolean(head &&
71
+ Number.isSafeInteger(readySince) &&
72
+ readySince <= now &&
73
+ now - readySince >= readyDelaySeconds);
74
+ }
57
75
  // ---------------------------------------------------------------------------
58
76
  // Helpers
59
77
  // ---------------------------------------------------------------------------
@@ -1,8 +1,8 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
- import { fetchPrBatch } from "../github/batch.mjs";
3
+ import { fetchReplyThreadTranscripts } from "../github/reply-thread-transcripts.mjs";
4
4
  import { markReplySeen } from "../state/seen-comments.mjs";
5
- import { threadTranscriptBody } from "../threads/transcript.mjs";
5
+ import { threadTranscriptBodies } from "../threads/transcript.mjs";
6
6
  import { addPrShepherdMarker } from "../comments/marker.mjs";
7
7
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
8
8
  /** @deprecated Hidden implementation for `resolve`; use `apply review`. */
@@ -14,10 +14,10 @@ export async function runResolveMutate(opts) {
14
14
  }
15
15
  // Fetch only to retain the pre-reply transcript for successful-reply seen
16
16
  // markers. It never determines which user-supplied IDs are sent to GitHub.
17
- let threadById;
17
+ let transcriptById;
18
18
  if (opts.replyThreadIds?.length) {
19
19
  try {
20
- threadById = new Map((await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true })).data.reviewThreads.map((thread) => [thread.id, thread]));
20
+ transcriptById = await fetchReplyThreadTranscripts(prNumber, repo, opts.replyThreadIds);
21
21
  }
22
22
  catch {
23
23
  // Seen-marker bookkeeping is best-effort. A failed read must not block
@@ -35,14 +35,13 @@ export async function runResolveMutate(opts) {
35
35
  dismissMessage: opts.dismissMessage,
36
36
  requireSha: opts.requireSha,
37
37
  });
38
- if (opts.dismissMessage && threadById) {
38
+ if (opts.dismissMessage && transcriptById) {
39
39
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
40
40
  await Promise.all(result.repliedThreads.map((id) => {
41
- const thread = threadById.get(id);
42
- if (!thread)
41
+ const previousBody = transcriptById.get(id);
42
+ if (previousBody === undefined)
43
43
  return Promise.resolve();
44
- const previousBody = threadTranscriptBody(thread);
45
- return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBody(thread, [markedMessage]), markedMessage);
44
+ return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBodies([previousBody, markedMessage]), markedMessage);
46
45
  }));
47
46
  }
48
47
  return result;
@@ -4,7 +4,7 @@ export declare const SHEPHERD_JOURNAL_DETAILS_OPEN = "<details>";
4
4
  export declare const SHEPHERD_JOURNAL_DETAILS_SUMMARY = "<summary>Shepherd Journal</summary>";
5
5
  export declare const SHEPHERD_JOURNAL_DETAILS_CLOSE = "</details>";
6
6
  export declare const SHEPHERD_JOURNAL_APPEND_HINT = "If Shepherd Journal details already exist, append entries inside them instead of creating another container.";
7
- export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
7
+ export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE: string;
8
8
  /**
9
9
  * Build the Shepherd Journal instruction step. The reference-citation convention (link
10
10
  * threads/comments from their headings, cite reviews by ID) is invariant across every
@@ -1,11 +1,12 @@
1
1
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
+ import { playbookPointer } from "./playbook-pointer.mjs";
2
3
  export const SHEPHERD_JOURNAL_SECTION = "Shepherd Journal";
3
4
  export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
4
5
  export const SHEPHERD_JOURNAL_DETAILS_OPEN = "<details>";
5
6
  export const SHEPHERD_JOURNAL_DETAILS_SUMMARY = "<summary>Shepherd Journal</summary>";
6
7
  export const SHEPHERD_JOURNAL_DETAILS_CLOSE = "</details>";
7
8
  export const SHEPHERD_JOURNAL_APPEND_HINT = "If Shepherd Journal details already exist, append entries inside them instead of creating another container.";
8
- export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
9
+ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = `Read each body under \`## Review summaries (first look)\` and journal any warranted note before review mutations. ${playbookPointer("Shepherd Journal")}`;
9
10
  /**
10
11
  * Build the Shepherd Journal instruction step. The reference-citation convention (link
11
12
  * threads/comments from their headings, cite reviews by ID) is invariant across every
@@ -15,5 +16,5 @@ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `##
15
16
  export function buildShepherdJournalInstruction(prReference) {
16
17
  // Single-quote the placeholder so a substituted decision stays literal in the shell.
17
18
  const command = `${buildPrShepherdCommand(["apply", "journal", String(prReference)]).text} '- <decision>'`;
18
- return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`${command}\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
19
+ return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`${command}\`. ${playbookPointer("Shepherd Journal")}`;
19
20
  }
@@ -1,3 +1,4 @@
1
+ import { playbookPointer } from "./playbook-pointer.mjs";
1
2
  import { stackMergeFlag } from "./stack-merge-flag.mjs";
2
3
  import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
3
4
  import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
@@ -48,7 +49,7 @@ export function planPrefixDrain(result, mergeRequested) {
48
49
  };
49
50
  }
50
51
  const instructions = [
51
- `1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes ${method.flag}\` to merge ${span}. When the base uses a merge queue, the same command queues that prefix together and GitHub evaluates each layer from the bottom; a failure ejects that layer and the layers above it. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\` first.`,
52
+ `1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes ${method.flag}\` to merge ${span}. ${playbookPointer("Stack merge")}`,
52
53
  ];
53
54
  appendAutonomousInstructions(instructions, above.sessions);
54
55
  appendMarkReadyInstructions(instructions, above.markReady);
@@ -11,6 +11,7 @@ export interface RawBatchResponse {
11
11
  squashMergeAllowed?: boolean;
12
12
  rebaseMergeAllowed?: boolean;
13
13
  pullRequest: RawPr | null;
14
+ receiptSummary?: import("./poll-summary-raw.mts").RawSummaryPr | null;
14
15
  } | null;
15
16
  }
16
17
  export interface RawPr extends RawPrMergeFields {
@@ -226,6 +227,7 @@ export type RawContextNode = {
226
227
  title: string | null;
227
228
  summary: string | null;
228
229
  annotations?: {
230
+ totalCount?: number;
229
231
  nodes: Array<{
230
232
  message: string;
231
233
  }>;
@@ -0,0 +1,5 @@
1
+ import type { RepoInfo } from "./client.mts";
2
+ import type { RawContextNode, RawPr } from "./batch-raw-types.mts";
3
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
4
+ /** Keep the exact PollSummaryPr shape used by v1 receipts; add only complete annotation totals. */
5
+ export declare function prepareBatchReceiptEvidence(summary: RawSummaryPr | null | undefined, batch: RawPr, checks: RawContextNode[], repo: RepoInfo): Promise<RawSummaryPr | null>;
@@ -0,0 +1,61 @@
1
+ import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
2
+ import { markReadyAnnotationProbeComplete } from "./poll-summary-annotation-probe.mjs";
3
+ /** Keep the exact PollSummaryPr shape used by v1 receipts; add only complete annotation totals. */
4
+ export async function prepareBatchReceiptEvidence(summary, batch, checks, repo) {
5
+ if (!summary ||
6
+ summary.number !== batch.number ||
7
+ summary.headRefOid !== batch.headRefOid ||
8
+ summary.baseRefOid !== batch.baseRefOid)
9
+ return null;
10
+ // Candidate evidence is best-effort. Wider windows need their own summary
11
+ // pagination, so use the existing standalone receipt path instead of issuing
12
+ // follow-ups from inside a full BatchPr read (which may have exhausted quota).
13
+ const headContexts = summary.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
14
+ const queueContexts = summary.mergeQueueEntry?.headCommit?.statusCheckRollup?.contexts;
15
+ if (headContexts?.pageInfo.hasPreviousPage || queueContexts?.pageInfo.hasPreviousPage)
16
+ return null;
17
+ try {
18
+ await hydratePollSummaryChecks(summary, repo);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ if (summary.mergeQueueEntry?.headCommit?.statusCheckRollup?.contexts.pageInfo.hasPreviousPage)
24
+ return null;
25
+ const summaryCommit = summary.commits.nodes[0]?.commit;
26
+ const batchCommit = batch.commits.nodes[0]?.commit;
27
+ if (!summaryCommit || summaryCommit.oid !== batchCommit?.oid)
28
+ return null;
29
+ const contexts = summaryCommit.statusCheckRollup?.contexts;
30
+ if (!contexts) {
31
+ if (batchCommit.statusCheckRollup !== null || checks.length > 0)
32
+ return null;
33
+ markReadyAnnotationProbeComplete(summary);
34
+ return summary;
35
+ }
36
+ if (contexts.pageInfo.hasPreviousPage || contexts.nodes.length !== contexts.totalCount)
37
+ return null;
38
+ if (checks.length !== contexts.totalCount)
39
+ return null;
40
+ const batchRuns = new Map();
41
+ for (const check of checks) {
42
+ if (check.__typename !== "CheckRun")
43
+ continue;
44
+ const count = check.annotations?.totalCount;
45
+ if (!Number.isSafeInteger(count) || count < 0 || batchRuns.has(check.id))
46
+ return null;
47
+ batchRuns.set(check.id, count);
48
+ }
49
+ const summaryRuns = contexts.nodes.filter((node) => node.__typename === "CheckRun");
50
+ if (summaryRuns.length !== batchRuns.size)
51
+ return null;
52
+ const seenSummaryIds = new Set();
53
+ for (const node of summaryRuns) {
54
+ if (!node.id || seenSummaryIds.has(node.id) || !batchRuns.has(node.id))
55
+ return null;
56
+ seenSummaryIds.add(node.id);
57
+ node.annotations = { totalCount: batchRuns.get(node.id) };
58
+ }
59
+ markReadyAnnotationProbeComplete(summary);
60
+ return summary;
61
+ }
@@ -1,5 +1,6 @@
1
1
  import { type RateLimitInfo, type RepoInfo } from "./client.mts";
2
2
  import type { WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
3
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
4
  import type { BatchPrData } from "../types.mts";
4
5
  import { type PrFingerprint } from "./fingerprint.mts";
5
6
  interface BatchResult {
@@ -12,6 +13,8 @@ interface BatchResult {
12
13
  headCheckSuitesEmpty?: true;
13
14
  /** Actions workflow suites on the head, excluding apps that have no workflow run. */
14
15
  headWorkflowSuites?: WorkflowSuiteSnapshot[];
16
+ /** Internal READY-receipt evidence from the same request, if complete. */
17
+ receiptSummary?: RawSummaryPr;
15
18
  }
16
19
  interface FetchPrBatchOptions {
17
20
  /**
@@ -23,6 +26,7 @@ interface FetchPrBatchOptions {
23
26
  * request — so there's no need to conditionally omit the field itself.
24
27
  */
25
28
  paginateApprovedReviews?: boolean;
29
+ includeReceiptSummary?: boolean;
26
30
  }
27
31
  /**
28
32
  * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
@@ -1,6 +1,9 @@
1
1
  import { graphqlWithRateLimit } from "./client.mjs";
2
2
  import { hydrateThreadCommentPages } from "./thread-comments.mjs";
3
- import { BATCH_PR_QUERY } from "./queries.mjs";
3
+ import { BATCH_PR_QUERY, BATCH_PR_RECEIPT_QUERY } from "./queries.mjs";
4
+ import { prepareBatchReceiptEvidence } from "./batch-receipt-evidence.mjs";
5
+ import { GitHubRequestError, isRetryableGraphQlResourceLimit, } from "./errors.mjs";
6
+ import { rateLimitKind } from "./rate-limit-kind.mjs";
4
7
  import { parseRawPr } from "./batch-parsers.mjs";
5
8
  import { parseCheckSuitesComplete, parseHeadCheckSuitesEmpty, parseHeadWorkflowSuites, parseSuiteStartupFailures, } from "./batch-parse-suites.mjs";
6
9
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
@@ -8,20 +11,50 @@ import { paginateBatchConnections } from "./batch-page.mjs";
8
11
  import { requireRawPr } from "./batch-response.mjs";
9
12
  import { hydrateMergeQueueChecks } from "./merge-queue-checks.mjs";
10
13
  import { fingerprintFromRaw } from "./fingerprint.mjs";
14
+ function onlyReceiptSummaryErrors(errors) {
15
+ return (!!errors?.length &&
16
+ errors.every((error) => Array.isArray(error.path) &&
17
+ error.path[0] === "repository" &&
18
+ error.path[1] === "receiptSummary"));
19
+ }
11
20
  /**
12
21
  * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
13
22
  */
14
23
  export async function fetchPrBatch(pr, repo, opts = {}) {
15
- const result = await graphqlWithRateLimit(BATCH_PR_QUERY, {
24
+ const variables = {
16
25
  owner: repo.owner,
17
26
  repo: repo.name,
18
27
  pr,
19
- });
28
+ };
29
+ let result;
30
+ try {
31
+ result = await graphqlWithRateLimit(opts.includeReceiptSummary ? BATCH_PR_RECEIPT_QUERY : BATCH_PR_QUERY, variables);
32
+ }
33
+ catch (error) {
34
+ if (!opts.includeReceiptSummary ||
35
+ !(error instanceof GitHubRequestError) ||
36
+ rateLimitKind(error) !== null ||
37
+ !(isRetryableGraphQlResourceLimit(error.graphqlErrors) ||
38
+ (error.status === 200 && onlyReceiptSummaryErrors(error.graphqlErrors))))
39
+ throw error;
40
+ // Receipt evidence is optional; a resource-limited combined query or an
41
+ // error confined to its sibling must not prevent the ordinary snapshot.
42
+ result = await graphqlWithRateLimit(BATCH_PR_QUERY, variables);
43
+ }
20
44
  const raw = requireRawPr(result.data, pr, repo);
21
- await hydrateMergeQueueChecks(raw, repo);
22
- const paged = await paginateBatchConnections(pr, repo, raw, opts, result.rateLimit);
23
- const rawThreadPages = await hydrateThreadCommentPages(paged.threads);
24
- const data = parseRawPr(raw, rawThreadPages, paged.comments, paged.changesRequested, paged.reviewSummaries, paged.approvedReviews, paged.checks, result.data.repository);
45
+ const queueRateLimit = await hydrateMergeQueueChecks(raw, repo, result.rateLimit);
46
+ const paged = await paginateBatchConnections(pr, repo, raw, opts, queueRateLimit);
47
+ const threadPages = await hydrateThreadCommentPages(paged.threads, paged.rateLimit);
48
+ let receiptSummary = null;
49
+ if (opts.includeReceiptSummary) {
50
+ try {
51
+ receiptSummary = await prepareBatchReceiptEvidence(result.data.repository?.receiptSummary, raw, paged.checks, repo);
52
+ }
53
+ catch {
54
+ // Malformed supplemental evidence must not discard a complete BatchPr.
55
+ }
56
+ }
57
+ const data = parseRawPr(raw, threadPages.threads, paged.comments, paged.changesRequested, paged.reviewSummaries, paged.approvedReviews, paged.checks, result.data.repository);
25
58
  const viewerLogin = result.data.viewer?.login;
26
59
  if (viewerLogin)
27
60
  data.viewerLogin = viewerLogin;
@@ -29,10 +62,11 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
29
62
  return {
30
63
  data,
31
64
  fingerprint: fingerprintFromRaw(raw, result.data.repository?.viewerPermission ?? null, result.data.viewer?.login ?? null),
32
- rateLimit: paged.rateLimit ?? result.rateLimit,
65
+ rateLimit: threadPages.rateLimit ?? paged.rateLimit ?? result.rateLimit,
33
66
  ...(parseCheckSuitesComplete(raw) && { checkSuitesComplete: true }),
34
67
  ...(parseHeadCheckSuitesEmpty(raw) && { headCheckSuitesEmpty: true }),
35
68
  ...workflowSuites(raw),
69
+ ...(receiptSummary && { receiptSummary }),
36
70
  };
37
71
  }
38
72
  function workflowSuites(raw) {
@@ -27,12 +27,15 @@ export declare class GitHubRequestError extends ShepherdError {
27
27
  readonly retryAfterSeconds?: number;
28
28
  readonly graphqlErrors?: GitHubGraphQlError[];
29
29
  readonly authSource?: string;
30
+ /** Response text without a request path; safe input for throttle classification. */
31
+ readonly responseMessage?: string;
30
32
  constructor(message: string, opts: {
31
33
  status: number;
32
34
  rateLimit?: RateLimitInfo;
33
35
  retryAfterSeconds?: number;
34
36
  graphqlErrors?: GitHubGraphQlError[];
35
37
  authSource?: string;
38
+ responseMessage?: string;
36
39
  /**
37
40
  * Bypasses status-based classification entirely — for callers that already
38
41
  * know the failure kind better than the HTTP status can express (e.g. a
@@ -1,4 +1,5 @@
1
1
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
+ import { rateLimitKind } from "./rate-limit-kind.mjs";
2
3
  // GitHub's GraphQL API reports field-level permission failures (e.g. a fine-grained
3
4
  // PAT missing a scope) as an `errors[].message` entry at HTTP 200, not as an HTTP
4
5
  // 401/403 — the transport-level request succeeded even though one field could not
@@ -47,16 +48,21 @@ export function isRetryableGraphQlResourceLimit(graphqlErrors) {
47
48
  return GRAPHQL_RESOURCE_LIMIT_MESSAGE.test(error.message);
48
49
  });
49
50
  }
50
- function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
51
+ function classifyStatus(status, message, responseMessage, rateLimit, retryAfterSeconds, graphqlErrors) {
51
52
  // Retry signals take priority over everything else: GitHub's secondary rate limit
52
53
  // returns 403 with a Retry-After header, which is a transient throttle — not the
53
54
  // permission-denied 403 a bad/missing token produces. Treat any retry signal as
54
55
  // TEMPFAIL first so it isn't shadowed by the checks below.
55
- const rateLimitExhausted = rateLimit !== undefined && rateLimit.remaining <= 0;
56
- if (status === 429 ||
56
+ const throttle = rateLimitKind({
57
+ status,
58
+ message,
59
+ responseMessage,
60
+ rateLimit,
61
+ retryAfterSeconds,
62
+ graphqlErrors,
63
+ });
64
+ if (throttle !== null ||
57
65
  status >= 500 ||
58
- retryAfterSeconds !== undefined ||
59
- rateLimitExhausted ||
60
66
  isRetryableGraphQlInternal(graphqlErrors) ||
61
67
  isRetryableGraphQlResourceLimit(graphqlErrors)) {
62
68
  return EXIT.TEMPFAIL;
@@ -75,14 +81,17 @@ export class GitHubRequestError extends ShepherdError {
75
81
  retryAfterSeconds;
76
82
  graphqlErrors;
77
83
  authSource;
84
+ /** Response text without a request path; safe input for throttle classification. */
85
+ responseMessage;
78
86
  constructor(message, opts) {
79
87
  super(message, opts.exitCodeOverride ??
80
- classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
88
+ classifyStatus(opts.status, message, opts.responseMessage, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
81
89
  this.name = "GitHubRequestError";
82
90
  this.status = opts.status;
83
91
  this.rateLimit = opts.rateLimit;
84
92
  this.retryAfterSeconds = opts.retryAfterSeconds;
85
93
  this.graphqlErrors = opts.graphqlErrors;
86
94
  this.authSource = opts.authSource;
95
+ this.responseMessage = opts.responseMessage;
87
96
  }
88
97
  }
@@ -174,6 +174,7 @@ query BatchPrPage(
174
174
  title
175
175
  summary
176
176
  annotations(first: 1) {
177
+ totalCount
177
178
  nodes {
178
179
  message
179
180
  }