pr-shepherd 0.45.0 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +20 -9
  3. package/bin/api.d.mts +2 -2
  4. package/bin/checks/conclusions.d.mts +0 -16
  5. package/bin/checks/conclusions.mjs +0 -27
  6. package/bin/cli/fix-formatter.mjs +10 -4
  7. package/bin/cli/help-command-pages.d.mts +16 -5
  8. package/bin/cli/help-command-pages.mjs +16 -5
  9. package/bin/cli/help-top-page.d.mts +1 -1
  10. package/bin/cli/help-top-page.mjs +3 -1
  11. package/bin/cli/help.d.mts +17 -6
  12. package/bin/cli/help.mjs +2 -0
  13. package/bin/cli/iterate-checks-formatter.d.mts +2 -0
  14. package/bin/cli/iterate-checks-formatter.mjs +55 -0
  15. package/bin/cli/iterate-formatter.mjs +7 -2
  16. package/bin/cli/iterate-lean.mjs +4 -0
  17. package/bin/cli/journal-extract-handler.d.mts +2 -0
  18. package/bin/cli/journal-extract-handler.mjs +48 -0
  19. package/bin/cli/mark-files-as-viewed-formatter.mjs +7 -6
  20. package/bin/cli/safe-body-file.d.mts +14 -0
  21. package/bin/cli/safe-body-file.mjs +42 -0
  22. package/bin/cli-parser.mjs +7 -0
  23. package/bin/commands/check-status.mjs +4 -4
  24. package/bin/commands/check.mjs +25 -5
  25. package/bin/commands/iterate/api-usage.mjs +1 -8
  26. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  27. package/bin/commands/iterate/check-instructions.mjs +8 -32
  28. package/bin/commands/iterate/classify.mjs +2 -1
  29. package/bin/commands/iterate/escalate.mjs +63 -9
  30. package/bin/commands/iterate/fix-code.mjs +83 -80
  31. package/bin/commands/iterate/index.mjs +2 -2
  32. package/bin/commands/iterate/merge-state.mjs +8 -37
  33. package/bin/commands/iterate/merge.mjs +1 -0
  34. package/bin/commands/iterate/render.d.mts +1 -1
  35. package/bin/commands/iterate/render.mjs +16 -13
  36. package/bin/commands/journal/index.d.mts +1 -0
  37. package/bin/commands/journal/index.mjs +1 -10
  38. package/bin/commands/mark-files-as-viewed.d.mts +2 -1
  39. package/bin/commands/mark-files-as-viewed.mjs +116 -4
  40. package/bin/commands/poll.mjs +1 -4
  41. package/bin/commands/resolve-mutate.mjs +17 -40
  42. package/bin/comments/resolve.d.mts +4 -0
  43. package/bin/comments/resolve.mjs +10 -4
  44. package/bin/comments/review-visibility.d.mts +1 -1
  45. package/bin/comments/review-visibility.mjs +3 -2
  46. package/bin/comments/thread-visibility.d.mts +1 -1
  47. package/bin/comments/thread-visibility.mjs +9 -3
  48. package/bin/github/client.d.mts +1 -2
  49. package/bin/github/client.mjs +1 -4
  50. package/bin/github/gql/get-pr-body.gql +0 -1
  51. package/bin/mcp/server.mjs +2 -2
  52. package/bin/pr-reference.d.mts +1 -1
  53. package/bin/pr-reference.mjs +1 -1
  54. package/bin/types/escalate.d.mts +5 -3
  55. package/bin/types/report.d.mts +1 -1
  56. package/package.json +2 -2
  57. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  58. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  59. package/plugins/pr-shepherd/.mcp.json +1 -1
  60. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +2 -2
  61. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +21 -15
@@ -7,45 +7,16 @@ 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
- // 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) {
14
- return {
15
- ...base,
16
- action: "merge",
17
- merge: buildMergeCommandPlan({
18
- pr: report.pr,
19
- repo: report.repo,
20
- nodeId: report.nodeId,
21
- headSha: report.headSha ?? "unknown",
22
- queue,
23
- }),
24
- };
25
- }
26
- const authorizationEscalateBase = {
27
- triggers: ["authorization-required"],
28
- unresolvedThreads: [],
29
- ambiguousComments: [],
30
- changesRequestedReviews: [],
31
- authorization: [
32
- {
33
- action: "merge-or-enqueue",
34
- targetIds: [report.nodeId],
35
- reason: "denied-or-unverifiable",
36
- },
37
- ],
38
- suggestion: buildEscalateSuggestion(["authorization-required"]),
39
- };
40
10
  return {
41
11
  ...base,
42
- action: "escalate",
43
- escalate: {
44
- ...authorizationEscalateBase,
45
- humanMessage: buildEscalateHumanMessage(authorizationEscalateBase, report.pr, {
46
- merge: true,
47
- }),
48
- },
12
+ action: "merge",
13
+ merge: buildMergeCommandPlan({
14
+ pr: report.pr,
15
+ repo: report.repo,
16
+ nodeId: report.nodeId,
17
+ headSha: report.headSha ?? "unknown",
18
+ queue,
19
+ }),
49
20
  };
50
21
  }
51
22
  export async function handleActiveMergeState(input) {
@@ -38,6 +38,7 @@ export function buildMergeCommandPlan(input) {
38
38
  return {
39
39
  mode: "auto",
40
40
  command: { argv: [...base, "--auto", ...commandArgs] },
41
+ fallbackCommand: { argv: [...base, ...commandArgs] },
41
42
  };
42
43
  }
43
44
  export function renderMergeCommand(command) {
@@ -2,4 +2,4 @@ import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, Fir
2
2
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
3
3
  export declare function renderResolveCommand(rc: ResolveCommand): string;
4
4
  export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
- isBehind?: boolean, viewerCanUpdate?: boolean, pushAuthorized?: boolean): string[];
5
+ isBehind?: boolean, viewerCanUpdate?: boolean): string[];
@@ -11,8 +11,10 @@ export function renderResolveCommand(rc) {
11
11
  return renderShellCommand(parts);
12
12
  }
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
- isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
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, pushAuthorized = 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, pushAuthorized = 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,14 +88,10 @@ isBehind = false, viewerCanUpdate = false, pushAuthorized = 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(pushAuthorized
85
- ? `Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`
86
- : `Commit any remaining conflict-resolution changes${mutationSuffix}.`);
91
+ instructions.push(`Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`);
87
92
  }
88
93
  else if (hasNonConflictHints) {
89
- instructions.push(pushAuthorized
90
- ? "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."
91
- : "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.");
92
95
  }
93
96
  if (viewerCanUpdate &&
94
97
  (hasReviewMutations ||
@@ -100,7 +103,7 @@ isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
100
103
  }
101
104
  if (resolveOnlyCommand?.hasMutations)
102
105
  instructions.push("Run the `resolve-only:` command shown above.");
103
- instructions.push(...buildResolveCommandInstruction(resolveCommand, pushAuthorized));
104
- instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha, pushAuthorized));
106
+ instructions.push(...buildResolveCommandInstruction(resolveCommand));
107
+ instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
105
108
  return instructions;
106
109
  }
@@ -13,6 +13,7 @@ export interface JournalResult {
13
13
  sectionExisted: boolean;
14
14
  dryRun: boolean;
15
15
  previewBody?: string;
16
+ /** @deprecated Explicit journal requests now rely on GitHub's mutation response. */
16
17
  authorizationSkipped?: "denied-or-unverifiable";
17
18
  }
18
19
  /** @deprecated Hidden implementation for standalone `journal`; use `apply journal`. */
@@ -12,17 +12,8 @@ export async function runJournal(opts) {
12
12
  if (!prNumber) {
13
13
  throw new Error("PR number is required: no PR number provided and none found for current branch");
14
14
  }
15
- const { nodeId, body, viewerCanUpdate } = await getPullRequestBody(prNumber, owner, name);
15
+ const { nodeId, body } = await getPullRequestBody(prNumber, owner, name);
16
16
  const { body: newBody, mutated, sectionExisted } = appendJournalItem(body, item);
17
- if (mutated && !opts.dryRun && viewerCanUpdate !== true) {
18
- return {
19
- prNumber,
20
- mutated: false,
21
- sectionExisted,
22
- dryRun: false,
23
- authorizationSkipped: "denied-or-unverifiable",
24
- };
25
- }
26
17
  if (mutated && !opts.dryRun) {
27
18
  await updatePullRequestBody(nodeId, newBody);
28
19
  }
@@ -1,4 +1,4 @@
1
- import type { ResolveRateLimitStop } from "../comments/rate-limit.mts";
1
+ import { type ResolveRateLimitStop } from "../comments/rate-limit.mts";
2
2
  import type { GlobalOptions } from "../types.mts";
3
3
  export interface MarkFilesAsViewedOptions extends GlobalOptions {
4
4
  prNumber?: number;
@@ -21,6 +21,7 @@ export interface MarkFilesAsViewedResult {
21
21
  errors: string[];
22
22
  rateLimit?: ResolveRateLimitStop;
23
23
  unmarkedPaths?: string[];
24
+ /** @deprecated Explicit file-view requests are attempted; GitHub authorizes the mutation. */
24
25
  authorizationSkipped?: "unverifiable";
25
26
  }
26
27
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable max-lines */
2
- import { graphql, getCurrentPrNumber, getRepoInfo } from "../github/client.mjs";
2
+ import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
+ import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
4
5
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
5
6
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
7
  _shepherdRateLimit: rateLimit {
@@ -29,6 +30,8 @@ const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr
29
30
  }
30
31
  }`;
31
32
  const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|(^|\/)tests?\.rs$/i;
33
+ // Keep mutation batches small so rate-limit stops leave an ordered pending list.
34
+ const MARK_FILES_CHUNK_SIZE = 10;
32
35
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
33
36
  export async function runMarkFilesAsViewed(opts) {
34
37
  const repo = opts.targetRepository ?? (await getRepoInfo());
@@ -58,12 +61,121 @@ export async function runMarkFilesAsViewed(opts) {
58
61
  missingPaths: selected.missingPaths,
59
62
  unmatchedSelectors: selected.unmatchedSelectors,
60
63
  errors: [],
61
- ...(selected.pathsToMark.length > 0 && { authorizationSkipped: "unverifiable" }),
62
64
  };
63
- // GitHub exposes no exact viewer capability for markFileAsViewed. Repository role and
64
- // viewerCanEditFiles describe different operations, so this command fails closed.
65
+ await markFilesAsViewed(fetched.pullRequestId, selected.pathsToMark, result);
65
66
  return result;
66
67
  }
68
+ async function markFilesAsViewed(pullRequestId, paths, result) {
69
+ for (let offset = 0; offset < paths.length; offset += MARK_FILES_CHUNK_SIZE) {
70
+ const chunk = paths.slice(offset, offset + MARK_FILES_CHUNK_SIZE);
71
+ // eslint-disable-next-line no-await-in-loop
72
+ const pendingPaths = await markFilesChunk(pullRequestId, chunk, result, offset + MARK_FILES_CHUNK_SIZE < paths.length);
73
+ if (pendingPaths === null)
74
+ continue;
75
+ const unmarkedPaths = [...pendingPaths, ...paths.slice(offset + chunk.length)];
76
+ if (unmarkedPaths.length > 0)
77
+ result.unmarkedPaths = unmarkedPaths;
78
+ return;
79
+ }
80
+ }
81
+ /** Returns pending paths when a rate limit stops further mutation batches. */
82
+ async function markFilesChunk(pullRequestId, paths, result, hasPendingAfter) {
83
+ try {
84
+ const response = await graphqlWithRateLimit(buildMarkFilesMutation(pullRequestId, paths), {}, { allowPartialData: true });
85
+ const errors = (response.errors ?? []);
86
+ const rateLimit = rateLimitFromGraphQlResult(errors.map((error) => error.message), {
87
+ rateLimit: response.rateLimit,
88
+ retryAfterSeconds: response.retryAfterSeconds,
89
+ stopOnZeroRemaining: hasPendingAfter,
90
+ });
91
+ const classifiedErrors = classifyMarkFileErrors(errors, paths.length);
92
+ const pendingPaths = recordMarkFileResults(paths, response.data, errors, classifiedErrors, rateLimit, result);
93
+ return completeMarkFilesChunk(rateLimit, pendingPaths, result);
94
+ }
95
+ catch (error) {
96
+ return recordMarkFilesTransportError(error, paths, result);
97
+ }
98
+ }
99
+ function classifyMarkFileErrors(errors, pathCount) {
100
+ const aliasesWithNonRateErrors = new Set();
101
+ const aliasesWithRateLimitErrors = new Set();
102
+ const unscopedNonRateMessages = [];
103
+ for (const error of errors) {
104
+ const aliasIndex = markFileErrorAliasIndex(error);
105
+ if (aliasIndex === undefined || aliasIndex >= pathCount) {
106
+ if (!isRateLimitMessage(error.message))
107
+ unscopedNonRateMessages.push(error.message);
108
+ continue;
109
+ }
110
+ if (isRateLimitMessage(error.message))
111
+ aliasesWithRateLimitErrors.add(aliasIndex);
112
+ else
113
+ aliasesWithNonRateErrors.add(aliasIndex);
114
+ }
115
+ return { aliasesWithNonRateErrors, aliasesWithRateLimitErrors, unscopedNonRateMessages };
116
+ }
117
+ function recordMarkFileResults(paths, data, errors, classifiedErrors, rateLimit, result) {
118
+ const pendingPaths = [];
119
+ for (let index = 0; index < paths.length; index += 1) {
120
+ const path = paths[index];
121
+ if (data[`f${index}`] != null) {
122
+ result.markedPaths.push(path);
123
+ continue;
124
+ }
125
+ const messages = errorsForMarkFileAlias(errors, index);
126
+ const hasRateLimitError = classifiedErrors.aliasesWithRateLimitErrors.has(index);
127
+ const nonRateMessages = classifiedErrors.aliasesWithNonRateErrors.has(index)
128
+ ? messages.filter((message) => !isRateLimitMessage(message))
129
+ : classifiedErrors.unscopedNonRateMessages;
130
+ if (nonRateMessages.length > 0) {
131
+ for (const message of nonRateMessages)
132
+ result.errors.push(`${path}: ${message}`);
133
+ }
134
+ else if (!rateLimit) {
135
+ result.errors.push(`${path}: ${messages[0] ?? "mark returned null"}`);
136
+ }
137
+ if (rateLimit && (hasRateLimitError || nonRateMessages.length === 0))
138
+ pendingPaths.push(path);
139
+ }
140
+ return pendingPaths;
141
+ }
142
+ function completeMarkFilesChunk(rateLimit, pendingPaths, result) {
143
+ if (!rateLimit)
144
+ return null;
145
+ result.errors.push(`rate limit: ${rateLimit.message}`);
146
+ result.rateLimit = rateLimit;
147
+ return pendingPaths;
148
+ }
149
+ function recordMarkFilesTransportError(error, paths, result) {
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ const stop = rateLimitFromError(error, message);
152
+ if (stop) {
153
+ result.errors.push(`rate limit: ${stop.message}`);
154
+ result.rateLimit = stop;
155
+ return paths;
156
+ }
157
+ for (const path of paths)
158
+ result.errors.push(`${path}: ${message}`);
159
+ return null;
160
+ }
161
+ function buildMarkFilesMutation(pullRequestId, paths) {
162
+ const operations = paths.map((path, index) => ` f${index}: markFileAsViewed(input: { pullRequestId: ${JSON.stringify(pullRequestId)}, path: ${JSON.stringify(path)} }) { clientMutationId }`);
163
+ return `mutation MarkFilesAsViewed {\n${operations.join("\n")}\n}`;
164
+ }
165
+ function markFileErrorAliasIndex(error) {
166
+ if (!Array.isArray(error.path))
167
+ return undefined;
168
+ const alias = error.path.find((part) => typeof part === "string" && /^f\d+$/.test(part));
169
+ if (typeof alias !== "string")
170
+ return undefined;
171
+ const index = Number.parseInt(alias.slice(1), 10);
172
+ return Number.isNaN(index) ? undefined : index;
173
+ }
174
+ function errorsForMarkFileAlias(errors, index) {
175
+ return errors
176
+ .filter((error) => markFileErrorAliasIndex(error) === index)
177
+ .map((error) => error.message);
178
+ }
67
179
  async function fetchPullRequestFiles(pr, repo) {
68
180
  const first = await graphql(FILES_QUERY, {
69
181
  owner: repo.owner,
@@ -99,10 +99,7 @@ async function runPollCore(opts) {
99
99
  pendingQuotaWarning !== undefined &&
100
100
  !(lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) &&
101
101
  !(debounceUntil !== null && !pastDebounce)) {
102
- const terminalOrHandoff = ["cancel", "escalate"].includes(lastResult.action) ||
103
- (lastResult.action === "fix_code" &&
104
- lastResult.fix.instructions.some((instruction) => /stop polling|human direction/i.test(instruction)));
105
- if (terminalOrHandoff) {
102
+ if (["cancel", "escalate"].includes(lastResult.action)) {
106
103
  const { quotaWarning: _quotaWarning, ...withoutQuotaWarning } = lastResult;
107
104
  lastResult = withoutQuotaWarning;
108
105
  }
@@ -27,45 +27,24 @@ export async function runResolveMutate(opts) {
27
27
  const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
28
28
  .filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
29
29
  .map((r) => r.id));
30
- const replyAuthorizedIds = new Set(data.reviewThreads
31
- .filter((thread) => thread.viewerCanReply === true)
32
- .map((thread) => thread.id));
33
- const resolveAuthorizedIds = new Set(data.reviewThreads
34
- .filter((thread) => thread.viewerCanResolve === true)
35
- .map((thread) => thread.id));
36
- const minimizeAuthorizedIds = new Set([
37
- ...data.comments
38
- .filter((comment) => comment.viewerCanMinimize === true)
39
- .map((comment) => comment.id),
40
- ...data.reviewSummaries
41
- .filter((review) => review.viewerCanMinimize === true)
42
- .map((review) => review.id),
43
- ...data.approvedReviews
44
- .filter((review) => review.viewerCanMinimize === true)
45
- .map((review) => review.id),
46
- ]);
47
- const requestedReplyIds = new Set((opts.replyThreadIds ?? []).filter((id) => replyAuthorizedIds.has(id)));
30
+ // Iterate uses viewer capability fields while deciding which commands to
31
+ // print. Once a caller explicitly runs apply, GitHub's mutation response is
32
+ // authoritative and this path must not second-guess that intent.
33
+ const requestedReplyIds = new Set(opts.replyThreadIds ?? []);
48
34
  const allowedViewerHumanResolveIds = new Set(data.reviewThreads
49
35
  .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) &&
50
36
  (requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread)))
51
37
  .map((thread) => thread.id));
52
- const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => (!humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id)) &&
53
- resolveAuthorizedIds.has(id));
38
+ const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id));
54
39
  const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !allowedViewerHumanResolveIds.has(id));
55
- const skippedUnauthorizedResolves = (opts.resolveThreadIds ?? []).filter((id) => (!humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id)) &&
56
- !resolveAuthorizedIds.has(id));
57
- const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id) && replyAuthorizedIds.has(id));
40
+ const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
58
41
  const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
59
- const skippedUnauthorizedReplies = (opts.replyThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !replyAuthorizedIds.has(id));
60
- const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id) && minimizeAuthorizedIds.has(id));
42
+ const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
61
43
  const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
62
- const skippedUnauthorizedMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id) && !minimizeAuthorizedIds.has(id));
63
- const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) &&
64
- data.changesRequestedReviews.some((review) => review.id === id) &&
65
- data.viewerAuthorization?.viewerCanAdminister === true);
44
+ const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && data.changesRequestedReviews.some((review) => review.id === id));
66
45
  const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
67
- const skippedUnauthorizedDismissals = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && !dismissReviewIds.includes(id));
68
- const hasAuthorizedMutation = resolveThreadIds.length > 0 ||
46
+ const skippedIneligibleDismissals = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && !dismissReviewIds.includes(id));
47
+ const hasMutation = resolveThreadIds.length > 0 ||
69
48
  (replyThreadIds?.length ?? 0) > 0 ||
70
49
  minimizeCommentIds.length > 0 ||
71
50
  dismissReviewIds.length > 0;
@@ -75,7 +54,7 @@ export async function runResolveMutate(opts) {
75
54
  minimizeCommentIds,
76
55
  dismissReviewIds,
77
56
  dismissMessage: opts.dismissMessage,
78
- requireSha: hasAuthorizedMutation ? opts.requireSha : undefined,
57
+ requireSha: hasMutation ? opts.requireSha : undefined,
79
58
  });
80
59
  if (skippedHumanResolves.length > 0)
81
60
  result.skippedHumanResolves = skippedHumanResolves;
@@ -85,14 +64,12 @@ export async function runResolveMutate(opts) {
85
64
  result.skippedHumanDismissals = skippedHumanDismissals;
86
65
  if (skippedNonHumanReplies.length > 0)
87
66
  result.skippedNonHumanReplies = skippedNonHumanReplies;
88
- if (skippedUnauthorizedReplies.length > 0)
89
- result.skippedUnauthorizedReplies = skippedUnauthorizedReplies;
90
- if (skippedUnauthorizedResolves.length > 0)
91
- result.skippedUnauthorizedResolves = skippedUnauthorizedResolves;
92
- if (skippedUnauthorizedMinimizes.length > 0)
93
- result.skippedUnauthorizedMinimizes = skippedUnauthorizedMinimizes;
94
- if (skippedUnauthorizedDismissals.length > 0)
95
- result.skippedUnauthorizedDismissals = skippedUnauthorizedDismissals;
67
+ if (skippedIneligibleDismissals.length > 0) {
68
+ result.skippedDismissals = [
69
+ ...(result.skippedDismissals ?? []),
70
+ ...skippedIneligibleDismissals,
71
+ ];
72
+ }
96
73
  if (opts.dismissMessage) {
97
74
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
98
75
  await Promise.all(result.repliedThreads.map((id) => {
@@ -12,9 +12,13 @@ export interface ResolveResult {
12
12
  skippedHumanMinimizes?: string[];
13
13
  skippedHumanDismissals?: string[];
14
14
  skippedNonHumanReplies?: string[];
15
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
15
16
  skippedUnauthorizedReplies?: string[];
17
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
16
18
  skippedUnauthorizedResolves?: string[];
19
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
17
20
  skippedUnauthorizedMinimizes?: string[];
21
+ /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
18
22
  skippedUnauthorizedDismissals?: string[];
19
23
  rateLimit?: ResolveRateLimitStop;
20
24
  unrepliedThreads?: string[];
@@ -24,6 +24,12 @@ function isCommentedDismissError(message) {
24
24
  function dismissReviewNonDismissibleMessage(id) {
25
25
  return `Not dismissed: ${id} is a COMMENTED review. Use --minimize-comment-ids instead; --dismiss-review-ids is only for CHANGES_REQUESTED reviews.`;
26
26
  }
27
+ function mutationErrorMessage(errors, alias) {
28
+ const messages = errors
29
+ .filter((error) => Array.isArray(error.path) && error.path.includes(alias))
30
+ .map((error) => error.message);
31
+ return messages.length > 0 ? messages.join("; ") : undefined;
32
+ }
27
33
  export async function applyResolveOptions(pr, repo, opts) {
28
34
  const resolveThreadIds = dedupeIds(opts.resolveThreadIds ?? []);
29
35
  const replyThreadIds = dedupeIds(opts.replyThreadIds ?? []);
@@ -168,21 +174,21 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
168
174
  if (p?.comment?.id)
169
175
  result.repliedThreads.push(id);
170
176
  else if (!suppressCurrentChunkErrors)
171
- result.errors.push(`${id}: reply returned null or comment not created`);
177
+ result.errors.push(`${id}: ${mutationErrorMessage(graphQlErrors, `p${i}`) ?? "reply returned null or comment not created"}`);
172
178
  }
173
179
  for (let i = 0; i < resolveIds.length; i++) {
174
180
  const r = data[`r${i}`];
175
181
  if (r?.thread?.isResolved === true)
176
182
  result.resolvedThreads.push(resolveIds[i]);
177
183
  else if (!suppressCurrentChunkErrors)
178
- result.errors.push(`${resolveIds[i]}: resolve returned null or thread not resolved`);
184
+ result.errors.push(`${resolveIds[i]}: ${mutationErrorMessage(graphQlErrors, `r${i}`) ?? "resolve returned null or thread not resolved"}`);
179
185
  }
180
186
  for (let i = 0; i < minimizeIds.length; i++) {
181
187
  const m = data[`m${i}`];
182
188
  if (m?.minimizedComment?.isMinimized === true)
183
189
  result.minimizedComments.push(minimizeIds[i]);
184
190
  else if (!suppressCurrentChunkErrors)
185
- result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
191
+ result.errors.push(`${minimizeIds[i]}: ${mutationErrorMessage(graphQlErrors, `m${i}`) ?? "minimize returned null or comment not minimized"}`);
186
192
  }
187
193
  const singleDismiss = dismissIds.length === 1;
188
194
  const commentedDismissErrorIndexes = new Set();
@@ -204,7 +210,7 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
204
210
  else if (!suppressCurrentChunkErrors)
205
211
  result.errors.push(commentedDismissErrorIndexes.has(i) || (singleDismiss && hasUnmappedCommentedDismissError)
206
212
  ? dismissReviewNonDismissibleMessage(dismissIds[i])
207
- : `${dismissIds[i]}: dismiss returned null`);
213
+ : `${dismissIds[i]}: ${mutationErrorMessage(graphQlErrors, `d${i}`) ?? "dismiss returned null"}`);
208
214
  }
209
215
  if (rateLimitStop) {
210
216
  result.errors.push(`rate limit: ${rateLimitStop.message}`);
@@ -24,5 +24,5 @@ export declare function classifyReviewsForDisplay(reviews: Review[], seenMap: Ma
24
24
  *
25
25
  * Human-authored CR reviews continue to flow through the standard seen-gate.
26
26
  */
27
- export declare function classifyChangesRequestedReviewsForDisplay(reviews: Review[], seenMap: Map<string, SeenMarker>, botUsernames: NormalizedBotUsernames): ReviewVisibility;
27
+ export declare function classifyChangesRequestedReviewsForDisplay(reviews: Review[], seenMap: Map<string, SeenMarker>, botUsernames: NormalizedBotUsernames, repeatBotReviews?: boolean): ReviewVisibility;
28
28
  export {};
@@ -31,7 +31,7 @@ export function classifyReviewsForDisplay(reviews, seenMap) {
31
31
  *
32
32
  * Human-authored CR reviews continue to flow through the standard seen-gate.
33
33
  */
34
- export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botUsernames) {
34
+ export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botUsernames, repeatBotReviews = true) {
35
35
  const visible = [];
36
36
  const toMarkSeen = [];
37
37
  for (const review of reviews) {
@@ -39,7 +39,8 @@ export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botU
39
39
  const cls = classifyItem(review.id, review.body, seenMap);
40
40
  if (isBot) {
41
41
  if (cls === "unchanged") {
42
- visible.push({ ...review, staleBotCr: true });
42
+ if (repeatBotReviews)
43
+ visible.push({ ...review, staleBotCr: true });
43
44
  }
44
45
  else if (cls === "edited") {
45
46
  visible.push({ ...review, edited: true });
@@ -7,5 +7,5 @@ interface ThreadVisibility {
7
7
  firstLookThreads: FirstLookThread[];
8
8
  toMarkSeen: ReviewThread[];
9
9
  }
10
- export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames): ThreadVisibility;
10
+ export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames, repeatableThreadIds?: ReadonlySet<string>): ThreadVisibility;
11
11
  export {};
@@ -19,14 +19,15 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
19
19
  return null;
20
20
  return { ...visible, firstLookStatus };
21
21
  }
22
- export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set()) {
22
+ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set(), repeatableThreadIds) {
23
+ const shouldRepeat = (thread) => repeatableThreadIds?.has(thread.id) ?? true;
23
24
  const unresolvedThreads = threads.filter((t) => !t.isResolved);
24
25
  const activeThreads = unresolvedThreads
25
26
  .filter((t) => !t.isOutdated && !t.isMinimized)
26
27
  .flatMap((t) => {
27
28
  if (threadEndedByShepherd(t))
28
29
  return [];
29
- if (isConfiguredBotAuthor(t, botUsernames))
30
+ if (isConfiguredBotAuthor(t, botUsernames) && shouldRepeat(t))
30
31
  return [t];
31
32
  const visible = classifyVisibleThread(t, seenMap);
32
33
  return visible ? [visible] : [];
@@ -40,7 +41,12 @@ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Se
40
41
  }
41
42
  return t.isOutdated || t.isMinimized;
42
43
  })
43
- .map((t) => classifyVisibleThread(t, seenMap) ?? t);
44
+ .flatMap((t) => {
45
+ const visible = classifyVisibleThread(t, seenMap);
46
+ if (visible)
47
+ return [visible];
48
+ return shouldRepeat(t) ? [t] : [];
49
+ });
44
50
  const firstLookThreads = [
45
51
  ...threads.flatMap((t) => {
46
52
  if (!t.isOutdated)
@@ -30,7 +30,6 @@ export declare function getPrHeadSha(pr: number, owner: string, name: string): P
30
30
  export declare function getPullRequestBody(pr: number, owner: string, name: string): Promise<{
31
31
  nodeId: string;
32
32
  body: string;
33
- viewerCanUpdate?: boolean;
34
33
  }>;
35
34
  /** Overwrites the PR body. */
36
35
  export declare function updatePullRequestBody(pullRequestId: string, body: string): Promise<void>;
@@ -38,7 +37,7 @@ export declare function updatePullRequestBody(pullRequestId: string, body: strin
38
37
  * Fetches PR state, `mergeable`, and `mergeStateStatus` via the REST API.
39
38
  *
40
39
  * Used when the GraphQL API returns `UNKNOWN` for mergeability or before a
41
- * READY handoff. The same response carries state so a concurrent merge or
40
+ * READY state. The same response carries state so a concurrent merge or
42
41
  * close can supersede the earlier GraphQL snapshot without another request.
43
42
  */
44
43
  export declare function getMergeableState(pr: number, owner: string, repo: string): Promise<{
@@ -80,9 +80,6 @@ export async function getPullRequestBody(pr, owner, name) {
80
80
  return {
81
81
  nodeId: pullRequest.id,
82
82
  body: pullRequest.body ?? "",
83
- ...(pullRequest.viewerCanUpdate !== undefined && {
84
- viewerCanUpdate: pullRequest.viewerCanUpdate,
85
- }),
86
83
  };
87
84
  }
88
85
  /** Overwrites the PR body. */
@@ -93,7 +90,7 @@ export async function updatePullRequestBody(pullRequestId, body) {
93
90
  * Fetches PR state, `mergeable`, and `mergeStateStatus` via the REST API.
94
91
  *
95
92
  * Used when the GraphQL API returns `UNKNOWN` for mergeability or before a
96
- * READY handoff. The same response carries state so a concurrent merge or
93
+ * READY state. The same response carries state so a concurrent merge or
97
94
  * close can supersede the earlier GraphQL snapshot without another request.
98
95
  */
99
96
  export async function getMergeableState(pr, owner, repo) {
@@ -11,7 +11,6 @@ query GetPrBody($owner: String!, $repo: String!, $pr: Int!) {
11
11
  pullRequest(number: $pr) {
12
12
  id
13
13
  body
14
- viewerCanUpdate
15
14
  }
16
15
  }
17
16
  }
@@ -41,7 +41,7 @@ const reviewMutationsOperationSchema = z.object({
41
41
  const markFilesViewedOperationSchema = z.object({
42
42
  type: z.literal("mark_files_viewed"),
43
43
  files: z.array(z.string().min(1)).optional(),
44
- tests: z.boolean().optional().describe("Select changed test files; no mutation occurs."),
44
+ tests: z.boolean().optional().describe("Select changed test files to mark as viewed."),
45
45
  matchPatterns: z.array(z.string().min(1)).optional(),
46
46
  });
47
47
  const appendJournalOperationSchema = z.object({
@@ -90,7 +90,7 @@ export function createPrShepherdMcpServer(options = {}) {
90
90
  },
91
91
  }, async (input) => runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), formatIterateResult));
92
92
  server.registerTool("apply", {
93
- description: "Apply ordered authorized review and journal operations, or run selection-only file-view diagnostics, after prevalidation.",
93
+ description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
94
94
  inputSchema: applyInputSchema,
95
95
  annotations: {
96
96
  readOnlyHint: false,
@@ -15,6 +15,6 @@ export declare function parsePrReference(pr: number | string | undefined): Parse
15
15
  export declare function parseCliPrReference(value: string): ParsedPrReference | null;
16
16
  /** Converts a validated parsed reference into the command-layer target shape. */
17
17
  export declare function resolveParsedPrTarget(parsed: ParsedPrReference): ResolvedPrTarget;
18
- /** Canonical collision-safe PR reference for generated commands and handoffs. */
18
+ /** Canonical collision-safe PR reference for generated commands and escalation messages. */
19
19
  export declare function formatPrUrl(repository: string, prNumber: number): string;
20
20
  export declare function isRepositoryQualifiedPrReference(pr: unknown): pr is string;