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
@@ -2,6 +2,61 @@ import { loadConfig } from "../../config/load.mjs";
2
2
  function renderEscalateAuthor(item) {
3
3
  return [`@${item.author}`, item.authorType, item.authorAssociation].filter(Boolean).join(" · ");
4
4
  }
5
+ function renderCheckTarget(check) {
6
+ const parts = [];
7
+ if (check.runId)
8
+ parts.push(`run \`${check.runId}\``);
9
+ if (check.detailsUrl)
10
+ parts.push(`URL \`${check.detailsUrl}\``);
11
+ return parts.length > 0 ? parts.join(", ") : "no run ID or URL";
12
+ }
13
+ function renderCheckScope(check) {
14
+ if (!check.scope)
15
+ return "";
16
+ const commit = check.commitOid ? ` at \`${check.commitOid}\`` : "";
17
+ return `, scope \`${check.scope}\`${commit}`;
18
+ }
19
+ function renderBlockquoteLines(value, indent) {
20
+ return value.split("\n").map((line) => `${indent}> ${line}`);
21
+ }
22
+ function renderEscalateAnnotation(annotation) {
23
+ const start = annotation.startLine ?? annotation.endLine ?? "?";
24
+ const end = annotation.endLine ?? annotation.startLine ?? "?";
25
+ const range = start === end ? String(start) : `${start}-${end}`;
26
+ const columns = annotation.startColumn == null && annotation.endColumn == null
27
+ ? ""
28
+ : `, columns ${annotation.startColumn ?? "?"}-${annotation.endColumn ?? "?"}`;
29
+ const title = annotation.title ? ` — ${annotation.title}` : "";
30
+ const link = annotation.blobUrl ? ` [source](${annotation.blobUrl})` : "";
31
+ const lines = [
32
+ ` - annotation \`${annotation.id}\`${link} — \`${annotation.path}:${range}\` [${annotation.level}${columns}]${title}`,
33
+ ...renderBlockquoteLines(annotation.message, " "),
34
+ ];
35
+ if (annotation.rawDetails) {
36
+ lines.push(...renderBlockquoteLines(annotation.rawDetails, " "));
37
+ }
38
+ return lines;
39
+ }
40
+ function renderEscalateCheck(check) {
41
+ const workflowPrefix = check.workflowName ? `${check.workflowName} › ` : "";
42
+ const jobLabel = check.jobName ?? check.name;
43
+ const conclusion = check.conclusion ?? "UNKNOWN";
44
+ const lines = [
45
+ `- ${renderCheckTarget(check)} — \`${workflowPrefix}${jobLabel}\` [conclusion: ${conclusion}]${renderCheckScope(check)}`,
46
+ ];
47
+ if (check.failedStep)
48
+ lines.push(` > failed step: ${check.failedStep}`);
49
+ if (check.summary)
50
+ lines.push(` > ${check.summary}`);
51
+ if (check.logExcerpt)
52
+ lines.push(...renderBlockquoteLines(check.logExcerpt, " "));
53
+ if (check.rerunCommand)
54
+ lines.push(` rerun: \`${check.rerunCommand}\``);
55
+ for (const annotation of check.annotations ?? []) {
56
+ lines.push(...renderEscalateAnnotation(annotation));
57
+ }
58
+ return lines;
59
+ }
5
60
  export function checkEscalateTriggers(actionableThreads, threadAttempts) {
6
61
  const triggers = [];
7
62
  const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
@@ -10,11 +65,6 @@ export function checkEscalateTriggers(actionableThreads, threadAttempts) {
10
65
  if (thrashThreads.length > 0) {
11
66
  triggers.push("fix-thrash");
12
67
  }
13
- // Trigger 2: actionable thread has no file/line — cannot locate code to edit.
14
- const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
15
- if (unlocatable.length > 0) {
16
- triggers.push("thread-missing-location");
17
- }
18
68
  return {
19
69
  triggers,
20
70
  thrashHistory: thrashThreads.length > 0
@@ -69,6 +119,7 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
69
119
  const hasItems = escalate.unresolvedThreads.length > 0 ||
70
120
  escalate.changesRequestedReviews.length > 0 ||
71
121
  escalate.ambiguousComments.length > 0 ||
122
+ (escalate.checks?.length ?? 0) > 0 ||
72
123
  (escalate.stalledChecks?.length ?? 0) > 0;
73
124
  if (hasItems) {
74
125
  lines.push("");
@@ -86,6 +137,9 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
86
137
  }
87
138
  if ((escalate.stalledChecks?.length ?? 0) > 0)
88
139
  lines.push("");
140
+ for (const check of escalate.checks ?? []) {
141
+ lines.push(...renderEscalateCheck(check), "");
142
+ }
89
143
  for (const t of escalate.unresolvedThreads) {
90
144
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
91
145
  lines.push(`- thread \`${t.id}\` — ${loc} (${renderEscalateAuthor(t)}):`);
@@ -144,8 +198,11 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
144
198
  return lines.join("\n");
145
199
  }
146
200
  export function buildEscalateSuggestion(triggers, detail) {
201
+ if (triggers.includes("check-follow-up-unavailable")) {
202
+ return "One or more failing checks have no autonomous follow-up available. Use the displayed conclusion, run or URL, and included evidence to handle them manually.";
203
+ }
147
204
  if (triggers.includes("authorization-required")) {
148
- return "GitHub did not confirm that the current viewer may perform one or more required actions. Ask a repository maintainer to handle the listed items; Shepherd will not recommend commands that would be denied.";
205
+ return "GitHub did not confirm that the current viewer may perform the requested mark-ready or merge/enqueue operation. Ask a repository maintainer to perform that state change; Shepherd will not recommend a command that would be denied.";
149
206
  }
150
207
  if (triggers.includes("merge-queue-removed")) {
151
208
  const reason = detail ? ` GitHub reason: ${detail}.` : "";
@@ -162,9 +219,6 @@ export function buildEscalateSuggestion(triggers, detail) {
162
219
  if (triggers.includes("fix-thrash")) {
163
220
  return "Same thread(s) reached the automated attempt limit — treat this as a manual handoff. Apply the fix by hand.";
164
221
  }
165
- if (triggers.includes("thread-missing-location")) {
166
- return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
167
- }
168
222
  if (triggers.includes("bot-cr-not-dismissed")) {
169
223
  const ids = detail ? ` (review IDs: ${detail})` : "";
170
224
  return `Bot CHANGES_REQUESTED review(s) remained undismissed past the stall window${ids}. The agent likely dropped \`--dismiss-review-ids\` from a prior apply command. Dismiss the review(s) manually (or re-run \`pr-shepherd apply review\` with the IDs) to unblock the PR.`;
@@ -11,8 +11,23 @@ import { applyStallGuard } from "./stall.mjs";
11
11
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
13
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
14
+ import { canRerunWorkflows } from "../../checks/conclusions.mjs";
14
15
  import { loadConfig } from "../../config/load.mjs";
15
16
  import { formatPrUrl } from "../../pr-reference.mjs";
17
+ function checkRequiresHumanFollowUp(check) {
18
+ if (check.rerunCommand)
19
+ return false;
20
+ if (check.conclusion === "ACTION_REQUIRED" ||
21
+ check.conclusion === "CANCELLED" ||
22
+ check.conclusion === "STARTUP_FAILURE")
23
+ return true;
24
+ // An external check's direct URL is actionable evidence: the agent can inspect the
25
+ // provider and/or reproduce the reported failure locally. Only a truly bare check
26
+ // has no autonomous investigation path.
27
+ if (check.runId === null)
28
+ return !check.detailsUrl?.trim();
29
+ return !check.logExcerpt?.trim();
30
+ }
16
31
  function nextFixAttempts(stored, headSha, threads) {
17
32
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
18
33
  const threadBodyHashes = stored?.threadBodyHashes
@@ -40,66 +55,17 @@ export async function handleFixCode(ctx) {
40
55
  ]);
41
56
  const replyIdSet = new Set(routedThreadMutations.replyThreadIds);
42
57
  const resolveIdSet = new Set(routedThreadMutations.resolveThreadIds);
43
- const unauthorizedReplies = allThreads.filter((thread) => report.viewerAuthorization !== undefined &&
44
- replyIdSet.has(thread.id) &&
45
- thread.viewerCanReply !== true);
46
- const unauthorizedResolves = allThreads.filter((thread) => report.viewerAuthorization !== undefined &&
47
- resolveIdSet.has(thread.id) &&
48
- thread.viewerCanResolve !== true);
49
- const unauthorizedDismissals = report.changesRequestedReviews.filter((review) => report.viewerAuthorization !== undefined &&
50
- (!isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames)) &&
58
+ const unauthorizedReplies = allThreads.filter((thread) => replyIdSet.has(thread.id) && thread.viewerCanReply !== true);
59
+ const unauthorizedResolves = allThreads.filter((thread) => resolveIdSet.has(thread.id) && thread.viewerCanResolve !== true);
60
+ const unauthorizedDismissals = report.changesRequestedReviews.filter((review) => (!isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames)) &&
51
61
  report.viewerAuthorization?.viewerCanAdminister !== true);
52
- const authorization = [
53
- ...(unauthorizedReplies.length > 0
54
- ? [
55
- {
56
- action: "reply-thread",
57
- targetIds: unauthorizedReplies.map((thread) => thread.id),
58
- reason: "denied-or-unverifiable",
59
- },
60
- ]
61
- : []),
62
- ...(unauthorizedResolves.length > 0
63
- ? [
64
- {
65
- action: "resolve-thread",
66
- targetIds: unauthorizedResolves.map((thread) => thread.id),
67
- reason: "denied-or-unverifiable",
68
- },
69
- ]
70
- : []),
71
- ...(unauthorizedDismissals.length > 0
72
- ? [
73
- {
74
- action: "dismiss-review",
75
- targetIds: unauthorizedDismissals.map((review) => review.id),
76
- reason: "denied-or-unverifiable",
77
- },
78
- ]
79
- : []),
80
- ];
81
- if (authorization.length > 0) {
82
- const authorizationEscalateBase = {
83
- triggers: ["authorization-required"],
84
- unresolvedThreads: allThreads.map(toAgentThread),
85
- ambiguousComments: report.comments.actionable.map(toAgentComment),
86
- changesRequestedReviews: report.changesRequestedReviews,
87
- authorization,
88
- suggestion: buildEscalateSuggestion(["authorization-required"]),
89
- };
90
- return {
91
- ...base,
92
- action: "escalate",
93
- escalate: {
94
- ...authorizationEscalateBase,
95
- humanMessage: buildEscalateHumanMessage(authorizationEscalateBase, prReference),
96
- },
97
- };
98
- }
62
+ const skippedThreadIds = new Set([...unauthorizedReplies, ...unauthorizedResolves].map((thread) => thread.id));
63
+ const retryableActionableThreads = report.threads.actionable.filter((thread) => !skippedThreadIds.has(thread.id) && thread.path !== null && thread.line !== null);
99
64
  const protectedRuns = [];
100
65
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
101
- const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
102
- const botCrReviews = report.changesRequestedReviews.filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames));
66
+ const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, retryableActionableThreads);
67
+ const botCrReviews = report.changesRequestedReviews.filter((r) => (!isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames)) &&
68
+ report.viewerAuthorization?.viewerCanAdminister === true);
103
69
  const botCrStateKey = { owner: repoOwner, repo: repoName, pr: prNumber };
104
70
  const previousBotCrState = await readBotCrSeenState(botCrStateKey);
105
71
  const nowSeconds = Math.floor(Date.now() / 1000);
@@ -120,11 +86,13 @@ export async function handleFixCode(ctx) {
120
86
  action: "escalate",
121
87
  escalate: {
122
88
  ...escalateBase,
123
- humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
89
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference, {
90
+ merge: opts.merge,
91
+ }),
124
92
  },
125
93
  };
126
94
  }
127
- const escalateTriggers = checkEscalateTriggers(report.threads.actionable, threadAttempts);
95
+ const escalateTriggers = checkEscalateTriggers(retryableActionableThreads, threadAttempts);
128
96
  if (escalateTriggers.triggers.length > 0) {
129
97
  const escalateBase = {
130
98
  triggers: escalateTriggers.triggers,
@@ -139,24 +107,57 @@ export async function handleFixCode(ctx) {
139
107
  action: "escalate",
140
108
  escalate: {
141
109
  ...escalateBase,
142
- humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
110
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference, {
111
+ merge: opts.merge,
112
+ }),
143
113
  },
144
114
  };
145
115
  }
146
116
  await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
147
- // GitHub does not expose a per-run viewer capability for cancellation. Fail closed:
148
- // do not issue or recommend an Actions mutation based only on repository role.
117
+ // GitHub does not expose a per-run viewer capability for cancellation, so Shepherd never
118
+ // issues or recommends a cancellation regardless of repository role. A rerun is different:
119
+ // GitHub's Actions rerun API requires actions:write, which rides with WRITE+ repo access, so
120
+ // repositoryPermission is a proxy for account-level rerun capability (see canRerunWorkflows) —
121
+ // it does not confirm the credential executing `gh` has that scope; an unauthorized rerun
122
+ // simply fails when the agent runs it, the same residual risk as every other CLI-recommended
123
+ // git/gh mutation in this codebase.
149
124
  const cancelled = [];
150
125
  const baseLookup = validateBaseBranch(report.baseBranch);
151
126
  const threads = report.threads.actionable.map(toAgentThread);
152
127
  const resolutionOnlyThreads = report.threads.resolutionOnly;
153
128
  const actionableComments = report.comments.actionable.map(toAgentComment);
154
- const failingAgentChecks = toAgentChecks(failingChecks);
129
+ const rerunAuthorized = canRerunWorkflows(report.viewerAuthorization);
130
+ // A workflow run can only be rerun once it has fully completed; a runId still present among
131
+ // in-progress checks (a sibling job from the same run) is not yet eligible.
132
+ const inProgressWorkflowRunIds = new Set(report.checks.inProgress.flatMap((c) => (c.runId !== null ? [c.runId] : [])));
133
+ // Confirmed GitHub Actions provenance for a runId: `source: "startup_failure"` checks are
134
+ // fetched directly from the Actions REST API (always genuine), while `source: "check_run"`
135
+ // checks need `workflowName` — derived from the same `checkSuite.workflowRun.workflow` GraphQL
136
+ // path as the run's numeric ID — because a third-party GitHub App's CheckRun can carry a
137
+ // details-URL-parsed runId that merely looks like an Actions run number.
138
+ const actionsRunIds = new Set(failingChecks.flatMap((c) => c.runId !== null && (c.source === "startup_failure" || c.workflowName !== undefined)
139
+ ? [c.runId]
140
+ : []));
141
+ const failingAgentChecks = toAgentChecks(failingChecks).map((c) => rerunAuthorized &&
142
+ c.runId &&
143
+ actionsRunIds.has(c.runId) &&
144
+ // ACTION_REQUIRED means the run is paused pending manual workflow approval; rerunning does
145
+ // not grant that approval, so no rerun command applies.
146
+ c.conclusion !== "ACTION_REQUIRED" &&
147
+ !inProgressWorkflowRunIds.has(c.runId)
148
+ ? { ...c, rerunCommand: `gh run rerun ${c.runId} -R ${report.repo}` }
149
+ : c);
155
150
  const checks = [
156
151
  ...failingAgentChecks,
157
152
  ...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
158
153
  ];
159
154
  const { changesRequestedReviews } = report;
155
+ const actionableChangesRequestedReviews = changesRequestedReviews.filter((review) => review.staleReview !== true ||
156
+ !isHumanAuthor(review) ||
157
+ isConfiguredBotAuthor(review, botUsernames));
158
+ const skippedDismissalIds = new Set(unauthorizedDismissals.map((review) => review.id));
159
+ const changesRequestedReviewsForWork = actionableChangesRequestedReviews.filter((review) => !skippedDismissalIds.has(review.id));
160
+ const resolutionOnlyThreadsForWork = resolutionOnlyThreads.filter((thread) => !skippedThreadIds.has(thread.id) && thread.path !== null && thread.line !== null);
160
161
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
161
162
  const isBehind = report.mergeStatus.status === "BEHIND";
162
163
  const { behindBaseHint } = loadConfig().iterate;
@@ -166,23 +167,57 @@ export async function handleFixCode(ctx) {
166
167
  const inProgressRunIds = [];
167
168
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
168
169
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
169
- // Conflict resolution necessarily changes the branch head, but Shepherd cannot verify
170
- // authorization for the local Git credential that would publish it. Defer review mutations
171
- // until an authorized push updates the PR and a fresh iteration can rebuild SHA-safe commands.
172
- const { resolveCommand, resolveOnlyCommand } = hasConflicts
173
- ? buildResolveCommand([], [], [], [], [], prReference, botUsernames, [], undefined, [])
174
- : buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads);
170
+ const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
171
+ const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
172
+ const hasAutonomousWork = hasConflicts ||
173
+ threads.length > 0 ||
174
+ resolutionOnlyThreads.length > 0 ||
175
+ actionableComments.length > 0 ||
176
+ commentMinimizeIds.length > 0 ||
177
+ actionableChangesRequestedReviews.length > 0 ||
178
+ reviewSummaryIds.length > 0 ||
179
+ firstLookSummaries.length > 0 ||
180
+ editedSummaries.length > 0 ||
181
+ report.threads.firstLook.length > 0 ||
182
+ report.comments.firstLook.length > 0 ||
183
+ checks.some((check) => (check.annotations?.length ?? 0) > 0) ||
184
+ failingAgentChecks.some((check) => belongsToActiveWorkflowRun(check) || !checkRequiresHumanFollowUp(check));
185
+ if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
186
+ const checkEscalateBase = {
187
+ triggers: ["check-follow-up-unavailable"],
188
+ unresolvedThreads: [],
189
+ ambiguousComments: [],
190
+ changesRequestedReviews,
191
+ checks: manualFollowUpChecks,
192
+ suggestion: buildEscalateSuggestion(["check-follow-up-unavailable"]),
193
+ };
194
+ return {
195
+ ...base,
196
+ action: "escalate",
197
+ escalate: {
198
+ ...checkEscalateBase,
199
+ humanMessage: buildEscalateHumanMessage(checkEscalateBase, prReference, {
200
+ merge: opts.merge,
201
+ }),
202
+ },
203
+ };
204
+ }
205
+ // Push access to the PR head branch is a usage precondition. Build review mutations for
206
+ // conflict ticks normally so the caller can push and complete the same fix_code cycle.
207
+ const retryableActionableIds = new Set(retryableActionableThreads.map((thread) => thread.id));
208
+ const retryableAgentThreads = threads.filter((thread) => retryableActionableIds.has(thread.id));
209
+ const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(retryableAgentThreads, resolutionOnlyThreadsForWork, allCommentIds, changesRequestedReviewsForWork, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads);
175
210
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
176
211
  // would need the correct base to rebase safely. This is a conservative guard, not a
177
212
  // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
178
213
  // resolution-only threads also need a known base in case the agent does push.
179
- const pushIsPlausible = threads.length > 0 ||
214
+ const pushIsPlausible = retryableActionableThreads.length > 0 ||
180
215
  failingAgentChecks.length > 0 ||
181
216
  annotatedExtra.length > 0 ||
182
217
  hasConflicts ||
183
- changesRequestedReviews.length > 0 ||
218
+ changesRequestedReviewsForWork.length > 0 ||
184
219
  actionableComments.length > 0 ||
185
- resolutionOnlyThreads.length > 0;
220
+ resolutionOnlyThreadsForWork.length > 0;
186
221
  if (baseLookup.isFallback && pushIsPlausible) {
187
222
  const fallbackEscalateBase = {
188
223
  triggers: ["base-branch-unknown"],
@@ -196,7 +231,9 @@ export async function handleFixCode(ctx) {
196
231
  action: "escalate",
197
232
  escalate: {
198
233
  ...fallbackEscalateBase,
199
- humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prReference),
234
+ humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prReference, {
235
+ merge: opts.merge,
236
+ }),
200
237
  },
201
238
  };
202
239
  }
@@ -14,7 +14,11 @@ import { checksWithActionableAnnotations } from "../check-annotations.mjs";
14
14
  import { buildReadyMergeResult, handleActiveMergeState } from "./merge-state.mjs";
15
15
  import { buildIterateBase } from "./base.mjs";
16
16
  import { markReadyIfAuthorized } from "./mark-ready.mjs";
17
- export async function runIterate(opts) {
17
+ import { withIterateApiUsage } from "./run.mjs";
18
+ export function runIterate(opts) {
19
+ return withIterateApiUsage(opts, () => runIterateCore(opts));
20
+ }
21
+ async function runIterateCore(opts) {
18
22
  const config = loadConfig();
19
23
  const botUsernames = normalizeBotUsernames(config.botUsernames);
20
24
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
@@ -48,8 +52,7 @@ export async function runIterate(opts) {
48
52
  seen: report.reviewSummaries,
49
53
  edited: report.editedSummaries,
50
54
  }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly], report.ruleAutoResolveReviewSummaryIds);
51
- // Already-seen review summaries have no new content to surface — minimize them
52
- // in-process so they never register as agent-facing actionable work (#313).
55
+ // Minimize already-seen review summaries in-process so they never become agent-facing work.
53
56
  // GitHub can still return a null/error/rate-limit result per ID without
54
57
  // throwing (autoMinimizeComments reports this via `errors`, not a rejection);
55
58
  // any ID it did not confirm minimized falls back into the agent-facing set so
@@ -78,8 +81,8 @@ export async function runIterate(opts) {
78
81
  editedSummaries.length > 0 ||
79
82
  (config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
80
83
  const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
81
- const isCleanReadyHandoff = report.status === "READY" && !hasActionableWork && !activeMerge;
82
- const readyState = await updateReadyDelay(report.pr, isCleanReadyHandoff, readyDelaySeconds, repoOwner, repoName);
84
+ const isCleanReadyState = report.status === "READY" && !hasActionableWork && !activeMerge;
85
+ const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
83
86
  const base = buildIterateBase(report, readyState);
84
87
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
85
88
  if (hasActionableWork) {
@@ -7,7 +7,10 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
7
7
  return null;
8
8
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
9
9
  report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
10
- if (!queue && report.viewerAuthorization?.viewerCanEnableAutoMerge === true) {
10
+ // A merge queue is enrolled the same way auto-merge is granted: enabling
11
+ // auto-merge on a queue-required PR is what adds it to the queue, so
12
+ // viewerCanEnableAutoMerge authorizes both the plain-merge and enqueue paths.
13
+ if (report.viewerAuthorization?.viewerCanEnableAutoMerge === true) {
11
14
  return {
12
15
  ...base,
13
16
  action: "merge",
@@ -16,7 +19,7 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
16
19
  repo: report.repo,
17
20
  nodeId: report.nodeId,
18
21
  headSha: report.headSha ?? "unknown",
19
- queue: false,
22
+ queue,
20
23
  }),
21
24
  };
22
25
  }
@@ -13,6 +13,8 @@ export function renderResolveCommand(rc) {
13
13
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
14
  isBehind = false, viewerCanUpdate = false) {
15
15
  const instructions = [];
16
+ const locatedThreads = threads.filter((thread) => thread.path !== null && thread.line !== null);
17
+ const unlocatedThreads = threads.filter((thread) => thread.path === null || thread.line === null);
16
18
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
17
19
  const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
18
20
  const hasNonConflictHints = threads.length > 0 ||
@@ -23,8 +25,10 @@ isBehind = false, viewerCanUpdate = false) {
23
25
  // Start with interpretation. The agent decides what raw feedback warrants a code change.
24
26
  if (hasNonConflictHints) {
25
27
  const actionableSections = [];
26
- if (threads.length > 0)
28
+ if (locatedThreads.length > 0)
27
29
  actionableSections.push("`## Review threads`");
30
+ if (unlocatedThreads.length > 0)
31
+ actionableSections.push("`## Unlocated review threads (logged once — no mutation)`");
28
32
  if (actionableComments.length > 0)
29
33
  actionableSections.push("`## Actionable comments`");
30
34
  if (failingChecks.length > 0)
@@ -53,17 +57,20 @@ isBehind = false, viewerCanUpdate = false) {
53
57
  if (editedTotal > 0) {
54
58
  instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
55
59
  }
60
+ if (unlocatedThreads.length > 0) {
61
+ instructions.push("Acknowledge each item under `## Unlocated review threads (logged once — no mutation)`. Shepherd cannot route a code fix or review mutation without a path and line; the unchanged item will be skipped on later ticks.");
62
+ }
56
63
  // GitHub exposes no exact viewer capability for workflow-run cancellation, so the
57
64
  // informational run lists never produce a cancellation recommendation.
58
65
  void inProgressRunIds;
59
66
  void cancelledCount;
60
- const hasSuggestions = threads.some((t) => t.suggestion);
67
+ const hasSuggestions = locatedThreads.some((t) => t.suggestion);
61
68
  if (hasSuggestions)
62
69
  instructions.push(buildCommitSuggestionInstruction(prReference, "## Review threads"));
63
- if (threads.length > 0 || actionableComments.length > 0) {
70
+ if (locatedThreads.length > 0 || actionableComments.length > 0) {
64
71
  // Actionable comments carry no file/line location (unlike threads), so "referenced above"
65
72
  // is only accurate when threads are present.
66
- const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
73
+ const filesRef = locatedThreads.length > 0 ? "each file referenced above" : "the relevant files";
67
74
  instructions.push(`Apply every warranted review fix in ${filesRef}.`);
68
75
  }
69
76
  if (resolutionOnlyThreads.length > 0) {
@@ -81,10 +88,10 @@ isBehind = false, viewerCanUpdate = false) {
81
88
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
82
89
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
83
90
  if (hasConflicts) {
84
- instructions.push(`Commit any remaining conflict-resolution changes${mutationSuffix}.`);
91
+ instructions.push(`Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`);
85
92
  }
86
93
  else if (hasNonConflictHints) {
87
- instructions.push("If you changed code, commit any remaining changes, then stop and hand off for a push whose authorization is established outside Shepherd; do not run the remaining review mutations or iterate until the remote PR head changes. Shepherd cannot verify the Git credential's push authorization. If you did not change code, do not commit and continue with the remaining steps.");
94
+ 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 again with the same options. If you did not change code, do not commit and continue with the remaining steps.");
88
95
  }
89
96
  if (viewerCanUpdate &&
90
97
  (hasReviewMutations ||
@@ -0,0 +1,2 @@
1
+ import type { IterateCommandOptions, IterateResult } from "../../types.mts";
2
+ export declare function withIterateApiUsage(opts: IterateCommandOptions, runCore: () => Promise<IterateResult>): Promise<IterateResult>;
@@ -0,0 +1,8 @@
1
+ import { withApiTelemetryScope } from "../../github/api-telemetry.mjs";
2
+ import { attachApiUsage } from "./api-usage.mjs";
3
+ export function withIterateApiUsage(opts, runCore) {
4
+ return withApiTelemetryScope(async () => {
5
+ const result = await runCore();
6
+ return attachApiUsage(result, opts.deferQuotaWarning !== true);
7
+ });
8
+ }
@@ -3,6 +3,14 @@ import { graphql, getCurrentPrNumber, getRepoInfo } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
4
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
5
5
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
+ _shepherdRateLimit: rateLimit {
7
+ cost
8
+ limit
9
+ nodeCount
10
+ remaining
11
+ resetAt
12
+ used
13
+ }
6
14
  repository(owner: $owner, name: $repo) {
7
15
  pullRequest(number: $pr) {
8
16
  id
@@ -0,0 +1,2 @@
1
+ import type { IterateResult } from "../types.mts";
2
+ export declare function withPollApiUsage(runCore: () => Promise<IterateResult>, preservePersistedWarning: boolean): Promise<IterateResult>;
@@ -0,0 +1,8 @@
1
+ import { withApiTelemetryScope } from "../github/api-telemetry.mjs";
2
+ import { attachApiUsage } from "./iterate/api-usage.mjs";
3
+ export function withPollApiUsage(runCore, preservePersistedWarning) {
4
+ return withApiTelemetryScope(async () => {
5
+ const result = await runCore();
6
+ return attachApiUsage(result, true, preservePersistedWarning);
7
+ });
8
+ }
@@ -1,5 +1,5 @@
1
1
  import type { IterateCommandOptions, IterateResult } from "../types.mts";
2
- interface PollCommandOptions extends IterateCommandOptions {
2
+ export interface PollCommandOptions extends IterateCommandOptions {
3
3
  intervalSeconds: number;
4
4
  timeoutSeconds: number;
5
5
  /** Settle window after first FIX_CODE before returning. Default 60. 0 disables. */
@@ -9,4 +9,3 @@ interface PollCommandOptions extends IterateCommandOptions {
9
9
  }
10
10
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
11
11
  export declare function runPoll(opts: PollCommandOptions): Promise<IterateResult>;
12
- export {};
@@ -1,5 +1,6 @@
1
1
  import { runIterate } from "./iterate/index.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
+ import { withPollApiUsage } from "./poll-run.mjs";
3
4
  const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
4
5
  function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
5
6
  if (verbose) {
@@ -61,7 +62,10 @@ function writeDebounceProgress(tick, elapsedMs, remainingMs) {
61
62
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] FIX_CODE — debounce ${remainingSeconds}s remaining\n`);
62
63
  }
63
64
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
64
- export async function runPoll(opts) {
65
+ export function runPoll(opts) {
66
+ return withPollApiUsage(() => runPollCore(opts), opts.untilTerminal === true);
67
+ }
68
+ async function runPollCore(opts) {
65
69
  const { intervalSeconds, timeoutSeconds, debounceSeconds: debounceSecondsOpt, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
66
70
  const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
67
71
  const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
@@ -74,11 +78,8 @@ export async function runPoll(opts) {
74
78
  const quietStatus = quietStatusOpt === true;
75
79
  const untilTerminal = untilTerminalOpt === true;
76
80
  let lastWaitSignature = null;
77
- // When prNumber is omitted, iterateOpts.prNumber starts undefined and each tick would otherwise
78
- // re-infer the PR from the current branch. That inference query only matches OPEN PRs, so once
79
- // the monitored PR merges it returns nothing and runIterate throws instead of reporting the
80
- // terminal CANCEL/merged result. Pin the PR resolved by the first tick so later ticks target it
81
- // directly and never re-run branch discovery.
81
+ let pendingQuotaWarning;
82
+ // Pin the PR resolved by the first tick; branch inference only matches OPEN PRs.
82
83
  let prNumber = opts.prNumber;
83
84
  let debounceUntil = null;
84
85
  while (true) {
@@ -88,10 +89,28 @@ export async function runPoll(opts) {
88
89
  ...iterateOpts,
89
90
  prNumber,
90
91
  persistSeen: debounceSeconds === 0 || pastDebounce,
92
+ // Until-terminal must persist before deciding to return.
93
+ deferQuotaWarning: !untilTerminal,
91
94
  });
92
95
  prNumber ??= lastResult.pr;
96
+ if (lastResult.quotaWarning !== undefined)
97
+ pendingQuotaWarning = lastResult.quotaWarning;
98
+ if (untilTerminal &&
99
+ pendingQuotaWarning !== undefined &&
100
+ !(lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) &&
101
+ !(debounceUntil !== null && !pastDebounce)) {
102
+ if (["cancel", "escalate"].includes(lastResult.action)) {
103
+ const { quotaWarning: _quotaWarning, ...withoutQuotaWarning } = lastResult;
104
+ lastResult = withoutQuotaWarning;
105
+ }
106
+ else if (lastResult.quotaWarning === undefined) {
107
+ lastResult = { ...lastResult, quotaWarning: pendingQuotaWarning };
108
+ }
109
+ break;
110
+ }
93
111
  if (lastResult.action === "wait" && !pastDebounce) {
94
- debounceUntil = null;
112
+ if (pendingQuotaWarning === undefined)
113
+ debounceUntil = null;
95
114
  const elapsedMs = Date.now() - start;
96
115
  if (!untilTerminal) {
97
116
  const remainingMs = timeoutMs - elapsedMs;
@@ -126,9 +145,6 @@ export async function runPoll(opts) {
126
145
  writeDebounceProgress(tick, Date.now() - start, remainingMs);
127
146
  await sleep(Math.min(intervalMs, remainingMs));
128
147
  }
129
- // Always continue so a tick that *started* before debounceUntil but *finished*
130
- // after it cannot return with persistSeen: false. The next loop iteration
131
- // sees pastDebounce and persists.
132
148
  continue;
133
149
  }
134
150
  break;
@@ -2,15 +2,19 @@ export interface ResolveRateLimitStop {
2
2
  message: string;
3
3
  retryAfterSeconds?: number;
4
4
  limit?: number;
5
+ used?: number;
5
6
  remaining?: number;
6
7
  resetAt?: number;
8
+ resource?: string;
7
9
  }
8
10
  export declare function rateLimitFromError(err: unknown, fallbackMessage: string): ResolveRateLimitStop | null;
9
11
  export declare function rateLimitFromGraphQlResult(messages: string[], meta: {
10
12
  rateLimit?: {
11
13
  remaining?: unknown;
12
14
  limit?: unknown;
15
+ used?: unknown;
13
16
  resetAt?: unknown;
17
+ resource?: unknown;
14
18
  };
15
19
  retryAfterSeconds?: unknown;
16
20
  stopOnZeroRemaining?: boolean;