pr-shepherd 0.43.0 → 0.44.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 (96) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +18 -17
  3. package/bin/api.d.mts +3 -2
  4. package/bin/api.mjs +13 -18
  5. package/bin/cli/args.mjs +13 -9
  6. package/bin/cli/fix-formatter.mjs +8 -6
  7. package/bin/cli/help-command-pages.d.mts +9 -6
  8. package/bin/cli/help-command-pages.mjs +7 -4
  9. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  10. package/bin/cli/help-iterate-poll-pages.mjs +2 -2
  11. package/bin/cli/help-top-page.d.mts +1 -1
  12. package/bin/cli/help-top-page.mjs +2 -2
  13. package/bin/cli/help.d.mts +10 -7
  14. package/bin/cli/iterate-formatter.d.mts +1 -1
  15. package/bin/cli/iterate-formatter.mjs +5 -5
  16. package/bin/cli/iterate-lean.mjs +4 -0
  17. package/bin/cli/journal-formatter.mjs +3 -0
  18. package/bin/cli/journal-handler.mjs +11 -7
  19. package/bin/cli/list-formatters.d.mts +3 -1
  20. package/bin/cli/list-formatters.mjs +9 -7
  21. package/bin/cli/mark-files-as-viewed-formatter.mjs +7 -1
  22. package/bin/cli/mutate-formatter.mjs +4 -0
  23. package/bin/commands/check.mjs +22 -6
  24. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  25. package/bin/commands/commit-suggestion-instruction.mjs +2 -2
  26. package/bin/commands/commit-suggestion.mjs +3 -1
  27. package/bin/commands/iterate/check-instructions.d.mts +5 -19
  28. package/bin/commands/iterate/check-instructions.mjs +24 -25
  29. package/bin/commands/iterate/classify.d.mts +2 -2
  30. package/bin/commands/iterate/classify.mjs +43 -25
  31. package/bin/commands/iterate/escalate.d.mts +1 -1
  32. package/bin/commands/iterate/escalate.mjs +11 -0
  33. package/bin/commands/iterate/fix-code.mjs +81 -42
  34. package/bin/commands/iterate/helpers.d.mts +0 -2
  35. package/bin/commands/iterate/helpers.mjs +0 -15
  36. package/bin/commands/iterate/index.mjs +6 -12
  37. package/bin/commands/iterate/mark-ready.d.mts +2 -0
  38. package/bin/commands/iterate/mark-ready.mjs +38 -0
  39. package/bin/commands/iterate/merge-state.mjs +38 -9
  40. package/bin/commands/iterate/merge.mjs +0 -1
  41. package/bin/commands/iterate/render.d.mts +2 -2
  42. package/bin/commands/iterate/render.mjs +19 -20
  43. package/bin/commands/iterate/stall.mjs +4 -2
  44. package/bin/commands/iterate/thread-mutation-routing.d.mts +9 -0
  45. package/bin/commands/iterate/thread-mutation-routing.mjs +29 -0
  46. package/bin/commands/journal/index.d.mts +5 -0
  47. package/bin/commands/journal/index.mjs +11 -2
  48. package/bin/commands/mark-files-as-viewed.d.mts +2 -1
  49. package/bin/commands/mark-files-as-viewed.mjs +5 -92
  50. package/bin/commands/resolve-mutate.mjs +51 -9
  51. package/bin/commands/shepherd-journal.d.mts +1 -1
  52. package/bin/commands/shepherd-journal.mjs +2 -2
  53. package/bin/commands/suggestion-patch-item.d.mts +1 -1
  54. package/bin/commands/suggestion-patch-item.mjs +3 -11
  55. package/bin/commands/suggestion-patches.mjs +3 -3
  56. package/bin/comments/authors.d.mts +6 -0
  57. package/bin/comments/authors.mjs +5 -0
  58. package/bin/comments/marker.d.mts +6 -1
  59. package/bin/comments/marker.mjs +8 -1
  60. package/bin/comments/resolve.d.mts +4 -0
  61. package/bin/comments/thread-visibility.mjs +10 -10
  62. package/bin/comments/visible-comments.mjs +2 -1
  63. package/bin/config/load.d.mts +1 -1
  64. package/bin/github/batch-parsers.d.mts +2 -2
  65. package/bin/github/batch-parsers.mjs +17 -1
  66. package/bin/github/batch-raw-types.d.mts +12 -0
  67. package/bin/github/batch.mjs +1 -1
  68. package/bin/github/client.d.mts +1 -0
  69. package/bin/github/client.mjs +8 -1
  70. package/bin/github/gql/batch-pr-page.gql +6 -0
  71. package/bin/github/gql/batch-pr.gql +13 -0
  72. package/bin/github/gql/get-pr-body.gql +1 -0
  73. package/bin/github/gql/review-thread-comments.gql +1 -0
  74. package/bin/github/gql/suggestion-threads.gql +1 -0
  75. package/bin/github/suggestion-thread.mjs +1 -0
  76. package/bin/mcp/server.mjs +12 -6
  77. package/bin/pr-reference.d.mts +13 -0
  78. package/bin/pr-reference.mjs +23 -1
  79. package/bin/reporters/agent.mjs +5 -0
  80. package/bin/threads/transcript.d.mts +2 -0
  81. package/bin/threads/transcript.mjs +2 -0
  82. package/bin/types/agent-thread.d.mts +1 -0
  83. package/bin/types/escalate.d.mts +6 -1
  84. package/bin/types/github.d.mts +19 -0
  85. package/bin/types/iterate.d.mts +9 -8
  86. package/bin/types/report.d.mts +14 -1
  87. package/bin/types/review-thread.d.mts +1 -0
  88. package/bin/types/suggestion-patch.d.mts +1 -1
  89. package/package.json +1 -1
  90. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  91. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  92. package/plugins/pr-shepherd/.mcp.json +1 -1
  93. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +3 -3
  94. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +15 -13
  95. package/bin/commands/iterate/reruns.d.mts +0 -20
  96. package/bin/commands/iterate/reruns.mjs +0 -105
@@ -10,8 +10,8 @@ export function renderResolveCommand(rc) {
10
10
  parts.push("--require-sha", "$HEAD_SHA");
11
11
  return renderShellCommand(parts);
12
12
  }
13
- export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
- isBehind = false) {
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) {
15
15
  const instructions = [];
16
16
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
17
17
  const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
@@ -38,13 +38,13 @@ isBehind = false) {
38
38
  instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
39
39
  }
40
40
  if (hasConflicts) {
41
- instructions.push("The branch has merge conflicts (see `**branch**` above). Resolve them before committing and pushing.");
41
+ instructions.push("The branch has merge conflicts (see `**branch**` above). Resolve them before committing.");
42
42
  }
43
43
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
44
44
  if (firstLookTotal > 0) {
45
45
  instructions.push("Review every item under `## First-look items` before acting.");
46
46
  }
47
- if (firstLookSummaries.length > 0)
47
+ if (firstLookSummaries.length > 0 && viewerCanUpdate)
48
48
  instructions.push(SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE);
49
49
  const editedTotal = editedSummaries.length +
50
50
  actionableComments.filter((c) => c.edited).length +
@@ -53,15 +53,13 @@ isBehind = false) {
53
53
  if (editedTotal > 0) {
54
54
  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
55
  }
56
- if (inProgressRunIds.length > 0) {
57
- instructions.push("If you will push, first cancel every ID under `## In-progress runs` with `gh run cancel <id>` (ignore errors for runs that already finished). If you will not push, leave them alone.");
58
- }
59
- if (cancelledCount > 0) {
60
- instructions.push("Do not cancel the IDs under `## Cancelled runs` again. The CLI already cancelled them.");
61
- }
56
+ // GitHub exposes no exact viewer capability for workflow-run cancellation, so the
57
+ // informational run lists never produce a cancellation recommendation.
58
+ void inProgressRunIds;
59
+ void cancelledCount;
62
60
  const hasSuggestions = threads.some((t) => t.suggestion);
63
61
  if (hasSuggestions)
64
- instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads"));
62
+ instructions.push(buildCommitSuggestionInstruction(prReference, "## Review threads"));
65
63
  if (threads.length > 0 || actionableComments.length > 0) {
66
64
  // Actionable comments carry no file/line location (unlike threads), so "referenced above"
67
65
  // is only accurate when threads are present.
@@ -83,21 +81,22 @@ isBehind = false) {
83
81
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
84
82
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
85
83
  if (hasConflicts) {
86
- instructions.push(`Commit any remaining changes and push the conflict resolution${mutationSuffix}.`);
84
+ instructions.push(`Commit any remaining conflict-resolution changes${mutationSuffix}.`);
87
85
  }
88
86
  else if (hasNonConflictHints) {
89
- instructions.push(`If you changed code, commit any remaining changes and push${mutationSuffix}. Otherwise, do not commit or push.`);
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.");
90
88
  }
91
- if (hasReviewMutations ||
92
- hasNonConflictHints ||
93
- firstLookTotal > 0 ||
94
- firstLookSummaries.length > 0 ||
95
- editedTotal > 0) {
96
- instructions.push(buildShepherdJournalInstruction(prNumber));
89
+ if (viewerCanUpdate &&
90
+ (hasReviewMutations ||
91
+ hasNonConflictHints ||
92
+ firstLookTotal > 0 ||
93
+ firstLookSummaries.length > 0 ||
94
+ editedTotal > 0)) {
95
+ instructions.push(buildShepherdJournalInstruction(prReference));
97
96
  }
98
97
  if (resolveOnlyCommand?.hasMutations)
99
98
  instructions.push("Run the `resolve-only:` command shown above.");
100
99
  instructions.push(...buildResolveCommandInstruction(resolveCommand));
101
- instructions.push(buildFixCompletionInstruction(failingChecks));
100
+ instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
102
101
  return instructions;
103
102
  }
@@ -2,6 +2,7 @@ import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
2
  import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
3
3
  import { buildEscalateSuggestion, buildEscalateHumanMessage, formatDurationApprox, } from "./escalate.mjs";
4
4
  import { checksWithActionableAnnotations } from "../check-annotations.mjs";
5
+ import { formatPrUrl } from "../../pr-reference.mjs";
5
6
  function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
6
7
  const checks = [
7
8
  ...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
@@ -36,6 +37,7 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
36
37
  });
37
38
  }
38
39
  export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
40
+ const prReference = formatPrUrl(report.repo, prNumber);
39
41
  const nowSeconds = Math.floor(Date.now() / 1000);
40
42
  const stalledChecks = findCiStartStalledChecks(report.checks.inProgress, nowSeconds, {
41
43
  stallTimeoutSeconds,
@@ -56,7 +58,7 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
56
58
  action: "escalate",
57
59
  escalate: {
58
60
  ...escalateBase,
59
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
61
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
60
62
  },
61
63
  };
62
64
  }
@@ -86,7 +88,7 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
86
88
  action: "escalate",
87
89
  escalate: {
88
90
  ...escalateBase,
89
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
91
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
90
92
  },
91
93
  };
92
94
  }
@@ -0,0 +1,9 @@
1
+ import { type NormalizedBotUsernames } from "../../comments/authors.mts";
2
+ import type { AgentThread, ReviewThread } from "../../types.mts";
3
+ export interface ThreadMutationRouting {
4
+ replyThreadIds: string[];
5
+ pairedResolveThreadIds: string[];
6
+ standaloneResolveThreadIds: string[];
7
+ resolveThreadIds: string[];
8
+ }
9
+ export declare function buildThreadMutationRouting(threads: Array<AgentThread | ReviewThread>, botUsernames: NormalizedBotUsernames, ruleAutoResolveThreadIds: string[]): ThreadMutationRouting;
@@ -0,0 +1,29 @@
1
+ import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "../../comments/authors.mjs";
2
+ import { threadEndedByShepherd } from "../../comments/marker.mjs";
3
+ function dedupeIds(ids) {
4
+ return [...new Set(ids)];
5
+ }
6
+ export function buildThreadMutationRouting(threads, botUsernames, ruleAutoResolveThreadIds) {
7
+ const isOrdinaryHuman = (thread) => isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
8
+ const replyThreadIds = dedupeIds(threads
9
+ .filter((thread) => isOrdinaryHuman(thread) && !threadEndedByShepherd(thread))
10
+ .map((thread) => thread.id));
11
+ const pairedResolveThreadIds = dedupeIds(threads
12
+ .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) && !threadEndedByShepherd(thread))
13
+ .map((thread) => thread.id));
14
+ const pairedResolveIdSet = new Set(pairedResolveThreadIds);
15
+ // Rule-matched threads bypass author routing; resolve-mutate retains the human-author guard.
16
+ const standaloneResolveThreadIds = dedupeIds([
17
+ ...threads
18
+ .filter((thread) => !isOrdinaryHuman(thread) ||
19
+ (isViewerAuthoredHuman(thread, botUsernames) && threadEndedByShepherd(thread)))
20
+ .map((thread) => thread.id),
21
+ ...ruleAutoResolveThreadIds,
22
+ ]).filter((id) => !pairedResolveIdSet.has(id));
23
+ return {
24
+ replyThreadIds,
25
+ pairedResolveThreadIds,
26
+ standaloneResolveThreadIds,
27
+ resolveThreadIds: dedupeIds([...pairedResolveThreadIds, ...standaloneResolveThreadIds]),
28
+ };
29
+ }
@@ -1,5 +1,9 @@
1
1
  export interface RunJournalOptions {
2
2
  prNumber: number | undefined;
3
+ targetRepository?: {
4
+ owner: string;
5
+ name: string;
6
+ };
3
7
  rawItem: string;
4
8
  dryRun: boolean;
5
9
  }
@@ -9,6 +13,7 @@ export interface JournalResult {
9
13
  sectionExisted: boolean;
10
14
  dryRun: boolean;
11
15
  previewBody?: string;
16
+ authorizationSkipped?: "denied-or-unverifiable";
12
17
  }
13
18
  /** @deprecated Hidden implementation for standalone `journal`; use `apply journal`. */
14
19
  export declare function runJournal(opts: RunJournalOptions): Promise<JournalResult>;
@@ -7,13 +7,22 @@ export async function runJournal(opts) {
7
7
  throw new Error(validation.error);
8
8
  }
9
9
  const { item } = validation;
10
- const { owner, name } = await getRepoInfo();
10
+ const { owner, name } = opts.targetRepository ?? (await getRepoInfo());
11
11
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
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 } = await getPullRequestBody(prNumber, owner, name);
15
+ const { nodeId, body, viewerCanUpdate } = 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
+ }
17
26
  if (mutated && !opts.dryRun) {
18
27
  await updatePullRequestBody(nodeId, newBody);
19
28
  }
@@ -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
+ authorizationSkipped?: "unverifiable";
24
25
  }
25
26
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
26
27
  export declare function runMarkFilesAsViewed(opts: MarkFilesAsViewedOptions): Promise<MarkFilesAsViewedResult>;
@@ -1,7 +1,6 @@
1
1
  /* eslint-disable max-lines */
2
- import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
2
+ import { graphql, getCurrentPrNumber, getRepoInfo } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
- import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
5
4
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
6
5
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
7
6
  repository(owner: $owner, name: $repo) {
@@ -22,10 +21,9 @@ const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr
22
21
  }
23
22
  }`;
24
23
  const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|(^|\/)tests?\.rs$/i;
25
- const BULK_CHUNK_SIZE = 10;
26
24
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
27
25
  export async function runMarkFilesAsViewed(opts) {
28
- const repo = await getRepoInfo();
26
+ const repo = opts.targetRepository ?? (await getRepoInfo());
29
27
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
30
28
  if (!prNumber) {
31
29
  throw new ShepherdError("No PR number provided and no current branch PR found", EXIT.UNAVAILABLE);
@@ -52,8 +50,10 @@ export async function runMarkFilesAsViewed(opts) {
52
50
  missingPaths: selected.missingPaths,
53
51
  unmatchedSelectors: selected.unmatchedSelectors,
54
52
  errors: [],
53
+ ...(selected.pathsToMark.length > 0 && { authorizationSkipped: "unverifiable" }),
55
54
  };
56
- await bulkMarkFilesAsViewed(fetched.pullRequestId, selected.pathsToMark, result);
55
+ // GitHub exposes no exact viewer capability for markFileAsViewed. Repository role and
56
+ // viewerCanEditFiles describe different operations, so this command fails closed.
57
57
  return result;
58
58
  }
59
59
  async function fetchPullRequestFiles(pr, repo) {
@@ -132,90 +132,3 @@ function selectChangedFiles(changedFiles, opts) {
132
132
  const pathsToMark = matchedPaths.filter((path) => !alreadyViewedSet.has(path));
133
133
  return { matchedPaths, alreadyViewedPaths, missingPaths, unmatchedSelectors, pathsToMark };
134
134
  }
135
- function buildBulkMutation(paths) {
136
- const ops = paths.map((path, i) => ` m${i}: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: ${JSON.stringify(path)} }) { pullRequest { id } }`);
137
- return `mutation BulkMarkFilesAsViewed($pullRequestId: ID!) {\n${ops.join("\n")}\n}`;
138
- }
139
- async function bulkMarkFilesAsViewed(pullRequestId, paths, result) {
140
- for (let i = 0; i < paths.length; i += BULK_CHUNK_SIZE) {
141
- const chunk = paths.slice(i, i + BULK_CHUNK_SIZE);
142
- // eslint-disable-next-line no-await-in-loop
143
- const stopped = await bulkMarkFilesAsViewedChunk(pullRequestId, chunk, result, i + BULK_CHUNK_SIZE < paths.length);
144
- if (stopped) {
145
- const markedSet = new Set(result.markedPaths);
146
- result.unmarkedPaths = paths.slice(i).filter((path) => !markedSet.has(path));
147
- return;
148
- }
149
- }
150
- }
151
- async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendingAfter) {
152
- if (paths.length === 0)
153
- return false;
154
- let data = {};
155
- let graphQlErrors = [];
156
- let suppressCurrentChunkErrors = false;
157
- let rateLimitStop;
158
- try {
159
- const resp = await graphqlWithRateLimit(buildBulkMutation(paths), { pullRequestId }, { allowPartialData: true });
160
- data = resp.data;
161
- graphQlErrors = (resp.errors ?? []);
162
- const messages = graphQlErrors.map((e) => e.message);
163
- suppressCurrentChunkErrors = messages.some(isRateLimitMessage);
164
- rateLimitStop = rateLimitFromGraphQlResult(messages, {
165
- rateLimit: resp.rateLimit,
166
- retryAfterSeconds: resp.retryAfterSeconds,
167
- stopOnZeroRemaining: hasPendingAfter,
168
- });
169
- }
170
- catch (err) {
171
- const msg = err instanceof Error ? err.message : String(err);
172
- const stop = rateLimitFromError(err, msg);
173
- if (stop) {
174
- result.errors.push(`rate limit: ${stop.message}`);
175
- result.rateLimit = stop;
176
- return true;
177
- }
178
- for (const path of paths)
179
- result.errors.push(`${path}: ${msg}`);
180
- return false;
181
- }
182
- const errorMessagesByAlias = mapAliasErrors(graphQlErrors);
183
- for (let i = 0; i < paths.length; i += 1) {
184
- const alias = `m${i}`;
185
- const m = data[alias];
186
- if (m?.pullRequest?.id === pullRequestId) {
187
- result.markedPaths.push(paths[i]);
188
- }
189
- else if (!suppressCurrentChunkErrors) {
190
- result.errors.push(`${paths[i]}: ${errorMessagesByAlias.get(alias) ?? "mark returned null"}`);
191
- }
192
- }
193
- if (rateLimitStop) {
194
- result.errors.push(`rate limit: ${rateLimitStop.message}`);
195
- result.rateLimit = rateLimitStop;
196
- return true;
197
- }
198
- return false;
199
- }
200
- function mapAliasErrors(errors) {
201
- const out = new Map();
202
- for (const error of errors) {
203
- if (!Array.isArray(error.path))
204
- continue;
205
- const alias = error.path.find((part) => typeof part === "string" && isMarkAlias(part));
206
- if (typeof alias === "string")
207
- out.set(alias, error.message);
208
- }
209
- return out;
210
- }
211
- function isMarkAlias(value) {
212
- if (!value.startsWith("m"))
213
- return false;
214
- if (value.length === 1)
215
- return false;
216
- for (const char of value.slice(1)) {
217
- if (char < "0" || char > "9")
218
- return false;
219
- }
220
- return true;
221
- }
@@ -2,14 +2,14 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
3
  import { fetchPrBatch } from "../github/batch.mjs";
4
4
  import { loadConfig } from "../config/load.mjs";
5
- import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
5
+ import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, normalizeBotUsernames, } from "../comments/authors.mjs";
6
6
  import { markReplySeen } from "../state/seen-comments.mjs";
7
7
  import { threadTranscriptBody } from "../threads/transcript.mjs";
8
- import { addPrShepherdMarker } from "../comments/marker.mjs";
8
+ import { addPrShepherdMarker, threadEndedByShepherd } from "../comments/marker.mjs";
9
9
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
10
10
  /** @deprecated Hidden implementation for `resolve`; use `apply review`. */
11
11
  export async function runResolveMutate(opts) {
12
- const repo = await getRepoInfo();
12
+ const repo = opts.targetRepository ?? (await getRepoInfo());
13
13
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
14
14
  if (prNumber === null) {
15
15
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
@@ -27,21 +27,55 @@ 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 resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
31
- const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
32
- const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(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)));
48
+ const allowedViewerHumanResolveIds = new Set(data.reviewThreads
49
+ .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) &&
50
+ (requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread)))
51
+ .map((thread) => thread.id));
52
+ const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => (!humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id)) &&
53
+ resolveAuthorizedIds.has(id));
54
+ 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));
33
58
  const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
34
- const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.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));
35
61
  const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
36
- const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((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);
37
66
  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 ||
69
+ (replyThreadIds?.length ?? 0) > 0 ||
70
+ minimizeCommentIds.length > 0 ||
71
+ dismissReviewIds.length > 0;
38
72
  const result = await applyResolveOptions(prNumber, repo, {
39
73
  resolveThreadIds,
40
74
  replyThreadIds,
41
75
  minimizeCommentIds,
42
76
  dismissReviewIds,
43
77
  dismissMessage: opts.dismissMessage,
44
- requireSha: opts.requireSha,
78
+ requireSha: hasAuthorizedMutation ? opts.requireSha : undefined,
45
79
  });
46
80
  if (skippedHumanResolves.length > 0)
47
81
  result.skippedHumanResolves = skippedHumanResolves;
@@ -51,6 +85,14 @@ export async function runResolveMutate(opts) {
51
85
  result.skippedHumanDismissals = skippedHumanDismissals;
52
86
  if (skippedNonHumanReplies.length > 0)
53
87
  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;
54
96
  if (opts.dismissMessage) {
55
97
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
56
98
  await Promise.all(result.repliedThreads.map((id) => {
@@ -11,4 +11,4 @@ export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body un
11
11
  * invocation, so it lives in the pr-shepherd skill's "Shepherd Journal" playbook instead
12
12
  * of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
13
13
  */
14
- export declare function buildShepherdJournalInstruction(prNumber: number): string;
14
+ export declare function buildShepherdJournalInstruction(prReference: string | number): string;
@@ -11,6 +11,6 @@ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `##
11
11
  * invocation, so it lives in the pr-shepherd skill's "Shepherd Journal" playbook instead
12
12
  * of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
13
13
  */
14
- export function buildShepherdJournalInstruction(prNumber) {
15
- return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prNumber} '- <decision>'\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
14
+ export function buildShepherdJournalInstruction(prReference) {
15
+ return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prReference} '- <decision>'\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
16
16
  }
@@ -10,6 +10,6 @@ export declare function buildSuggestionPatchItem({ thread, request, originalCont
10
10
  request: SuggestionPatchRequest;
11
11
  originalContent: string;
12
12
  }): SuggestionPatchResult;
13
- export declare function buildSingularInstructions(patch: SuggestionPatchResult, prNumber: number): string[];
13
+ export declare function buildSingularInstructions(patch: SuggestionPatchResult, prReference: string | number): string[];
14
14
  export declare function buildCommitCommand(patch: SuggestionPatchResult): string;
15
15
  export declare function quotePath(path: string): string;
@@ -1,7 +1,6 @@
1
1
  import { relative, resolve } from "node:path";
2
2
  import { getEffectiveCwd } from "../execution-context.mjs";
3
3
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
4
- import { buildPrShepherdCommand } from "../cli/runner.mjs";
5
4
  import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
6
5
  import { buildUnifiedDiff } from "../suggestions/patch.mjs";
7
6
  import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
@@ -62,23 +61,16 @@ export function buildSuggestionPatchItem({ thread, request, originalContent, })
62
61
  filesToStage: [thread.path],
63
62
  };
64
63
  }
65
- export function buildSingularInstructions(patch, prNumber) {
64
+ export function buildSingularInstructions(patch, prReference) {
66
65
  const range = patch.startLine === patch.endLine
67
66
  ? `line ${patch.startLine}`
68
67
  : `lines ${patch.startLine}–${patch.endLine}`;
69
- const resolveCommand = buildPrShepherdCommand([
70
- "apply",
71
- "review",
72
- String(prNumber),
73
- "--resolve-thread-ids",
74
- patch.threadId,
75
- ]).text;
76
68
  return [
77
69
  `Apply the patch to \`${patch.path}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
78
70
  `Stage the file: \`git add -- ${quotePath(patch.path)}\``,
79
71
  `Commit: \`${buildCommitCommand(patch)}\``,
80
- `Resolve the thread on GitHub: \`${resolveCommand}\``,
81
- `Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
72
+ `Use the originating iterate output for any authorization-checked GitHub review action for thread \`${patch.threadId}\` on ${String(prReference)}.`,
73
+ "Shepherd cannot verify authorization for the Git credential that would push this branch, so this output does not recommend a push.",
82
74
  ];
83
75
  }
84
76
  export function buildCommitCommand(patch) {
@@ -5,7 +5,7 @@ import { buildCommitCommand, buildSuggestionPatchItem, quotePath, validateSugges
5
5
  import { checkPatchesApply, getLocalHeadSha, getPathsStatus, isAncestor, readPrHeadFile, } from "./suggestion-patch-git.mjs";
6
6
  export async function runSuggestionPatches(opts) {
7
7
  validateRequests(opts.suggestions);
8
- const repo = await getRepoInfo();
8
+ const repo = opts.targetRepository ?? (await getRepoInfo());
9
9
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
10
10
  if (prNumber === null) {
11
11
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
@@ -96,8 +96,8 @@ function buildBatchInstructions(patches) {
96
96
  ]);
97
97
  return [
98
98
  ...patchInstructions,
99
- "Push once after every patch is committed: `git push` (or `git push --force-with-lease` after rebasing).",
100
- `Then continue with the originating iterate output's \`apply review\` instructions for thread IDs: ${patches.map((patch) => patch.threadId).join(", ")}.`,
99
+ "Shepherd cannot verify authorization for the Git credential that would push this branch, so this output does not recommend a push.",
100
+ `Continue only with authorization-checked \`apply review\` instructions from the originating iterate output for thread IDs: ${patches.map((patch) => patch.threadId).join(", ")}.`,
101
101
  ];
102
102
  }
103
103
  function errorMessage(error) {
@@ -12,3 +12,9 @@ export declare function isConfiguredBotAuthor(author: {
12
12
  login?: string;
13
13
  authorType?: AuthorType;
14
14
  }, botUsernames?: NormalizedBotUsernames): boolean;
15
+ export declare function isViewerAuthoredHuman(author: {
16
+ author?: string;
17
+ login?: string;
18
+ authorType?: AuthorType;
19
+ viewerDidAuthor?: true;
20
+ }, botUsernames?: NormalizedBotUsernames): boolean;
@@ -30,3 +30,8 @@ export function isConfiguredBotAuthor(author, botUsernames = new Set()) {
30
30
  return false;
31
31
  return botUsernames.has(normalized);
32
32
  }
33
+ export function isViewerAuthoredHuman(author, botUsernames = new Set()) {
34
+ return (author.viewerDidAuthor === true &&
35
+ isHumanAuthor(author) &&
36
+ !isConfiguredBotAuthor(author, botUsernames));
37
+ }
@@ -1,2 +1,7 @@
1
- export declare function hasPrShepherdMarker(body: string): boolean;
2
1
  export declare function addPrShepherdMarker(body: string): string;
2
+ export declare function threadEndedByShepherd(thread: {
3
+ body: string;
4
+ comments?: readonly {
5
+ body: string;
6
+ }[];
7
+ }): boolean;
@@ -1,7 +1,14 @@
1
1
  const PR_SHEPHERD_MARKER = "<!-- pr-shepherd -->";
2
- export function hasPrShepherdMarker(body) {
2
+ function hasPrShepherdMarker(body) {
3
3
  return body.startsWith(PR_SHEPHERD_MARKER);
4
4
  }
5
5
  export function addPrShepherdMarker(body) {
6
6
  return `${PR_SHEPHERD_MARKER}\n${body}`;
7
7
  }
8
+ export function threadEndedByShepherd(thread) {
9
+ const comments = thread.comments;
10
+ if (comments && comments.length > 0) {
11
+ return hasPrShepherdMarker(comments[comments.length - 1].body);
12
+ }
13
+ return hasPrShepherdMarker(thread.body);
14
+ }
@@ -12,6 +12,10 @@ export interface ResolveResult {
12
12
  skippedHumanMinimizes?: string[];
13
13
  skippedHumanDismissals?: string[];
14
14
  skippedNonHumanReplies?: string[];
15
+ skippedUnauthorizedReplies?: string[];
16
+ skippedUnauthorizedResolves?: string[];
17
+ skippedUnauthorizedMinimizes?: string[];
18
+ skippedUnauthorizedDismissals?: string[];
15
19
  rateLimit?: ResolveRateLimitStop;
16
20
  unrepliedThreads?: string[];
17
21
  unresolvedThreads?: string[];
@@ -1,14 +1,7 @@
1
1
  import { classifyItem } from "../state/seen-comments.mjs";
2
2
  import { threadTranscriptBody } from "../threads/transcript.mjs";
3
- import { isConfiguredBotAuthor } from "./authors.mjs";
4
- import { hasPrShepherdMarker } from "./marker.mjs";
5
- function threadEndedByShepherd(thread) {
6
- const comments = thread.comments;
7
- if (comments && comments.length > 0) {
8
- return hasPrShepherdMarker(comments[comments.length - 1].body);
9
- }
10
- return hasPrShepherdMarker(thread.body);
11
- }
3
+ import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "./authors.mjs";
4
+ import { threadEndedByShepherd } from "./marker.mjs";
12
5
  function withEdited(thread, edited) {
13
6
  return edited ? { ...thread, edited: true } : thread;
14
7
  }
@@ -39,7 +32,14 @@ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Se
39
32
  return visible ? [visible] : [];
40
33
  });
41
34
  const resolutionOnlyThreads = unresolvedThreads
42
- .filter((t) => t.isOutdated || t.isMinimized)
35
+ .filter((t) => {
36
+ const endedByShepherd = threadEndedByShepherd(t);
37
+ const ordinaryHuman = isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames);
38
+ if (endedByShepherd && ordinaryHuman) {
39
+ return isViewerAuthoredHuman(t, botUsernames);
40
+ }
41
+ return t.isOutdated || t.isMinimized;
42
+ })
43
43
  .map((t) => classifyVisibleThread(t, seenMap) ?? t);
44
44
  const firstLookThreads = [
45
45
  ...threads.flatMap((t) => {
@@ -5,7 +5,8 @@ export function classifyVisibleComments(comments, seenMap, minimizeComments, bot
5
5
  const minimizeIds = [];
6
6
  const toMarkSeen = [];
7
7
  for (const c of comments.filter((comment) => !comment.isMinimized)) {
8
- if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
8
+ if (c.viewerCanMinimize === true &&
9
+ shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
9
10
  actionable.push(c);
10
11
  minimizeIds.push(c.id);
11
12
  toMarkSeen.push(c); // prevents re-surfacing as first-look after GitHub marks it minimized
@@ -50,7 +50,7 @@ interface PrShepherdConfig {
50
50
  actions: {
51
51
  autoMinimizeSuppressed: boolean;
52
52
  autoMarkReady: boolean;
53
- /** Case-insensitive glob patterns for workflow/check names Shepherd must not cancel. */
53
+ /** Legacy-named patterns that keep matching Actions checks visible despite ignoreChecks. */
54
54
  neverCancelRuns: string[];
55
55
  /** @deprecated Accepted for compatibility, ignored by the loader. */
56
56
  autoResolveOutdated?: boolean;