pr-shepherd 0.41.0 → 0.42.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -7
  3. package/bin/api.d.mts +12 -1
  4. package/bin/api.mjs +34 -1
  5. package/bin/cli/formatters.d.mts +2 -1
  6. package/bin/cli/formatters.mjs +33 -0
  7. package/bin/cli/handlers.d.mts +1 -0
  8. package/bin/cli/handlers.mjs +20 -1
  9. package/bin/cli/help-command-pages.d.mts +20 -4
  10. package/bin/cli/help-command-pages.mjs +20 -4
  11. package/bin/cli/help-top-page.d.mts +1 -1
  12. package/bin/cli/help-top-page.mjs +3 -3
  13. package/bin/cli/help.d.mts +21 -5
  14. package/bin/cli/suggestion-patch-flags.d.mts +11 -0
  15. package/bin/cli/suggestion-patch-flags.mjs +83 -0
  16. package/bin/cli-parser.mjs +6 -2
  17. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  18. package/bin/commands/commit-suggestion-instruction.mjs +3 -3
  19. package/bin/commands/commit-suggestion.d.mts +1 -1
  20. package/bin/commands/commit-suggestion.mjs +19 -138
  21. package/bin/commands/suggestion-patch-git.d.mts +6 -0
  22. package/bin/commands/suggestion-patch-git.mjs +54 -0
  23. package/bin/commands/suggestion-patch-item.d.mts +15 -0
  24. package/bin/commands/suggestion-patch-item.mjs +106 -0
  25. package/bin/commands/suggestion-patches.d.mts +6 -0
  26. package/bin/commands/suggestion-patches.mjs +108 -0
  27. package/bin/github/gql/{commit-suggestion-thread.gql → suggestion-threads.gql} +2 -2
  28. package/bin/github/queries.d.mts +1 -1
  29. package/bin/github/queries.mjs +1 -1
  30. package/bin/github/suggestion-thread.d.mts +3 -3
  31. package/bin/github/suggestion-thread.mjs +8 -5
  32. package/bin/mcp/server.d.mts +1 -1
  33. package/bin/mcp/server.mjs +23 -3
  34. package/bin/types/report.d.mts +0 -19
  35. package/bin/types/suggestion-patch.d.mts +30 -0
  36. package/bin/types/suggestion-patch.mjs +1 -0
  37. package/bin/types.d.mts +1 -0
  38. package/bin/types.mjs +1 -0
  39. package/package.json +1 -1
  40. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  41. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  42. package/plugins/pr-shepherd/.mcp.json +1 -1
  43. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +8 -7
@@ -4,5 +4,5 @@ export interface CommitSuggestionOptions extends GlobalOptions {
4
4
  message: string;
5
5
  description?: string;
6
6
  }
7
- /** @deprecated Hidden implementation for `commit-suggestion`; use `build-suggestion-patch`. */
7
+ /** @deprecated Use runSuggestionPatches. */
8
8
  export declare function runCommitSuggestion(opts: CommitSuggestionOptions): Promise<CommitSuggestionResult>;
@@ -1,143 +1,24 @@
1
- import { execFile as execFileCb } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
3
- import { relative, resolve } from "node:path";
4
- import { promisify } from "node:util";
5
- import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
6
- import { fetchSuggestionThread } from "../github/suggestion-thread.mjs";
7
- import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
8
- import { buildUnifiedDiff } from "../suggestions/patch.mjs";
9
- import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
10
- import { EXIT, ShepherdError } from "../exit-codes.mjs";
11
- import { buildPrShepherdCommand } from "../cli/runner.mjs";
12
- import { getEffectiveCwd, getExecutionCwd } from "../execution-context.mjs";
13
- const execFile = promisify(execFileCb);
14
- /** @deprecated Hidden implementation for `commit-suggestion`; use `build-suggestion-patch`. */
1
+ import { buildSingularInstructions } from "./suggestion-patch-item.mjs";
2
+ import { runSuggestionPatches } from "./suggestion-patches.mjs";
3
+ /** @deprecated Use runSuggestionPatches. */
15
4
  export async function runCommitSuggestion(opts) {
16
- if (!opts.threadId) {
17
- throw new ShepherdError("--thread-id is required", EXIT.USAGE);
18
- }
19
- if (!opts.message || opts.message.trim() === "") {
20
- throw new ShepherdError("--message is required and must be non-empty", EXIT.USAGE);
21
- }
22
- const repo = await getRepoInfo();
23
- const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
24
- if (prNumber === null) {
25
- throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
26
- }
27
- const currentBranch = await getCurrentBranch();
28
- const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"], {
29
- cwd: getExecutionCwd(),
5
+ const result = await runSuggestionPatches({
6
+ prNumber: opts.prNumber,
7
+ format: opts.format,
8
+ verbose: opts.verbose,
9
+ suggestions: [
10
+ {
11
+ threadId: opts.threadId,
12
+ message: opts.message,
13
+ ...(opts.description !== undefined && { description: opts.description }),
14
+ },
15
+ ],
30
16
  });
31
- const localHeadSha = localHeadOut.trim();
32
- const data = await fetchSuggestionThread(prNumber, repo, opts.threadId);
33
- if (!data.headRepoWithOwner) {
34
- throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
35
- }
36
- if (currentBranch !== data.headRefName) {
37
- throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
38
- `Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
39
- }
40
- if (localHeadSha !== data.headRefOid) {
41
- throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
42
- `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
43
- }
44
- const thread = data.thread;
45
- if (!thread) {
46
- throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
47
- }
48
- if (thread.isResolved) {
49
- throw new ShepherdError(`Thread ${opts.threadId} is already resolved.`, EXIT.UNAVAILABLE);
50
- }
51
- if (thread.isOutdated) {
52
- throw new ShepherdError(`Thread ${opts.threadId} is outdated.`, EXIT.UNAVAILABLE);
53
- }
54
- if (thread.isMinimized) {
55
- throw new ShepherdError(`Thread ${opts.threadId} is minimized.`, EXIT.UNAVAILABLE);
56
- }
57
- if (!thread.path || thread.line === null) {
58
- throw new ShepherdError(`Thread ${opts.threadId} has no file/line anchor.`, EXIT.UNAVAILABLE);
59
- }
60
- // Validate the target file is clean before generating the patch, so the emitted
61
- // `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
62
- const { stdout: fileStatus } = await execFile("git", ["status", "--porcelain", "--", thread.path], { cwd: getExecutionCwd() });
63
- if (fileStatus.trim() !== "") {
64
- throw new ShepherdError(`${thread.path} has uncommitted changes. Commit or stash them before running build-suggestion-patch.`, EXIT.UNAVAILABLE);
65
- }
66
- const parsed = parseSuggestion(thread.body);
67
- if (!parsed) {
68
- throw new ShepherdError(`Thread ${opts.threadId} has no suggestion block in the comment body.`, EXIT.UNAVAILABLE);
69
- }
70
- if (!isCommittableSuggestion(parsed)) {
71
- throw new ShepherdError(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
72
- `3+ backtick fences — refusing to apply (could silently truncate).`, EXIT.UNAVAILABLE);
73
- }
74
- const startLine = thread.startLine ?? thread.line;
75
- const endLine = thread.line;
76
- const filePath = thread.path;
77
- const cwd = getEffectiveCwd();
78
- const resolvedPath = resolve(cwd, filePath);
79
- const rel = relative(cwd, resolvedPath);
80
- if (rel.startsWith("..") || rel === "") {
81
- throw new ShepherdError(`Thread ${opts.threadId} path escapes the working tree.`, EXIT.UNAVAILABLE);
82
- }
83
- const originalContent = await readFile(resolvedPath, "utf8");
84
- const unsafeRangeReason = getUnsafeSuggestionRangeReason({
85
- originalContent,
86
- startLine,
87
- endLine,
88
- replacementLines: parsed.lines,
89
- });
90
- if (unsafeRangeReason) {
91
- const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
92
- throw new ShepherdError(`Thread ${opts.threadId}'s suggestion does not safely fit GitHub's anchored range ` +
93
- `${filePath}:${range}: ${unsafeRangeReason} Refusing to build a patch; inspect the surrounding ` +
94
- `source and reviewer intent, then apply the change manually.`, EXIT.UNAVAILABLE);
95
- }
96
- const patch = buildUnifiedDiff({
97
- path: filePath,
98
- originalContent,
99
- startLine,
100
- endLine,
101
- replacementLines: parsed.lines,
102
- });
103
- const coAuthor = `Co-authored-by: ${thread.author} <${thread.author}@users.noreply.github.com>`;
104
- const commitBody = opts.description ? `${opts.description}\n\n${coAuthor}` : coAuthor;
105
- const commitMessageArg = opts.message;
106
- const commitBodyArg = commitBody;
107
- const quotedPath = `'${filePath.replace(/'/g, "'\\''")}'`;
108
- const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}–${endLine}`;
109
- const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
110
- const commitCmd = [
111
- "git commit",
112
- `-m ${sq(commitMessageArg)}`,
113
- ...commitBodyArg.split("\n\n").map((p) => `-m ${sq(p)}`),
114
- ].join(" ");
115
- const resolveCommand = buildPrShepherdCommand([
116
- "apply",
117
- "review",
118
- String(prNumber),
119
- "--resolve-thread-ids",
120
- opts.threadId,
121
- ]).text;
122
- const postActionInstructions = [
123
- `Apply the patch to \`${filePath}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
124
- `Stage the file: \`git add -- ${quotedPath}\``,
125
- `Commit: \`${commitCmd}\``,
126
- `Resolve the thread on GitHub: \`${resolveCommand}\``,
127
- `Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
128
- ];
17
+ const patch = result.patches[0];
129
18
  return {
130
- pr: prNumber,
131
- repo: `${repo.owner}/${repo.name}`,
132
- threadId: opts.threadId,
133
- path: filePath,
134
- startLine,
135
- endLine,
136
- author: thread.author,
137
- patch,
138
- commitMessage: commitMessageArg,
139
- commitBody: commitBodyArg,
140
- filesToStage: [filePath],
141
- postActionInstructions,
19
+ ...patch,
20
+ pr: result.pr,
21
+ repo: result.repo,
22
+ postActionInstructions: buildSingularInstructions(patch, result.pr),
142
23
  };
143
24
  }
@@ -0,0 +1,6 @@
1
+ export declare function getLocalHeadSha(): Promise<string>;
2
+ export declare function isAncestor(ancestor: string, descendant: string): Promise<boolean>;
3
+ export declare function readPrHeadFile(headSha: string, path: string): Promise<string>;
4
+ export declare function getPathsStatus(paths: readonly string[]): Promise<string>;
5
+ /** Dry-run an ordered patch stream against the current worktree. */
6
+ export declare function checkPatchesApply(patches: readonly string[]): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { getExecutionCwd } from "../execution-context.mjs";
4
+ const execFile = promisify(execFileCb);
5
+ export async function getLocalHeadSha() {
6
+ const { stdout } = await execFile("git", ["rev-parse", "HEAD"], {
7
+ cwd: getExecutionCwd(),
8
+ });
9
+ return stdout.trim();
10
+ }
11
+ export async function isAncestor(ancestor, descendant) {
12
+ try {
13
+ await execFile("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
14
+ cwd: getExecutionCwd(),
15
+ });
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ if (isExitCode(error, 1) || isExitCode(error, 128))
20
+ return false;
21
+ throw error;
22
+ }
23
+ }
24
+ export async function readPrHeadFile(headSha, path) {
25
+ const { stdout } = await execFile("git", ["show", `${headSha}:${path}`], {
26
+ cwd: getExecutionCwd(),
27
+ maxBuffer: 32 * 1024 * 1024,
28
+ });
29
+ return stdout;
30
+ }
31
+ export async function getPathsStatus(paths) {
32
+ const { stdout } = await execFile("git", ["status", "--porcelain", "--", ...paths], {
33
+ cwd: getExecutionCwd(),
34
+ });
35
+ return stdout.trim();
36
+ }
37
+ /** Dry-run an ordered patch stream against the current worktree. */
38
+ export function checkPatchesApply(patches) {
39
+ const input = patches.join("\n");
40
+ return new Promise((resolve, reject) => {
41
+ const child = execFileCb("git", // NOSONAR -- resolve git through PATH consistently with all CLI git helpers.
42
+ ["apply", "--check"], { cwd: getExecutionCwd() }, (error, _stdout, stderr) => {
43
+ if (!error) {
44
+ resolve();
45
+ return;
46
+ }
47
+ reject(new Error(stderr.trim() || error.message));
48
+ });
49
+ child.stdin.end(input);
50
+ });
51
+ }
52
+ function isExitCode(error, code) {
53
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
54
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReviewThread, SuggestionPatchResult } from "../types.mts";
2
+ export interface SuggestionPatchRequest {
3
+ threadId: string;
4
+ message: string;
5
+ description?: string;
6
+ }
7
+ export declare function validateSuggestionThread(thread: ReviewThread | null, request: SuggestionPatchRequest): ReviewThread;
8
+ export declare function buildSuggestionPatchItem({ thread, request, originalContent, }: {
9
+ thread: ReviewThread;
10
+ request: SuggestionPatchRequest;
11
+ originalContent: string;
12
+ }): SuggestionPatchResult;
13
+ export declare function buildSingularInstructions(patch: SuggestionPatchResult, prNumber: number): string[];
14
+ export declare function buildCommitCommand(patch: SuggestionPatchResult): string;
15
+ export declare function quotePath(path: string): string;
@@ -0,0 +1,106 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { getEffectiveCwd } from "../execution-context.mjs";
3
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
4
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
5
+ import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
6
+ import { buildUnifiedDiff } from "../suggestions/patch.mjs";
7
+ import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
8
+ const SINGLE_QUOTE_ESCAPE = String.raw `'\''`;
9
+ export function validateSuggestionThread(thread, request) {
10
+ if (!thread)
11
+ unavailable(`Thread ${request.threadId} not found on PR.`);
12
+ if (thread.isResolved)
13
+ unavailable(`Thread ${request.threadId} is already resolved.`);
14
+ if (thread.isOutdated)
15
+ unavailable(`Thread ${request.threadId} is outdated.`);
16
+ if (thread.isMinimized)
17
+ unavailable(`Thread ${request.threadId} is minimized.`);
18
+ if (!thread.path || thread.line === null) {
19
+ unavailable(`Thread ${request.threadId} has no file/line anchor.`);
20
+ }
21
+ ensureSafePath(thread.path);
22
+ return thread;
23
+ }
24
+ export function buildSuggestionPatchItem({ thread, request, originalContent, }) {
25
+ const parsed = parseSuggestion(thread.body);
26
+ if (!parsed)
27
+ unavailable(`Thread ${request.threadId} has no suggestion block in the comment body.`);
28
+ if (!isCommittableSuggestion(parsed)) {
29
+ unavailable(`Thread ${request.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
30
+ `3+ backtick fences — refusing to apply (could silently truncate).`);
31
+ }
32
+ const startLine = thread.startLine ?? thread.line;
33
+ const endLine = thread.line;
34
+ const unsafeRangeReason = getUnsafeSuggestionRangeReason({
35
+ originalContent,
36
+ startLine,
37
+ endLine,
38
+ replacementLines: parsed.lines,
39
+ });
40
+ if (unsafeRangeReason) {
41
+ const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
42
+ unavailable(`Thread ${request.threadId}'s suggestion does not safely fit GitHub's anchored range ` +
43
+ `${thread.path}:${range}: ${unsafeRangeReason} Refusing to build a patch; inspect the ` +
44
+ `surrounding source and reviewer intent, then apply the change manually.`);
45
+ }
46
+ const coAuthor = `Co-authored-by: ${thread.author} <${thread.author}@users.noreply.github.com>`;
47
+ return {
48
+ threadId: request.threadId,
49
+ path: thread.path,
50
+ startLine,
51
+ endLine,
52
+ author: thread.author,
53
+ patch: buildUnifiedDiff({
54
+ path: thread.path,
55
+ originalContent,
56
+ startLine,
57
+ endLine,
58
+ replacementLines: parsed.lines,
59
+ }),
60
+ commitMessage: request.message,
61
+ commitBody: request.description ? `${request.description}\n\n${coAuthor}` : coAuthor,
62
+ filesToStage: [thread.path],
63
+ };
64
+ }
65
+ export function buildSingularInstructions(patch, prNumber) {
66
+ const range = patch.startLine === patch.endLine
67
+ ? `line ${patch.startLine}`
68
+ : `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
+ return [
77
+ `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
+ `Stage the file: \`git add -- ${quotePath(patch.path)}\``,
79
+ `Commit: \`${buildCommitCommand(patch)}\``,
80
+ `Resolve the thread on GitHub: \`${resolveCommand}\``,
81
+ `Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
82
+ ];
83
+ }
84
+ export function buildCommitCommand(patch) {
85
+ return [
86
+ "git commit",
87
+ `-m ${shellQuote(patch.commitMessage)}`,
88
+ ...patch.commitBody.split("\n\n").map((part) => `-m ${shellQuote(part)}`),
89
+ ].join(" ");
90
+ }
91
+ export function quotePath(path) {
92
+ return shellQuote(path);
93
+ }
94
+ function shellQuote(value) {
95
+ return "'" + value.replaceAll("'", SINGLE_QUOTE_ESCAPE) + "'";
96
+ }
97
+ function ensureSafePath(path) {
98
+ const cwd = getEffectiveCwd();
99
+ const rel = relative(cwd, resolve(cwd, path));
100
+ if (rel.startsWith("..") || rel === "") {
101
+ unavailable(`Thread path escapes the working tree: ${path}.`);
102
+ }
103
+ }
104
+ function unavailable(message) {
105
+ throw new ShepherdError(message, EXIT.UNAVAILABLE);
106
+ }
@@ -0,0 +1,6 @@
1
+ import type { BuildSuggestionPatchesResult, GlobalOptions } from "../types.mts";
2
+ import { type SuggestionPatchRequest } from "./suggestion-patch-item.mts";
3
+ export interface SuggestionPatchesOptions extends GlobalOptions {
4
+ suggestions: readonly SuggestionPatchRequest[];
5
+ }
6
+ export declare function runSuggestionPatches(opts: SuggestionPatchesOptions): Promise<BuildSuggestionPatchesResult>;
@@ -0,0 +1,108 @@
1
+ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
2
+ import { fetchSuggestionThreads } from "../github/suggestion-thread.mjs";
3
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
4
+ import { buildCommitCommand, buildSuggestionPatchItem, quotePath, validateSuggestionThread, } from "./suggestion-patch-item.mjs";
5
+ import { checkPatchesApply, getLocalHeadSha, getPathsStatus, isAncestor, readPrHeadFile, } from "./suggestion-patch-git.mjs";
6
+ export async function runSuggestionPatches(opts) {
7
+ validateRequests(opts.suggestions);
8
+ const repo = await getRepoInfo();
9
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
10
+ if (prNumber === null) {
11
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
12
+ }
13
+ const [currentBranch, localHeadSha, data] = await Promise.all([
14
+ getCurrentBranch(),
15
+ getLocalHeadSha(),
16
+ fetchSuggestionThreads(prNumber, repo, opts.suggestions.map((suggestion) => suggestion.threadId)),
17
+ ]);
18
+ await validateHead({ currentBranch, localHeadSha, prNumber, data });
19
+ const threads = data.threads.map((thread, index) => validateSuggestionThread(thread, opts.suggestions[index]));
20
+ const paths = [...new Set(threads.map((thread) => thread.path))];
21
+ const status = await getPathsStatus(paths);
22
+ if (status !== "") {
23
+ throw new ShepherdError(`Suggestion target files have uncommitted changes:\n${status}\n` +
24
+ "Commit or stash them before running build-suggestion-patches.", EXIT.UNAVAILABLE);
25
+ }
26
+ const originals = await readOriginals(data.headRefOid, paths);
27
+ const patches = threads.map((thread, index) => buildSuggestionPatchItem({
28
+ thread,
29
+ request: opts.suggestions[index],
30
+ originalContent: originals.get(thread.path),
31
+ }));
32
+ try {
33
+ await checkPatchesApply(patches.map((patch) => patch.patch));
34
+ }
35
+ catch (error) {
36
+ throw new ShepherdError(`Ordered suggestion patches built from PR head ${data.headRefOid} do not apply to ` +
37
+ `local HEAD ${localHeadSha}: ${errorMessage(error)} No patches were returned; inspect the ` +
38
+ "current source and reviewer intent.", EXIT.UNAVAILABLE);
39
+ }
40
+ return {
41
+ pr: prNumber,
42
+ repo: `${repo.owner}/${repo.name}`,
43
+ patches,
44
+ postActionInstructions: buildBatchInstructions(patches),
45
+ };
46
+ }
47
+ function validateRequests(requests) {
48
+ if (requests.length === 0)
49
+ usage("At least one suggestion is required.");
50
+ const seen = new Set();
51
+ for (const request of requests) {
52
+ if (!request.threadId || request.threadId.trim() === "")
53
+ usage("Each --thread-id is required.");
54
+ if (!request.message || request.message.trim() === "") {
55
+ usage(`--message is required and must be non-empty for thread ${request.threadId}.`);
56
+ }
57
+ if (seen.has(request.threadId))
58
+ usage(`Duplicate thread ID: ${request.threadId}.`);
59
+ seen.add(request.threadId);
60
+ }
61
+ }
62
+ async function validateHead({ currentBranch, localHeadSha, prNumber, data, }) {
63
+ if (!data.headRepoWithOwner) {
64
+ throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
65
+ }
66
+ if (currentBranch !== data.headRefName) {
67
+ throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
68
+ `Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
69
+ }
70
+ if (localHeadSha === data.headRefOid)
71
+ return;
72
+ await ensureDescendant(data.headRefOid, localHeadSha, data.headRefName);
73
+ }
74
+ async function ensureDescendant(prHeadSha, localHeadSha, branch) {
75
+ if (await isAncestor(prHeadSha, localHeadSha))
76
+ return;
77
+ throw new ShepherdError(`Local HEAD ${localHeadSha} is not PR head ${prHeadSha} or its descendant. ` +
78
+ `Pull/rebase "${branch}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
79
+ }
80
+ async function readOriginals(headSha, paths) {
81
+ const entries = await Promise.all(paths.map(async (path) => {
82
+ try {
83
+ return [path, await readPrHeadFile(headSha, path)];
84
+ }
85
+ catch (error) {
86
+ throw new ShepherdError(`Could not read ${path} at PR head ${headSha}: ${errorMessage(error)}`, EXIT.UNAVAILABLE);
87
+ }
88
+ }));
89
+ return new Map(entries);
90
+ }
91
+ function buildBatchInstructions(patches) {
92
+ const patchInstructions = patches.flatMap((patch, index) => [
93
+ `Apply patch ${index + 1} to \`${patch.path}\` using \`git apply\`.`,
94
+ `Stage patch ${index + 1}: \`git add -- ${quotePath(patch.path)}\``,
95
+ `Commit patch ${index + 1}: \`${buildCommitCommand(patch)}\``,
96
+ ]);
97
+ return [
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(", ")}.`,
101
+ ];
102
+ }
103
+ function errorMessage(error) {
104
+ return error instanceof Error ? error.message : String(error);
105
+ }
106
+ function usage(message) {
107
+ throw new ShepherdError(message, EXIT.USAGE);
108
+ }
@@ -1,4 +1,4 @@
1
- query CommitSuggestionThread($owner: String!, $repo: String!, $pr: Int!, $threadId: ID!) {
1
+ query SuggestionThreads($owner: String!, $repo: String!, $pr: Int!, $threadIds: [ID!]!) {
2
2
  repository(owner: $owner, name: $repo) {
3
3
  pullRequest(number: $pr) {
4
4
  headRefOid
@@ -8,7 +8,7 @@ query CommitSuggestionThread($owner: String!, $repo: String!, $pr: Int!, $thread
8
8
  }
9
9
  }
10
10
  }
11
- node(id: $threadId) {
11
+ nodes(ids: $threadIds) {
12
12
  ... on PullRequestReviewThread {
13
13
  id
14
14
  isResolved
@@ -11,7 +11,7 @@ export declare const BATCH_PR_QUERY: string;
11
11
  /** Slim @include follow-up for outstanding batch-query connections. */
12
12
  export declare const BATCH_PR_PAGE_QUERY: string;
13
13
  /** PR head fields plus a single review thread for `commit-suggestion`. */
14
- export declare const COMMIT_SUGGESTION_THREAD_QUERY: string;
14
+ export declare const SUGGESTION_THREADS_QUERY: string;
15
15
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
16
16
  export declare const REVIEW_THREAD_COMMENTS_QUERY: string;
17
17
  /** Fetch inline annotations for a single CheckRun by node ID. */
@@ -14,7 +14,7 @@ export const BATCH_PR_QUERY = gql("batch-pr.gql");
14
14
  /** Slim @include follow-up for outstanding batch-query connections. */
15
15
  export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
16
16
  /** PR head fields plus a single review thread for `commit-suggestion`. */
17
- export const COMMIT_SUGGESTION_THREAD_QUERY = gql("commit-suggestion-thread.gql");
17
+ export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
18
18
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
19
19
  export const REVIEW_THREAD_COMMENTS_QUERY = gql("review-thread-comments.gql");
20
20
  /** Fetch inline annotations for a single CheckRun by node ID. */
@@ -1,9 +1,9 @@
1
1
  import type { ReviewThread } from "../types.mts";
2
2
  import type { RepoInfo } from "./client.mts";
3
- export interface SuggestionThreadResult {
3
+ export interface SuggestionThreadsResult {
4
4
  headRefOid: string;
5
5
  headRefName: string;
6
6
  headRepoWithOwner: string | null;
7
- thread: ReviewThread | null;
7
+ threads: (ReviewThread | null)[];
8
8
  }
9
- export declare function fetchSuggestionThread(pr: number, repo: RepoInfo, threadId: string): Promise<SuggestionThreadResult>;
9
+ export declare function fetchSuggestionThreads(pr: number, repo: RepoInfo, threadIds: readonly string[]): Promise<SuggestionThreadsResult>;
@@ -1,12 +1,12 @@
1
1
  import { graphql } from "./client.mjs";
2
- import { COMMIT_SUGGESTION_THREAD_QUERY } from "./queries.mjs";
2
+ import { SUGGESTION_THREADS_QUERY } from "./queries.mjs";
3
3
  import { mapAuthorType, parseCreatedAt } from "./batch-parser-helpers.mjs";
4
- export async function fetchSuggestionThread(pr, repo, threadId) {
5
- const result = await graphql(COMMIT_SUGGESTION_THREAD_QUERY, {
4
+ export async function fetchSuggestionThreads(pr, repo, threadIds) {
5
+ const result = await graphql(SUGGESTION_THREADS_QUERY, {
6
6
  owner: repo.owner,
7
7
  repo: repo.name,
8
8
  pr,
9
- threadId,
9
+ threadIds,
10
10
  });
11
11
  const pull = result.data.repository.pullRequest;
12
12
  if (!pull) {
@@ -16,7 +16,10 @@ export async function fetchSuggestionThread(pr, repo, threadId) {
16
16
  headRefOid: pull.headRefOid,
17
17
  headRefName: pull.headRefName,
18
18
  headRepoWithOwner: pull.headRepository?.nameWithOwner ?? null,
19
- thread: parseThread(result.data.node, pr, threadId),
19
+ threads: threadIds.map((threadId) => {
20
+ const raw = result.data.nodes.find((candidate) => candidate?.id === threadId) ?? null;
21
+ return parseThread(raw, pr, threadId);
22
+ }),
20
23
  };
21
24
  }
22
25
  function parseThread(raw, pr, threadId) {
@@ -4,5 +4,5 @@ export interface CreatePrShepherdMcpServerOptions extends CreatePrShepherdOption
4
4
  /** Optional injection point for embedding hosts and focused tests. */
5
5
  shepherd?: PrShepherd;
6
6
  }
7
- /** Creates a local-only MCP server with the three public Shepherd operations. */
7
+ /** Creates a local-only MCP server with Shepherd's public operations. */
8
8
  export declare function createPrShepherdMcpServer(options?: CreatePrShepherdMcpServerOptions): McpServer;
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from "../api.mjs";
6
6
  import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
7
7
  import { formatJournalResult } from "../cli/journal-formatter.mjs";
8
- import { formatCommitSuggestionResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
8
+ import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
9
9
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
10
10
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
11
11
  const pr = z
@@ -57,7 +57,17 @@ const suggestionPatchInputSchema = z.object({
57
57
  message: z.string().min(1),
58
58
  description: z.string().optional(),
59
59
  });
60
- /** Creates a local-only MCP server with the three public Shepherd operations. */
60
+ const suggestionPatchesInputSchema = z.object({
61
+ pr,
62
+ suggestions: z
63
+ .array(z.object({
64
+ threadId: z.string().min(1),
65
+ message: z.string().min(1),
66
+ description: z.string().optional(),
67
+ }))
68
+ .min(1),
69
+ });
70
+ /** Creates a local-only MCP server with Shepherd's public operations. */
61
71
  export function createPrShepherdMcpServer(options = {}) {
62
72
  const shepherd = options.shepherd ?? createPrShepherd({ cwd: options.cwd });
63
73
  const server = new McpServer({ name: "pr-shepherd", version: readPackageVersion() });
@@ -81,8 +91,18 @@ export function createPrShepherdMcpServer(options = {}) {
81
91
  openWorldHint: true,
82
92
  },
83
93
  }, async (input) => runTool(() => shepherd.apply(requireRepositoryQualifiedPr(input)), formatApplyResult));
94
+ server.registerTool("build_suggestion_patches", {
95
+ description: "Build, but never apply, an ordered list of eligible review suggestion patches.",
96
+ inputSchema: suggestionPatchesInputSchema,
97
+ annotations: {
98
+ readOnlyHint: true,
99
+ destructiveHint: false,
100
+ idempotentHint: true,
101
+ openWorldHint: true,
102
+ },
103
+ }, async (input) => runTool(() => shepherd.buildSuggestionPatches(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
84
104
  server.registerTool("build_suggestion_patch", {
85
- description: "Build, but never apply, a patch from an eligible review suggestion.",
105
+ description: "Deprecated: use build_suggestion_patches with a one-item suggestions array.",
86
106
  inputSchema: suggestionPatchInputSchema,
87
107
  annotations: {
88
108
  readOnlyHint: true,
@@ -150,25 +150,6 @@ export interface RelevantCheck {
150
150
  /** Marker-gated inline annotations from this check. */
151
151
  annotations?: CheckAnnotation[];
152
152
  }
153
- export interface CommitSuggestionResult {
154
- pr: number;
155
- repo: string;
156
- threadId: string;
157
- path: string;
158
- startLine: number;
159
- endLine: number;
160
- author: string;
161
- /** The unified diff generated for this suggestion. */
162
- patch: string;
163
- /** The commit subject line (user-supplied --message). */
164
- commitMessage: string;
165
- /** The commit body (optional description + Co-authored-by trailer). */
166
- commitBody: string;
167
- /** Files the agent should stage before committing. */
168
- filesToStage: string[];
169
- /** Numbered steps the agent must execute to apply, commit, resolve, and push. */
170
- postActionInstructions: string[];
171
- }
172
153
  export interface GlobalOptions {
173
154
  prNumber?: number;
174
155
  format: "text" | "json";