pr-shepherd 0.44.1 → 0.46.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 (119) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +19 -8
  3. package/bin/checks/conclusions.d.mts +8 -1
  4. package/bin/checks/conclusions.mjs +12 -0
  5. package/bin/checks/triage.d.mts +3 -2
  6. package/bin/checks/triage.mjs +44 -15
  7. package/bin/cli/api-usage-formatter.d.mts +3 -0
  8. package/bin/cli/api-usage-formatter.mjs +36 -0
  9. package/bin/cli/error-format.d.mts +13 -0
  10. package/bin/cli/error-format.mjs +41 -0
  11. package/bin/cli/fix-formatter.mjs +22 -5
  12. package/bin/cli/help-command-pages.d.mts +13 -0
  13. package/bin/cli/help-command-pages.mjs +13 -0
  14. package/bin/cli/help-top-page.d.mts +1 -1
  15. package/bin/cli/help-top-page.mjs +2 -0
  16. package/bin/cli/help.d.mts +14 -1
  17. package/bin/cli/help.mjs +2 -0
  18. package/bin/cli/iterate-activity-formatter.d.mts +2 -0
  19. package/bin/cli/iterate-activity-formatter.mjs +26 -0
  20. package/bin/cli/iterate-checks-formatter.d.mts +2 -0
  21. package/bin/cli/iterate-checks-formatter.mjs +55 -0
  22. package/bin/cli/iterate-formatter.mjs +17 -29
  23. package/bin/cli/iterate-instructions.mjs +14 -1
  24. package/bin/cli/iterate-lean.mjs +5 -0
  25. package/bin/cli/journal-extract-handler.d.mts +2 -0
  26. package/bin/cli/journal-extract-handler.mjs +48 -0
  27. package/bin/cli/mutate-formatter.mjs +2 -0
  28. package/bin/cli/safe-body-file.d.mts +14 -0
  29. package/bin/cli/safe-body-file.mjs +42 -0
  30. package/bin/cli-parser.mjs +7 -0
  31. package/bin/commands/check-annotations.d.mts +2 -1
  32. package/bin/commands/check-annotations.mjs +7 -7
  33. package/bin/commands/check-status.mjs +4 -4
  34. package/bin/commands/check.mjs +31 -9
  35. package/bin/commands/iterate/api-usage.d.mts +2 -0
  36. package/bin/commands/iterate/api-usage.mjs +35 -0
  37. package/bin/commands/iterate/check-instructions.d.mts +1 -1
  38. package/bin/commands/iterate/check-instructions.mjs +13 -18
  39. package/bin/commands/iterate/classify.mjs +2 -1
  40. package/bin/commands/iterate/escalate.mjs +63 -9
  41. package/bin/commands/iterate/fix-code.mjs +110 -73
  42. package/bin/commands/iterate/index.mjs +8 -5
  43. package/bin/commands/iterate/merge-state.mjs +5 -2
  44. package/bin/commands/iterate/render.mjs +13 -6
  45. package/bin/commands/iterate/run.d.mts +2 -0
  46. package/bin/commands/iterate/run.mjs +8 -0
  47. package/bin/commands/mark-files-as-viewed.mjs +8 -0
  48. package/bin/commands/poll-run.d.mts +2 -0
  49. package/bin/commands/poll-run.mjs +8 -0
  50. package/bin/commands/poll.d.mts +1 -2
  51. package/bin/commands/poll.mjs +26 -10
  52. package/bin/comments/rate-limit.d.mts +4 -0
  53. package/bin/comments/rate-limit.mjs +6 -0
  54. package/bin/comments/review-visibility.d.mts +1 -1
  55. package/bin/comments/review-visibility.mjs +3 -2
  56. package/bin/comments/thread-visibility.d.mts +1 -1
  57. package/bin/comments/thread-visibility.mjs +9 -3
  58. package/bin/config/load.d.mts +6 -1
  59. package/bin/config/load.mjs +33 -1
  60. package/bin/config.json +6 -1
  61. package/bin/github/api-telemetry-aggregate.d.mts +32 -0
  62. package/bin/github/api-telemetry-aggregate.mjs +84 -0
  63. package/bin/github/api-telemetry.d.mts +13 -0
  64. package/bin/github/api-telemetry.mjs +128 -0
  65. package/bin/github/check-annotations.d.mts +14 -1
  66. package/bin/github/check-annotations.mjs +29 -2
  67. package/bin/github/client.d.mts +1 -1
  68. package/bin/github/client.mjs +1 -1
  69. package/bin/github/errors.d.mts +2 -0
  70. package/bin/github/errors.mjs +2 -0
  71. package/bin/github/gql/batch-pr-page.gql +8 -0
  72. package/bin/github/gql/batch-pr.gql +8 -0
  73. package/bin/github/gql/check-run-annotations.gql +8 -0
  74. package/bin/github/gql/commit-check-contexts.gql +8 -0
  75. package/bin/github/gql/get-pr-body.gql +8 -0
  76. package/bin/github/gql/get-pr-head-sha.gql +8 -0
  77. package/bin/github/gql/pr-number-by-branch.gql +8 -0
  78. package/bin/github/gql/review-thread-comments.gql +8 -0
  79. package/bin/github/gql/suggestion-threads.gql +8 -0
  80. package/bin/github/graphql-http.mjs +63 -8
  81. package/bin/github/http-auth.d.mts +9 -1
  82. package/bin/github/http-auth.mjs +35 -14
  83. package/bin/github/http-intermediate.d.mts +9 -0
  84. package/bin/github/http-intermediate.mjs +20 -0
  85. package/bin/github/http-request.d.mts +1 -1
  86. package/bin/github/http-request.mjs +1 -1
  87. package/bin/github/http-utils.d.mts +6 -0
  88. package/bin/github/http-utils.mjs +10 -1
  89. package/bin/github/rest-http.d.mts +16 -1
  90. package/bin/github/rest-http.mjs +71 -8
  91. package/bin/github/rest-text.mjs +41 -5
  92. package/bin/index.mjs +2 -1
  93. package/bin/log/session.d.mts +11 -0
  94. package/bin/log/session.mjs +22 -0
  95. package/bin/mcp/server.mjs +16 -2
  96. package/bin/pr-reference.d.mts +1 -1
  97. package/bin/pr-reference.mjs +1 -1
  98. package/bin/quota-warning.d.mts +2 -0
  99. package/bin/quota-warning.mjs +6 -0
  100. package/bin/state/graphql-quota-claims.d.mts +2 -0
  101. package/bin/state/graphql-quota-claims.mjs +61 -0
  102. package/bin/state/graphql-quota-policy.d.mts +17 -0
  103. package/bin/state/graphql-quota-policy.mjs +43 -0
  104. package/bin/state/graphql-quota-warnings.d.mts +6 -0
  105. package/bin/state/graphql-quota-warnings.mjs +105 -0
  106. package/bin/state/rest-cache.d.mts +48 -0
  107. package/bin/state/rest-cache.mjs +91 -0
  108. package/bin/types/api-usage.d.mts +31 -0
  109. package/bin/types/api-usage.mjs +1 -0
  110. package/bin/types/escalate.d.mts +5 -3
  111. package/bin/types/iterate.d.mts +4 -1
  112. package/bin/types/report.d.mts +3 -1
  113. package/bin/types.d.mts +1 -0
  114. package/bin/types.mjs +1 -0
  115. package/package.json +2 -2
  116. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  117. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  118. package/plugins/pr-shepherd/.mcp.json +1 -1
  119. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +24 -13
@@ -1,34 +1,11 @@
1
1
  import { formatFixCodeResult } from "./fix-formatter.mjs";
2
+ import { formatRelevantChecks } from "./iterate-checks-formatter.mjs";
2
3
  import { joinSections } from "../util/markdown.mjs";
3
4
  import { adaptIterateLog, buildSimpleIterateInstructions, numberInstructions, } from "./iterate-instructions.mjs";
4
5
  import { formatMergeRequirementLines } from "../merge-status/requirements-format.mjs";
5
6
  import { appendMergeQueueHeader, formatMergeAction } from "./iterate-merge-formatter.mjs";
6
- function formatActivityLine(result) {
7
- const activity = result.activity ?? {
8
- commitCount: 0,
9
- reviewRoundCount: 0,
10
- latestCommitCommittedAtUnix: null,
11
- reviewItemsSinceLatestCommit: [],
12
- };
13
- const hasActiveChecks = (result.inProgressChecks?.length ?? 0) > 0;
14
- if (activity.commitCount === 0 &&
15
- activity.reviewRoundCount === 0 &&
16
- activity.reviewItemsSinceLatestCommit.length === 0 &&
17
- !hasActiveChecks) {
18
- return null;
19
- }
20
- const parts = [`${activity.commitCount} commits`, `${activity.reviewRoundCount} review rounds`];
21
- if (activity.reviewItemsSinceLatestCommit.length > 0) {
22
- parts.push(`${activity.reviewItemsSinceLatestCommit.length} review items since latest commit`);
23
- }
24
- if (hasActiveChecks) {
25
- parts.push(`active: ${result
26
- .inProgressChecks.slice(0, 5)
27
- .map((c) => `\`${c.name}\``)
28
- .join(", ")}`);
29
- }
30
- return `**activity** ${parts.join(" · ")}`;
31
- }
7
+ import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
8
+ import { formatActivityLine } from "./iterate-activity-formatter.mjs";
32
9
  /**
33
10
  * Format an IterateResult as human-readable Markdown.
34
11
  *
@@ -50,7 +27,8 @@ export function formatIterateResult(result, opts) {
50
27
  const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
51
28
  ? ` · **reviewDecision** \`${result.reviewDecision}\``
52
29
  : "";
53
- const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
30
+ const baseBranchSeg = verbose && result.baseBranch ? ` · **baseBranch** \`${result.baseBranch}\`` : "";
31
+ const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\`${baseBranchSeg}`;
54
32
  let summaryLine;
55
33
  if (verbose) {
56
34
  let verboseBranch = "";
@@ -132,21 +110,27 @@ export function formatIterateResult(result, opts) {
132
110
  if (activityLine)
133
111
  headerLines.push(activityLine);
134
112
  const header = headerLines.join("\n");
113
+ const quotaWarning = formatQuotaWarning(result.quotaWarning);
114
+ const apiUsage = verbose ? formatApiUsage(result.apiUsage) : null;
115
+ const verboseChecks = verbose ? formatRelevantChecks(result.checks) : null;
116
+ const telemetrySections = [quotaWarning, apiUsage, verboseChecks];
135
117
  switch (result.action) {
136
118
  case "wait":
137
119
  return joinSections([
138
120
  header,
121
+ ...telemetrySections,
139
122
  adaptIterateLog(result.log),
140
123
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
141
124
  ]);
142
125
  case "mark_ready":
143
126
  return joinSections([
144
127
  header,
128
+ ...telemetrySections,
145
129
  adaptIterateLog(result.log),
146
130
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
147
131
  ]);
148
132
  case "merge":
149
- return formatMergeAction(header, result);
133
+ return formatMergeAction(joinSections([header, ...telemetrySections]), result);
150
134
  case "cancel": {
151
135
  const cancelHeaderLines = [`${heading} — ${result.reason}`, "", baseLine, summaryLine];
152
136
  if (result.mergeRequirements) {
@@ -167,6 +151,8 @@ export function formatIterateResult(result, opts) {
167
151
  cancelHeaderLines.push(activityLine);
168
152
  return joinSections([
169
153
  cancelHeaderLines.join("\n"),
154
+ ...(apiUsage ? [apiUsage] : []),
155
+ ...(verboseChecks ? [verboseChecks] : []),
170
156
  adaptIterateLog(result.log),
171
157
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
172
158
  ]);
@@ -174,10 +160,12 @@ export function formatIterateResult(result, opts) {
174
160
  case "escalate":
175
161
  return joinSections([
176
162
  header,
163
+ ...(apiUsage ? [apiUsage] : []),
164
+ ...(verboseChecks ? [verboseChecks] : []),
177
165
  result.escalate.humanMessage,
178
166
  `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result))}`,
179
167
  ]);
180
168
  case "fix_code":
181
- return formatFixCodeResult(header, result);
169
+ return formatFixCodeResult(joinSections([header, ...telemetrySections]), result);
182
170
  }
183
171
  }
@@ -1,12 +1,23 @@
1
1
  import { renderMergeCommand } from "../commands/iterate/merge.mjs";
2
2
  import { inlineCode } from "../util/markdown.mjs";
3
+ import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
3
4
  export function buildSimpleIterateInstructions(result) {
4
5
  switch (result.action) {
5
6
  case "wait":
7
+ if (result.quotaWarning) {
8
+ return [
9
+ buildQuotaAwareContinuation(result.quotaWarning, "Non-terminal — no action needed this tick."),
10
+ ];
11
+ }
6
12
  return [
7
13
  "Non-terminal — no action needed this tick. Iterate again with the same options to continue.",
8
14
  ];
9
15
  case "mark_ready":
16
+ if (result.quotaWarning) {
17
+ return [
18
+ buildQuotaAwareContinuation(result.quotaWarning, "The CLI marked the PR ready for review."),
19
+ ];
20
+ }
10
21
  return [
11
22
  "The CLI marked the PR ready for review. Iterate again with the same options to continue.",
12
23
  ];
@@ -20,7 +31,9 @@ export function buildSimpleIterateInstructions(result) {
20
31
  else if (result.merge.fallbackCommand) {
21
32
  instructions.push(`Only if GitHub reports that auto-merge is unavailable, run the \`plain merge fallback\` command: ${inlineCode(renderMergeCommand(result.merge.fallbackCommand))}.`);
22
33
  }
23
- instructions.push("Then iterate again with the same options to monitor until the PR merges or needs work.");
34
+ instructions.push(result.quotaWarning
35
+ ? buildQuotaAwareContinuation(result.quotaWarning, "After running the merge command.")
36
+ : "Then iterate again with the same options to monitor until the PR merges or needs work.");
24
37
  return instructions;
25
38
  }
26
39
  case "cancel":
@@ -64,6 +64,7 @@ export function projectIterateLean(result, opts) {
64
64
  ...((result.supersededNames?.length ?? 0) > 0 && {
65
65
  supersededNames: result.supersededNames,
66
66
  }),
67
+ ...(result.quotaWarning && { quotaWarning: result.quotaWarning }),
67
68
  };
68
69
  switch (result.action) {
69
70
  case "wait":
@@ -157,6 +158,10 @@ export function projectIterateLean(result, opts) {
157
158
  ...(result.escalate.changesRequestedReviews.length > 0 && {
158
159
  changesRequestedReviews: result.escalate.changesRequestedReviews,
159
160
  }),
161
+ ...(result.escalate.checks &&
162
+ result.escalate.checks.length > 0 && {
163
+ checks: result.escalate.checks,
164
+ }),
160
165
  ...(result.escalate.stalledChecks &&
161
166
  result.escalate.stalledChecks.length > 0 && {
162
167
  stalledChecks: result.escalate.stalledChecks,
@@ -0,0 +1,2 @@
1
+ /** Extracts a checked Shepherd Journal from a local PR-body file without external I/O. */
2
+ export declare function handleJournalExtract(args: string[]): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import { extractShepherdJournal } from "../journal/index.mjs";
2
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
3
+ import { USAGE, maybePrintHelp } from "./help.mjs";
4
+ import { readSafeBodyFile } from "./safe-body-file.mjs";
5
+ /** Extracts a checked Shepherd Journal from a local PR-body file without external I/O. */
6
+ export async function handleJournalExtract(args) {
7
+ if (maybePrintHelp(args, "journal extract"))
8
+ return;
9
+ const bodyFile = parseBodyFile(args);
10
+ if (bodyFile === null)
11
+ return;
12
+ try {
13
+ const body = await readSafeBodyFile(bodyFile);
14
+ process.stdout.write(`${JSON.stringify(extractShepherdJournal(body))}\n`);
15
+ }
16
+ catch (error) {
17
+ process.stderr.write(`pr-shepherd: journal extract: ${String(error)}\n`);
18
+ process.exitCode = error instanceof ShepherdError ? error.exitCode : EXIT.NOINPUT;
19
+ }
20
+ }
21
+ function parseBodyFile(args) {
22
+ let bodyFile;
23
+ for (let index = 0; index < args.length; index++) {
24
+ const arg = args[index];
25
+ if (arg === "--body-file") {
26
+ const value = args[++index];
27
+ if (value === undefined || bodyFile !== undefined) {
28
+ return printUsage();
29
+ }
30
+ bodyFile = value;
31
+ continue;
32
+ }
33
+ if (arg.startsWith("--body-file=")) {
34
+ const value = arg.slice("--body-file=".length);
35
+ if (value === "" || bodyFile !== undefined)
36
+ return printUsage();
37
+ bodyFile = value;
38
+ continue;
39
+ }
40
+ return printUsage();
41
+ }
42
+ return bodyFile ?? printUsage();
43
+ }
44
+ function printUsage() {
45
+ process.stderr.write(`${USAGE["journal extract"]}\n`);
46
+ process.exitCode = EXIT.USAGE;
47
+ return null;
48
+ }
@@ -6,12 +6,14 @@ function formatRateLimit(result) {
6
6
  const rateLimit = result.rateLimit;
7
7
  if (rateLimit) {
8
8
  const details = [
9
+ typeof rateLimit.resource === "string" ? `resource ${rateLimit.resource}` : null,
9
10
  typeof rateLimit.retryAfterSeconds === "number"
10
11
  ? `retry after ${rateLimit.retryAfterSeconds}s`
11
12
  : null,
12
13
  typeof rateLimit.remaining === "number" && typeof rateLimit.limit === "number"
13
14
  ? `remaining ${rateLimit.remaining}/${rateLimit.limit}`
14
15
  : null,
16
+ typeof rateLimit.used === "number" ? `used ${rateLimit.used}` : null,
15
17
  typeof rateLimit.resetAt === "number"
16
18
  ? `reset at ${new Date(rateLimit.resetAt * 1000).toISOString()}`
17
19
  : null,
@@ -0,0 +1,14 @@
1
+ import { open } from "node:fs/promises";
2
+ type FileHandle = Awaited<ReturnType<typeof open>>;
3
+ export interface SafeBodyFileReaderDependencies {
4
+ platform: NodeJS.Platform;
5
+ noFollow: number | undefined;
6
+ nonBlock: number | undefined;
7
+ open: (filePath: string, flags: number) => Promise<FileHandle>;
8
+ }
9
+ /**
10
+ * Reads a regular file after rejecting unsafe final path entries without exposing its contents.
11
+ * Callers must trust parent directories: O_NOFOLLOW only protects the final path entry.
12
+ */
13
+ export declare function readSafeBodyFile(filePath: string, dependencies?: SafeBodyFileReaderDependencies): Promise<string>;
14
+ export {};
@@ -0,0 +1,42 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
4
+ const DEFAULT_DEPENDENCIES = {
5
+ platform: process.platform,
6
+ noFollow: constants.O_NOFOLLOW,
7
+ nonBlock: constants.O_NONBLOCK,
8
+ open,
9
+ };
10
+ /**
11
+ * Reads a regular file after rejecting unsafe final path entries without exposing its contents.
12
+ * Callers must trust parent directories: O_NOFOLLOW only protects the final path entry.
13
+ */
14
+ export async function readSafeBodyFile(filePath, dependencies = DEFAULT_DEPENDENCIES) {
15
+ const flags = safeOpenFlags(dependencies);
16
+ if (flags === null)
17
+ throw noInput();
18
+ let handle;
19
+ try {
20
+ handle = await dependencies.open(filePath, flags);
21
+ if (!(await handle.stat()).isFile())
22
+ throw new Error("not a regular file");
23
+ return await handle.readFile({ encoding: "utf8" });
24
+ }
25
+ catch {
26
+ throw noInput();
27
+ }
28
+ finally {
29
+ await handle?.close().catch(() => undefined);
30
+ }
31
+ }
32
+ function safeOpenFlags(dependencies) {
33
+ if (dependencies.platform === "win32" ||
34
+ dependencies.noFollow === undefined ||
35
+ dependencies.nonBlock === undefined) {
36
+ return null;
37
+ }
38
+ return constants.O_RDONLY | dependencies.noFollow | dependencies.nonBlock;
39
+ }
40
+ function noInput() {
41
+ return new ShepherdError("body file could not be read safely", EXIT.NOINPUT);
42
+ }
@@ -10,6 +10,7 @@ import { USAGE, helpKeyForArgs, maybePrintHelp } from "./cli/help.mjs";
10
10
  import { formatMutateResult } from "./cli/formatters.mjs";
11
11
  import { handleClean, handleCommitSuggestion, handleSuggestionPatches, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
12
12
  import { handleJournal } from "./cli/journal-handler.mjs";
13
+ import { handleJournalExtract } from "./cli/journal-extract-handler.mjs";
13
14
  import { handlePoll } from "./cli/poll-handler.mjs";
14
15
  import { warnPrrcThreadIds, validateRequireSha, rejectPrrcMinimizeIds, } from "./cli/resolve-validators.mjs";
15
16
  import { setupLog } from "./log/setup.mjs";
@@ -32,6 +33,12 @@ export async function main(argv) {
32
33
  await handleLogFile(args.slice(1));
33
34
  return;
34
35
  }
36
+ // Extraction deliberately precedes legacy warnings and logging: it is a local,
37
+ // GitHub/config/log-free read path for automation that already has a PR body.
38
+ if (subcommand === "journal" && args[1] === "extract") {
39
+ await handleJournalExtract(args.slice(2));
40
+ return;
41
+ }
35
42
  // The public command groups keep admin diagnostics out of the normal
36
43
  // command namespace. Keep this before setupLog because log-file reads the
37
44
  // log path and must not initialize/tee the log it is reporting.
@@ -1,3 +1,4 @@
1
+ import { type AnnotationCacheOptions } from "../github/check-annotations.mts";
1
2
  import type { CheckAnnotation, ClassifiedCheck, ShepherdReport, TriagedCheck } from "../types.mts";
2
3
  export declare function checksWithActionableAnnotations(report: ShepherdReport): TriagedCheck[];
3
4
  export declare function attachAndMergeCheckAnnotations(buckets: {
@@ -8,7 +9,7 @@ export declare function attachAndMergeCheckAnnotations(buckets: {
8
9
  ignored: ClassifiedCheck[];
9
10
  }, seenMap: Map<string, {
10
11
  seenAt: number;
11
- }>, prNumber: number): Promise<{
12
+ }>, prNumber: number, cacheOpts?: AnnotationCacheOptions): Promise<{
12
13
  passing: ClassifiedCheck[];
13
14
  failing: TriagedCheck[];
14
15
  skipped: ClassifiedCheck[];
@@ -1,4 +1,4 @@
1
- import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
1
+ import { fetchCheckRunAnnotations, } from "../github/check-annotations.mjs";
2
2
  function shouldFetchCheckAnnotations(check) {
3
3
  return check.id != null && check.status === "COMPLETED" && check.hasAnnotations === true;
4
4
  }
@@ -11,7 +11,7 @@ export function checksWithActionableAnnotations(report) {
11
11
  ...(report.checks.ignored ?? []),
12
12
  ].filter((c) => c.conclusion !== "SUCCESS" && (c.annotations?.length ?? 0) > 0);
13
13
  }
14
- export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber) {
14
+ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber, cacheOpts) {
15
15
  const candidates = [
16
16
  ...buckets.failing,
17
17
  ...buckets.passing,
@@ -19,7 +19,7 @@ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber)
19
19
  ...buckets.filtered,
20
20
  ...buckets.ignored,
21
21
  ].filter(shouldFetchCheckAnnotations);
22
- const annotated = await attachUnseenCheckAnnotations(candidates, seenMap, prNumber);
22
+ const annotated = await attachUnseenCheckAnnotations(candidates, seenMap, prNumber, cacheOpts);
23
23
  const byId = new Map(annotated.flatMap((c) => (c.id != null ? [[c.id, c]] : [])));
24
24
  const apply = (list) => list.map((c) => {
25
25
  if (c.id == null)
@@ -35,20 +35,20 @@ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber)
35
35
  ignored: apply(buckets.ignored),
36
36
  };
37
37
  }
38
- async function attachUnseenCheckAnnotations(checks, seenMap, prNumber) {
38
+ async function attachUnseenCheckAnnotations(checks, seenMap, prNumber, cacheOpts) {
39
39
  const checksWithAnnotations = [];
40
40
  for (const check of checks) {
41
41
  // eslint-disable-next-line no-await-in-loop
42
- checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber));
42
+ checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber, cacheOpts));
43
43
  }
44
44
  return checksWithAnnotations;
45
45
  }
46
- async function attachForCheck(check, seenMap, prNumber) {
46
+ async function attachForCheck(check, seenMap, prNumber, cacheOpts) {
47
47
  if (check.id == null)
48
48
  return check;
49
49
  let annotations;
50
50
  try {
51
- annotations = await fetchCheckRunAnnotations(check.id);
51
+ annotations = await fetchCheckRunAnnotations(check.id, cacheOpts);
52
52
  }
53
53
  catch (err) {
54
54
  const msg = err instanceof Error ? err.message : String(err);
@@ -9,15 +9,15 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
9
9
  return "FAILING";
10
10
  if (verdict.anyInProgress)
11
11
  return "IN_PROGRESS";
12
- // BLOCKED with no remaining shepherd work — hand off via ready-delay regardless of why GitHub
12
+ // BLOCKED with no remaining shepherd work — enter ready-delay regardless of why GitHub
13
13
  // is BLOCKED (review pending, insufficient approvals, branch-protection rule, etc.).
14
14
  // Requires hasChecks so that a PR with zero relevant checks (CI never started, or all
15
15
  // filtered/skipped) doesn't prematurely trigger READY before any check has reported.
16
16
  // Exception: UNSTABLE with ignored checks — UNSTABLE means only non-required checks are
17
- // pending/failing, and if those are all ignored the handoff is safe even with no other checks.
17
+ // pending/failing, and if those are all ignored the ready state is safe even with no other checks.
18
18
  // BLOCKED is excluded from the ignoredNames extension: BLOCKED can mean required checks haven't
19
- // started, and handing off prematurely there risks a broken merge attempt.
20
- // blockingBotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
19
+ // started, and entering READY prematurely there risks a broken merge attempt.
20
+ // blockingBotReviewInProgress is still excluded — a bot review is Shepherd work, not a READY state.
21
21
  const hasRelevantPassingChecks = verdict.hasChecks || (mergeStatus.status === "UNSTABLE" && verdict.ignoredNames.length > 0);
22
22
  if (verdict.allPassed &&
23
23
  hasRelevantPassingChecks &&
@@ -16,7 +16,8 @@ import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
16
16
  import { classifyReviewsForDisplay, classifyChangesRequestedReviewsForDisplay, } from "../comments/review-visibility.mjs";
17
17
  import { autoMinimizeComments, autoResolveThreads } from "../comments/resolve.mjs";
18
18
  import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
19
- import { normalizeBotUsernames } from "../comments/authors.mjs";
19
+ import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
20
+ import { buildThreadMutationRouting } from "./iterate/thread-mutation-routing.mjs";
20
21
  import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
21
22
  import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
22
23
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
@@ -27,6 +28,7 @@ export async function runCheck(opts) {
27
28
  if (prNumber === null) {
28
29
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
29
30
  }
31
+ const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
30
32
  const config = loadConfig();
31
33
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
32
34
  const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
@@ -40,7 +42,7 @@ export async function runCheck(opts) {
40
42
  }
41
43
  const startupFailureChecks = result.checkSuitesComplete
42
44
  ? []
43
- : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber);
45
+ : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey);
44
46
  const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
45
47
  const classifiedPrChecks = classifyChecks(allChecks);
46
48
  const latestRemoval = batchData.latestMergeQueueRemoval;
@@ -65,20 +67,31 @@ export async function runCheck(opts) {
65
67
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
66
68
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
67
69
  const ignored = classifiedChecks.filter((c) => c.category === "ignored");
68
- const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
69
- const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
70
+ const triagedBase = failing.length > 0 && !opts.skipTriage
71
+ ? await triageFailingChecks(failing, repo, stateKey)
72
+ : failing;
70
73
  const seenMap = await loadSeenMap(stateKey);
71
74
  const botUsernames = normalizeBotUsernames(config.botUsernames);
72
75
  const ruleSet = await loadRules(discoverRuleFiles(getEffectiveCwd()));
73
76
  const classifyIndex = buildClassifyIndex(ruleSet, batchData);
74
77
  const partition = partitionBatch(classifyIndex, batchData);
75
- const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered, ignored }, seenMap, prNumber);
78
+ const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered, ignored }, seenMap, prNumber, { stateKey, headSha: batchData.headRefOid });
76
79
  const ignoredAnnotated = merged.ignored.filter((c) => (c.annotations?.length ?? 0) > 0);
77
80
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized && !partition.suppressedCommentIds.has(c.id));
78
81
  const deniedRuleAutoResolveCommentIds = new Set(partition.ruleAutoResolveCommentIds.filter((id) => batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize !== true));
79
82
  const visibleCommentClassification = classifyVisibleComments(batchData.comments.filter((c) => !partition.suppressedCommentIds.has(c.id) || deniedRuleAutoResolveCommentIds.has(c.id)), seenMap, config.iterate.minimizeComments, botUsernames);
80
83
  const deniedRuleAutoResolveThreadIds = new Set(partition.ruleAutoResolveThreadIds.filter((id) => batchData.reviewThreads.find((thread) => thread.id === id)?.viewerCanResolve !== true));
81
- const threadVisibility = classifyThreadVisibility(batchData.reviewThreads.filter((t) => !partition.suppressedThreadIds.has(t.id) || deniedRuleAutoResolveThreadIds.has(t.id)), seenMap, botUsernames);
84
+ const visibleThreadCandidates = batchData.reviewThreads.filter((t) => !partition.suppressedThreadIds.has(t.id) || deniedRuleAutoResolveThreadIds.has(t.id));
85
+ const threadMutationRouting = buildThreadMutationRouting(visibleThreadCandidates, botUsernames, partition.ruleAutoResolveThreadIds);
86
+ const replyThreadIds = new Set(threadMutationRouting.replyThreadIds);
87
+ const resolveThreadIds = new Set(threadMutationRouting.resolveThreadIds);
88
+ const repeatableThreadIds = new Set(visibleThreadCandidates
89
+ .filter((thread) => thread.path !== null &&
90
+ thread.line !== null &&
91
+ (!replyThreadIds.has(thread.id) || thread.viewerCanReply === true) &&
92
+ (!resolveThreadIds.has(thread.id) || thread.viewerCanResolve === true))
93
+ .map((thread) => thread.id));
94
+ const threadVisibility = classifyThreadVisibility(visibleThreadCandidates, seenMap, botUsernames, repeatableThreadIds);
82
95
  const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
83
96
  const cls = classifyItem(c.id, c.body, seenMap);
84
97
  if (cls === "unchanged")
@@ -101,7 +114,7 @@ export async function runCheck(opts) {
101
114
  else
102
115
  seenSummaries.push(r);
103
116
  }
104
- const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames);
117
+ const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames, batchData.viewerAuthorization?.viewerCanAdminister === true);
105
118
  const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
106
119
  if (opts.persistSeen !== false) {
107
120
  const successfulAnnotations = [
@@ -143,12 +156,21 @@ export async function runCheck(opts) {
143
156
  ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => batchData.reviewSummaries.find((review) => review.id === id)?.viewerCanMinimize === true),
144
157
  };
145
158
  const { threadIds: authorizedRuleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, } = await remainingRuleAutoResolveIds(authorizedPartition, opts.autoMinimizeSuppressed);
159
+ const visibleMutationThreadIds = new Set([...threadVisibility.activeThreads, ...threadVisibility.resolutionOnlyThreads].map((thread) => thread.id));
146
160
  const ruleAutoResolveThreadIds = [
147
161
  ...authorizedRuleAutoResolveThreadIds,
148
- ...deniedRuleAutoResolveThreadIds,
162
+ ...[...deniedRuleAutoResolveThreadIds].filter((id) => visibleMutationThreadIds.has(id)),
149
163
  ];
150
164
  const changesRequestedReviews = changesRequestedReviewVisibility.visible;
151
- const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)).length;
165
+ const visibleChangesRequestedIds = new Set(changesRequestedReviews.map((review) => review.id));
166
+ const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((review) => {
167
+ if (partition.suppressedChangesRequestedIds.has(review.id))
168
+ return false;
169
+ const isBot = !isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames);
170
+ return (!isBot ||
171
+ batchData.viewerAuthorization?.viewerCanAdminister === true ||
172
+ visibleChangesRequestedIds.has(review.id));
173
+ }).length;
152
174
  const approvedReviews = approvedReviewVisibility.visible;
153
175
  let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
154
176
  if (status === "READY" && !didRefreshMergeability) {
@@ -0,0 +1,2 @@
1
+ import type { IterateResult } from "../../types.mts";
2
+ export declare function attachApiUsage(result: IterateResult, persistWarning: boolean, preservePersistedWarning?: boolean): Promise<IterateResult>;
@@ -0,0 +1,35 @@
1
+ import { loadConfig } from "../../config/load.mjs";
2
+ import { summarizeApiTelemetry } from "../../github/api-telemetry.mjs";
3
+ import { evaluateWorktreeGraphqlQuotaWarning } from "../../state/graphql-quota-warnings.mjs";
4
+ import { buildQuotaAwareContinuation } from "../../quota-warning.mjs";
5
+ function shouldWarn(result) {
6
+ return ["wait", "mark_ready", "merge", "fix_code"].includes(result.action);
7
+ }
8
+ export async function attachApiUsage(result, persistWarning, preservePersistedWarning = false) {
9
+ const apiUsage = summarizeApiTelemetry();
10
+ if (apiUsage === undefined)
11
+ return result;
12
+ let quotaWarning = preservePersistedWarning ? result.quotaWarning : undefined;
13
+ if (quotaWarning === undefined && apiUsage.graphql !== undefined && shouldWarn(result)) {
14
+ const [owner, repo] = result.repo.split("/");
15
+ if (owner && repo) {
16
+ quotaWarning = await evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, loadConfig().watch.graphqlQuotaWarnings, apiUsage.graphql, persistWarning);
17
+ }
18
+ }
19
+ const { quotaWarning: _deferredWarning, ...baseResult } = result;
20
+ const withWarning = {
21
+ ...baseResult,
22
+ apiUsage,
23
+ ...(quotaWarning !== undefined && { quotaWarning }),
24
+ };
25
+ if (quotaWarning === undefined || withWarning.action !== "fix_code")
26
+ return withWarning;
27
+ const instructions = [...withWarning.fix.instructions];
28
+ const completion = instructions.at(-1);
29
+ if (completion !== undefined) {
30
+ if (/\[FIX_CODE\].*non-terminal/i.test(completion)) {
31
+ instructions[instructions.length - 1] = buildQuotaAwareContinuation(quotaWarning, "`[FIX_CODE]` is non-terminal. After completing these steps,");
32
+ }
33
+ }
34
+ return { ...withWarning, fix: { ...withWarning.fix, instructions } };
35
+ }
@@ -30,4 +30,4 @@ export declare function buildBehindBaseHintInstruction(baseBranch: string, hint:
30
30
  export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
31
31
  /** Build the CI-triage pointer; the skill limits follow-up actions to included evidence. */
32
32
  export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
33
- export declare function buildFixCompletionInstruction(checks: AgentCheck[], requiresRemoteUpdateAuthorization?: boolean, hasShaGatedReviewMutations?: boolean): string;
33
+ export declare function buildFixCompletionInstruction(checks: AgentCheck[], hasConflicts?: boolean, hasShaGatedReviewMutations?: boolean): string;
@@ -44,7 +44,7 @@ export function buildResolveCommandInstruction(resolveCommand) {
44
44
  instructions.push("Run the generated thread IDs unchanged. A latest comment beginning `<!-- pr-shepherd -->` is an established Shepherd reply; a marked viewer-authored human thread is emitted resolve-only, not for another reply.");
45
45
  }
46
46
  if (resolveCommand.requiresHeadSha) {
47
- instructions.push("If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, do not run this command until an authorized push updates the remote PR head; then replace `$HEAD_SHA` with that pushed commit SHA.");
47
+ instructions.push("If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, commit and push to the PR head branch first, then replace `$HEAD_SHA` with the pushed commit SHA.");
48
48
  }
49
49
  if (resolveCommand.requiresDismissMessage) {
50
50
  instructions.push("Replace `$DISMISS_MESSAGE` with one sentence describing what changed.");
@@ -58,32 +58,27 @@ export function buildFailingCheckInstructions(checks) {
58
58
  return [];
59
59
  const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
60
60
  const hasTriageable = checks.some((c) => c.runId || c.detailsUrl);
61
+ const hasRerunAuthorized = checks.some((c) => c.rerunCommand);
61
62
  const instructions = [];
62
63
  if (hasTriageable) {
63
64
  instructions.push('Triage every failure under `## Failing checks`. See "CI failure triage" in the pr-shepherd skill for read-only inspection rules.');
64
65
  }
66
+ if (hasRerunAuthorized) {
67
+ instructions.push('A `[rerun authorized]` check includes a `rerun:` command. See "CI failure triage" in the pr-shepherd skill for which conclusions warrant a rerun versus a code fix.');
68
+ }
65
69
  if (hasBare) {
66
- instructions.push("For each `(no runId)` failure, escalate to a human because no log or URL is available.");
70
+ instructions.push("For each `(no runId)` failure, preserve the displayed metadata; Shepherd will escalate when no other autonomous work remains.");
67
71
  }
68
72
  return instructions;
69
73
  }
70
- export function buildFixCompletionInstruction(checks, requiresRemoteUpdateAuthorization = false, hasShaGatedReviewMutations = false) {
71
- if (requiresRemoteUpdateAuthorization) {
72
- return "`[FIX_CODE]` requires a human handoff for an authorized push after conflict resolution. Shepherd cannot verify the Git credential's push authorization. Stop polling after committing, and resume only after the remote PR head changes.";
73
- }
74
- const hasUninspectableFailure = checks.some((check) => !check.runId && !check.detailsUrl);
75
- const hasCiAuthorizationHandoff = checks.some((check) => check.conclusion === "CANCELLED" ||
76
- check.conclusion === "STARTUP_FAILURE" ||
77
- (check.runId === null && Boolean(check.detailsUrl)) ||
78
- (check.runId !== null && !check.logExcerpt?.trim()));
79
- if (hasUninspectableFailure) {
80
- return "`[FIX_CODE]` requires a human handoff for an uninspectable failing check. Stop polling after escalating, and resume only after human direction.";
81
- }
82
- if (hasCiAuthorizationHandoff) {
83
- return "`[FIX_CODE]` requires a human handoff for a failing check with no authorized follow-up action. Stop polling after escalating, and resume only after human direction.";
84
- }
74
+ export function buildFixCompletionInstruction(checks, hasConflicts = false, hasShaGatedReviewMutations = false) {
75
+ if (hasConflicts)
76
+ return "`[FIX_CODE]` is non-terminal: resolve the conflicts, commit, push to the PR head branch, then iterate again with the same options.";
85
77
  if (hasShaGatedReviewMutations) {
86
- return "`[FIX_CODE]` is conditional: if you changed code, stop after committing and resume only after an authorized push changes the remote PR head; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
78
+ return "`[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run the review mutations using the pushed commit SHA and iterate again with the same options; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
79
+ }
80
+ if (checks.some((check) => check.rerunCommand)) {
81
+ return "`[FIX_CODE]` is non-terminal. Run any warranted reruns for `[rerun authorized]` checks (or apply code fixes for real failures), then iterate again with the same options to continue.";
87
82
  }
88
83
  return "`[FIX_CODE]` is non-terminal. After completing these steps, iterate again with the same options to continue.";
89
84
  }
@@ -67,7 +67,8 @@ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentId
67
67
  const canResolve = new Set(authorizationThreads
68
68
  .filter((thread) => thread.viewerCanResolve === true)
69
69
  .map((thread) => thread.id));
70
- const replyThreadIds = routed.replyThreadIds.filter((id) => canReply.has(id));
70
+ const pairedResolveIds = new Set(routed.pairedResolveThreadIds);
71
+ const replyThreadIds = routed.replyThreadIds.filter((id) => canReply.has(id) && (!pairedResolveIds.has(id) || canResolve.has(id)));
71
72
  // Viewer-authored human resolves stay paired with an authorized reply. Marker-ended
72
73
  // viewer-authored retries and bot/non-human resolves need only resolve authorization.
73
74
  const pairedResolveThreadIds = routed.pairedResolveThreadIds.filter((id) => canReply.has(id) && canResolve.has(id));