pr-shepherd 0.40.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +25 -7
  3. package/bin/api.d.mts +13 -2
  4. package/bin/api.mjs +41 -24
  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/journal/extract.d.mts +16 -0
  33. package/bin/journal/extract.mjs +71 -0
  34. package/bin/journal/index.d.mts +1 -0
  35. package/bin/journal/index.mjs +1 -0
  36. package/bin/journal/markdown-line.d.mts +2 -0
  37. package/bin/journal/markdown-line.mjs +4 -2
  38. package/bin/journal/reconcile.d.mts +1 -0
  39. package/bin/journal/reconcile.mjs +11 -8
  40. package/bin/mcp/server.d.mts +1 -1
  41. package/bin/mcp/server.mjs +38 -7
  42. package/bin/pr-reference.d.mts +7 -0
  43. package/bin/pr-reference.mjs +36 -0
  44. package/bin/types/report.d.mts +0 -19
  45. package/bin/types/suggestion-patch.d.mts +30 -0
  46. package/bin/types/suggestion-patch.mjs +1 -0
  47. package/bin/types.d.mts +1 -0
  48. package/bin/types.mjs +1 -0
  49. package/package.json +1 -1
  50. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  51. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  52. package/plugins/pr-shepherd/.mcp.json +1 -1
  53. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +2 -2
  54. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +10 -9
@@ -0,0 +1,83 @@
1
+ export function parseSuggestionPatchGroups(args) {
2
+ const parsed = parseFlags(args);
3
+ if (!parsed.ok)
4
+ return parsed;
5
+ return groupFlags(parsed.flags);
6
+ }
7
+ function parseFlags(args) {
8
+ const flags = [];
9
+ for (let index = 0; index < args.length;) {
10
+ const parsed = parseFlag(args, index);
11
+ if (!parsed)
12
+ return { ok: false, error: `Unknown argument: ${args[index]}` };
13
+ flags.push(parsed);
14
+ index += parsed.consumed;
15
+ }
16
+ return { ok: true, flags };
17
+ }
18
+ function groupFlags(flags) {
19
+ const suggestions = [];
20
+ let current = null;
21
+ for (const flag of flags) {
22
+ if (flag.name === "--thread-id") {
23
+ const finalized = finalize(current);
24
+ if (!finalized.ok)
25
+ return finalized;
26
+ if (finalized.suggestion)
27
+ suggestions.push(finalized.suggestion);
28
+ current = { threadId: flag.value };
29
+ continue;
30
+ }
31
+ const updated = setMetadata(current, flag);
32
+ if (!updated.ok)
33
+ return updated;
34
+ current = updated.current;
35
+ }
36
+ const finalized = finalize(current);
37
+ if (!finalized.ok)
38
+ return finalized;
39
+ if (finalized.suggestion)
40
+ suggestions.push(finalized.suggestion);
41
+ if (suggestions.length === 0)
42
+ return { ok: false, error: "At least one --thread-id is required." };
43
+ return { ok: true, suggestions };
44
+ }
45
+ function setMetadata(current, flag) {
46
+ if (!current)
47
+ return { ok: false, error: `${flag.name} must follow --thread-id.` };
48
+ const property = flag.name === "--message" ? "message" : "description";
49
+ if (current[property] !== undefined) {
50
+ return { ok: false, error: `${flag.name} may appear only once per suggestion.` };
51
+ }
52
+ return { ok: true, current: { ...current, [property]: flag.value } };
53
+ }
54
+ function parseFlag(args, index) {
55
+ const arg = args[index];
56
+ for (const name of ["--thread-id", "--message", "--description"]) {
57
+ if (arg.startsWith(`${name}=`)) {
58
+ return { name, value: arg.slice(name.length + 1), consumed: 1 };
59
+ }
60
+ if (arg === name && index + 1 < args.length) {
61
+ return { name, value: args[index + 1], consumed: 2 };
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ function finalize(current) {
67
+ if (!current)
68
+ return { ok: true, suggestion: null };
69
+ if (!current.threadId || current.threadId.trim() === "") {
70
+ return { ok: false, error: "--thread-id must be non-empty." };
71
+ }
72
+ if (!current.message || current.message.trim() === "") {
73
+ return { ok: false, error: `--message is required for thread ${current.threadId}.` };
74
+ }
75
+ return {
76
+ ok: true,
77
+ suggestion: {
78
+ threadId: current.threadId,
79
+ message: current.message,
80
+ ...(current.description !== undefined && { description: current.description }),
81
+ },
82
+ };
83
+ }
@@ -8,7 +8,7 @@ import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
8
8
  import { isDefaultPollInvocation, validateDefaultPollArgs } from "./cli/default-poll.mjs";
9
9
  import { USAGE, helpKeyForArgs, maybePrintHelp } from "./cli/help.mjs";
10
10
  import { formatMutateResult } from "./cli/formatters.mjs";
11
- import { handleClean, handleCommitSuggestion, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
11
+ import { handleClean, handleCommitSuggestion, handleSuggestionPatches, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
12
12
  import { handleJournal } from "./cli/journal-handler.mjs";
13
13
  import { handlePoll } from "./cli/poll-handler.mjs";
14
14
  import { warnPrrcThreadIds, validateRequireSha, rejectPrrcMinimizeIds, } from "./cli/resolve-validators.mjs";
@@ -54,7 +54,8 @@ export async function main(argv) {
54
54
  resolve: "apply review",
55
55
  "mark-files-as-viewed": "apply files",
56
56
  journal: "apply journal",
57
- "commit-suggestion": "build-suggestion-patch",
57
+ "build-suggestion-patch": "build-suggestion-patches",
58
+ "commit-suggestion": "build-suggestion-patches",
58
59
  };
59
60
  if (subcommand !== undefined && legacyReplacement[subcommand] !== undefined) {
60
61
  warnLegacyAlias(subcommand, legacyReplacement[subcommand]);
@@ -71,6 +72,9 @@ export async function main(argv) {
71
72
  case "apply":
72
73
  await handleApply(args.slice(1));
73
74
  break;
75
+ case "build-suggestion-patches":
76
+ await handleSuggestionPatches(args.slice(1));
77
+ break;
74
78
  case "build-suggestion-patch":
75
79
  await handleCommitSuggestion(args.slice(1));
76
80
  break;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Build the `build-suggestion-patch` instruction step for agent consumers.
2
+ * Build the `build-suggestion-patches` instruction step for agent consumers.
3
3
  * Currently emitted by iterate `fix_code` for suggestion review threads. The CLI keeps
4
4
  * only the trigger and the concrete command; refusal/drift handling is invariant across
5
5
  * every invocation, so it lives in the pr-shepherd skill's "Suggestion patches" playbook
@@ -1,6 +1,6 @@
1
1
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
2
  /**
3
- * Build the `build-suggestion-patch` instruction step for agent consumers.
3
+ * Build the `build-suggestion-patches` instruction step for agent consumers.
4
4
  * Currently emitted by iterate `fix_code` for suggestion review threads. The CLI keeps
5
5
  * only the trigger and the concrete command; refusal/drift handling is invariant across
6
6
  * every invocation, so it lives in the pr-shepherd skill's "Suggestion patches" playbook
@@ -11,7 +11,7 @@ import { buildPrShepherdCommand } from "../cli/runner.mjs";
11
11
  */
12
12
  export function buildCommitSuggestionInstruction(prNumber, sectionName) {
13
13
  const command = buildPrShepherdCommand([
14
- "build-suggestion-patch",
14
+ "build-suggestion-patches",
15
15
  String(prNumber),
16
16
  "--thread-id",
17
17
  "<id>",
@@ -19,5 +19,5 @@ export function buildCommitSuggestionInstruction(prNumber, sectionName) {
19
19
  "<one-sentence headline>",
20
20
  "--format=json",
21
21
  ]).text;
22
- return `For each thread marked \`[suggestion]\` under \`${sectionName}\`, run \`${command}\` and apply the returned patch. See "Suggestion patches" in the pr-shepherd skill for refusals and drift.`;
22
+ return `For all threads marked \`[suggestion]\` under \`${sectionName}\`, run one \`${command}\` command, repeating the \`--thread-id <id> --message <one-sentence headline>\` group in displayed order, then apply the returned patches in order. See "Suggestion patches" in the pr-shepherd skill for refusals and drift.`;
23
23
  }
@@ -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. */