pr-shepherd 0.16.4 → 0.17.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.16.4",
4
+ "version": "0.17.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -20,7 +20,7 @@ Example Workflow:
20
20
 
21
21
  `pr-shepherd` optimizes token management, rate limits, and agentic orchestration by moving **ALL** deterministic logic and prompts to code via a CLI tool, enshrining what would be a large skill or command prompt (of which the agent would inevitably make mistakes) into the code and returning a clear, actionable prompt.
22
22
 
23
- The CLI emits runtime-specific retry instructions. Claude-compatible output schedules exactly one next session-only iteration after a fresh delay between 30 seconds and 4 minutes, then ends the turn. Codex-compatible output sleeps inline for that delay, then reruns the configured pr-shepherd command. Codex is detected with `AGENT=codex` or the current Codex CLI signal `CODEX_CI=1`. Generated commands use `cli.runner` from `.pr-shepherdrc.yml`: `auto` (default), `npx`, `pnpm`, or `yarn`.
23
+ The CLI emits runtime-specific retry instructions. Claude-compatible output schedules exactly one next session-only iteration after a fresh delay between 30 seconds and 4 minutes, then ends the turn. Codex-compatible output sleeps inline for that delay, then reruns the configured pr-shepherd command. Codex is detected with `AGENT=codex` or the current Codex CLI signal `CODEX_CI=1`. Generated commands use `cli.runner` from `.pr-shepherdrc.yml`: `auto` (default), `npx`, `pnpm`, `yarn`, or `bun`.
24
24
 
25
25
  At a high level, the skill invokes `pr-shepherd <PR>` through the selected package runner, which provides actionable feedback directly to the agent:
26
26
 
@@ -136,6 +136,18 @@ npx pr-shepherd iterate 42 # legacy-compatible spelling
136
136
 
137
137
  On each tick: fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, or `wait`). Claude schedules one next session-only iteration after a fresh 30s-4m delay; Codex sleeps inline for that delay and reruns. See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
138
138
 
139
+ ## Cleaning state
140
+
141
+ `pr-shepherd` accumulates state under `$PR_SHEPHERD_STATE_DIR` (seen markers, fix-attempt counters, stall fingerprints, etc.). To reset it:
142
+
143
+ ```sh
144
+ npx pr-shepherd clean current # remove state for the current branch's PR
145
+ npx pr-shepherd clean repo # remove all state for this repo
146
+ npx pr-shepherd clean all # remove all pr-shepherd state
147
+ ```
148
+
149
+ Add `--dry-run` to preview what would be removed. See [docs/cli-usage.md](docs/cli-usage.md) for the full `clean` reference.
150
+
139
151
  ## Install
140
152
 
141
153
  > **Note:** Skill and plugin install methods add the skill definitions only — they do not install the `pr-shepherd` CLI. The skills invoke `pr-shepherd` through the repo package runner, so you also need the CLI available. If you're using `pr-shepherd` as development tooling for your repo, install it as a dev dependency so the selected runner resolves it without prompting:
@@ -143,6 +155,7 @@ On each tick: fetch PR state in one GraphQL batch → classify CI, comments, and
143
155
  > ```bash
144
156
  > pnpm add -D pr-shepherd # pnpm repos
145
157
  > yarn add -D pr-shepherd # yarn repos
158
+ > bun add -d pr-shepherd # bun repos
146
159
  > npm install --save-dev pr-shepherd
147
160
  > ```
148
161
  >
@@ -197,6 +210,7 @@ Install the CLI where Codex will run it:
197
210
  ```bash
198
211
  pnpm add -D pr-shepherd # pnpm repos
199
212
  yarn add -D pr-shepherd # yarn repos
213
+ bun add -d pr-shepherd # bun repos
200
214
  npm install --save-dev pr-shepherd
201
215
  ```
202
216
 
@@ -214,7 +228,7 @@ Then iterate a PR from Codex with the target repository's package runner:
214
228
  <runner> pr-shepherd iterate 42
215
229
  ```
216
230
 
217
- For example, a repo like `~/filaments` that declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml` should use `pnpm exec pr-shepherd iterate 42`. For npm repos, use `npx pr-shepherd iterate 42`.
231
+ For example, a repo like `~/filaments` that declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml` should use `pnpm exec pr-shepherd iterate 42`. For Bun repos (with `bun.lock` or `bun.lockb`), use `bunx pr-shepherd iterate 42`. For npm repos, use `npx pr-shepherd iterate 42`.
218
232
 
219
233
  Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. The skill runs one tick and Codex-compatible instructions tell you to pick a fresh sleep/timeout between 30 seconds and 4 minutes before the next rerun. Continue until Shepherd emits `[CANCEL]` or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `pr-shepherd iterate 42` remains supported for existing workflows.
220
234
 
@@ -0,0 +1,20 @@
1
+ import { joinSections } from "../util/markdown.mjs";
2
+ export function formatCleanResult(result) {
3
+ if (!result.ok) {
4
+ return `Error: ${result.error ?? "unknown error"}`;
5
+ }
6
+ const heading = result.dryRun ? "## Would clean" : "## Cleaned";
7
+ const paths = result.deleted;
8
+ if (result.skipped.length > 0) {
9
+ const label = result.dryRun ? "Nothing to clean (dry-run)" : "Nothing to clean";
10
+ return `${label} — ${result.target} does not exist.`;
11
+ }
12
+ const sections = [
13
+ heading,
14
+ paths.map((p) => `- ${p}`).join("\n"),
15
+ result.dryRun
16
+ ? `Would remove ${paths.length} item(s) under ${result.target}`
17
+ : `Removed ${paths.length} item(s) under ${result.target}`,
18
+ ];
19
+ return joinSections(sections);
20
+ }
@@ -1,7 +1,7 @@
1
1
  import { renderResolveCommand } from "../commands/iterate/render.mjs";
2
2
  import { joinSections } from "../util/markdown.mjs";
3
3
  import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
4
- import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, renderThreadResolutionStatusTag, renderAuthor, } from "./list-formatters.mjs";
4
+ import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, } from "./list-formatters.mjs";
5
5
  import { adaptFixCodeInstructions, numberInstructions } from "./iterate-instructions.mjs";
6
6
  export function formatFixCodeResult(header, result, opts) {
7
7
  const runtime = opts?.runtime ?? "claude";
@@ -92,15 +92,8 @@ export function formatFixCodeResult(header, result, opts) {
92
92
  const firstLookTotal = result.fix.firstLookThreads.length + result.fix.firstLookComments.length;
93
93
  if (firstLookTotal > 0) {
94
94
  sections.push(`## First-look items (${firstLookTotal}) — acknowledge status before acting`);
95
- const bullets = [];
96
- for (const t of result.fix.firstLookThreads) {
97
- bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
98
- }
99
- for (const c of result.fix.firstLookComments) {
100
- const editedSuffix = c.edited ? ", edited" : "";
101
- bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
102
- }
103
- sections.push(bullets.join("\n"));
95
+ const resolutionOnlyIds = new Set(result.fix.resolutionOnlyThreads.map((t) => t.id));
96
+ sections.push(buildFirstLookBullets(result.fix.firstLookThreads, resolutionOnlyIds, result.fix.firstLookComments).join("\n"));
104
97
  }
105
98
  if (result.fix.inProgressRunIds.length > 0) {
106
99
  sections.push("## In-progress runs");
@@ -1,7 +1,8 @@
1
1
  export { formatIterateResult } from "./iterate-formatter.mjs";
2
2
  export { projectIterateLean, projectIterateVerbose } from "./iterate-lean.mjs";
3
+ export { formatCleanResult } from "./clean-formatter.mjs";
3
4
  import { safeFence } from "./fence.mjs";
4
- import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, renderThreadResolutionStatusTag, } from "./list-formatters.mjs";
5
+ import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderThreadResolutionStatusTag, buildFirstLookBullets, } from "./list-formatters.mjs";
5
6
  import { joinSections } from "../util/markdown.mjs";
6
7
  export function formatFetchResult(result) {
7
8
  const activeTotal = result.actionableThreads.length +
@@ -46,15 +47,8 @@ export function formatFetchResult(result) {
46
47
  }
47
48
  if (firstLookTotal > 0) {
48
49
  sections.push(`## First-look items (${firstLookTotal}) — acknowledge status before acting`);
49
- const bullets = [];
50
- for (const t of result.firstLookThreads) {
51
- bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
52
- }
53
- for (const c of result.firstLookComments) {
54
- const editedSuffix = c.edited ? ", edited" : "";
55
- bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
56
- }
57
- sections.push(bullets.join("\n"));
50
+ const resolutionOnlyIds = new Set(result.resolutionOnlyThreads.map((t) => t.id));
51
+ sections.push(buildFirstLookBullets(result.firstLookThreads, resolutionOnlyIds, result.firstLookComments).join("\n"));
58
52
  }
59
53
  sections.push("## Summary");
60
54
  sections.push(total === 0
@@ -108,6 +102,8 @@ export function formatMutateResult(result) {
108
102
  lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
109
103
  if (result.dismissedReviews.length)
110
104
  lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
105
+ if (result.skippedDismissals?.length)
106
+ lines.push(`Skipped dismissals (${result.skippedDismissals.length}): ${result.skippedDismissals.join(", ")}`);
111
107
  if (result.rateLimit) {
112
108
  const details = [
113
109
  result.rateLimit.retryAfterSeconds !== undefined
@@ -1,11 +1,67 @@
1
1
  import { runCommitSuggestion } from "../commands/commit-suggestion.mjs";
2
2
  import { runIterate } from "../commands/iterate/index.mjs";
3
+ import { runClean } from "../commands/clean.mjs";
3
4
  import { loadConfig } from "../config/load.mjs";
4
5
  import { detectAgentRuntime } from "../agent-runtime.mjs";
5
6
  import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
6
7
  import { parseDurationToMinutes, iterateActionToExitCode } from "./exit-codes.mjs";
7
- import { formatCommitSuggestionResult, formatIterateResult, projectIterateLean, projectIterateVerbose, } from "./formatters.mjs";
8
+ import { formatCommitSuggestionResult, formatCleanResult, formatIterateResult, projectIterateLean, projectIterateVerbose, } from "./formatters.mjs";
8
9
  import { validateDurationFlag } from "./duration-flag.mjs";
10
+ const CLEAN_VARIANTS = new Set(["pr", "branch", "current", "repo", "all"]);
11
+ export async function handleClean(args) {
12
+ const variant = args[0];
13
+ if (!variant || !CLEAN_VARIANTS.has(variant)) {
14
+ process.stderr.write("Usage: pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]\n");
15
+ process.exitCode = 1;
16
+ return;
17
+ }
18
+ const rest = args.slice(1);
19
+ for (const a of rest) {
20
+ if (!a.startsWith("--"))
21
+ continue;
22
+ if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
23
+ continue;
24
+ process.stderr.write(`pr-shepherd: clean: unknown flag: "${a}"\n`);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const fmtIdx = rest.indexOf("--format");
29
+ const fmtEqEntry = rest.find((a) => a.startsWith("--format="));
30
+ let formatValue;
31
+ if (fmtEqEntry !== undefined) {
32
+ formatValue = fmtEqEntry.slice("--format=".length);
33
+ }
34
+ else if (fmtIdx !== -1 && fmtIdx + 1 < rest.length && !rest[fmtIdx + 1].startsWith("--")) {
35
+ formatValue = rest[fmtIdx + 1];
36
+ }
37
+ if (formatValue !== undefined && formatValue !== "text" && formatValue !== "json") {
38
+ process.stderr.write(`pr-shepherd: clean: invalid --format value: "${formatValue}". Expected "text" or "json".\n`);
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ const jsonOut = formatValue === "json";
43
+ const dryRun = rest.includes("--dry-run");
44
+ // Skip the value consumed by --format <value> so it isn't mistaken for the positional.
45
+ const flagConsumedIndices = new Set();
46
+ if (fmtIdx !== -1 && fmtIdx + 1 < rest.length && !rest[fmtIdx + 1].startsWith("--")) {
47
+ flagConsumedIndices.add(fmtIdx);
48
+ flagConsumedIndices.add(fmtIdx + 1);
49
+ }
50
+ const positionals = rest.filter((a, i) => !flagConsumedIndices.has(i) && !a.startsWith("--"));
51
+ if (positionals.length > 1) {
52
+ process.stderr.write(`pr-shepherd: clean: too many positional arguments (expected at most 1, got ${positionals.length})\n`);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ const value = positionals[0];
57
+ const result = await runClean({ variant: variant, value, dryRun });
58
+ if (!result.ok) {
59
+ process.stderr.write(`pr-shepherd: clean: ${result.error}\n`);
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${formatCleanResult(result)}\n`);
64
+ }
9
65
  export async function handleCommitSuggestion(args) {
10
66
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
11
67
  const threadId = getFlag(extra, "--thread-id");
@@ -4,7 +4,7 @@ function buildRecheckInstruction(runtime, rerunCommand, purpose) {
4
4
  if (runtime === "codex") {
5
5
  return `Pick a fresh sleep/timeout between 30 seconds and 4 minutes, wait that long, then rerun \`${rerunCommand}\` to ${purpose}.`;
6
6
  }
7
- return `Schedule one session-only follow-up task to run \`${rerunCommand}\` to ${purpose} once after a fresh delay between 30 seconds and 4 minutes, then end this turn. Do not sleep or rerun inline.`;
7
+ return `Single-shot continuation: schedule one session-only follow-up task to run \`${rerunCommand}\` to ${purpose} once after a fresh delay between 30 seconds and 4 minutes, then end this turn. Do not sleep or rerun inline.`;
8
8
  }
9
9
  export function buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner) {
10
10
  const rerunCommand = buildIterateCommand(result.pr, readyDelaySuffix, runner);
@@ -18,7 +18,9 @@ export function buildSimpleIterateInstructions(result, runtime, readyDelaySuffix
18
18
  case "cancel":
19
19
  return ["Stop — the active goal is complete."];
20
20
  case "escalate":
21
- return ["Stop — the PR needs human direction before iterating can resume."];
21
+ return [
22
+ "Stop — the PR needs human direction before iterating can resume. This is a manual handoff; do not continue automated fix attempts.",
23
+ ];
22
24
  }
23
25
  }
24
26
  export function adaptFixCodeInstructions(instructions, pr, runtime, readyDelaySuffix, runner) {
@@ -27,7 +27,8 @@ export function renderThreadBullet(t, opts = {}) {
27
27
  : "`(no location)`";
28
28
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
29
29
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
30
- const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}: ${renderBodyPreview(t.body)}`;
30
+ const bodySuffix = opts.noBody ? "" : `: ${renderBodyPreview(t.body)}`;
31
+ const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}${bodySuffix}`;
31
32
  if (t.suggestion && opts.renderSuggestion) {
32
33
  return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
33
34
  }
@@ -47,3 +48,22 @@ export function renderReviewListSection(heading, items) {
47
48
  return null;
48
49
  return `## ${heading}\n\n${items.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n")}`;
49
50
  }
51
+ /**
52
+ * Build bullet strings for the `## First-look items` section.
53
+ * Threads that also appear in resolutionOnlyIds have their body suppressed
54
+ * (already shown in `## Review threads to resolve`).
55
+ */
56
+ export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, firstLookComments) {
57
+ const bullets = [];
58
+ for (const t of firstLookThreads) {
59
+ bullets.push(renderThreadBullet(t, {
60
+ statusTag: renderFirstLookStatusTag(t),
61
+ noBody: resolutionOnlyIds.has(t.id),
62
+ }));
63
+ }
64
+ for (const c of firstLookComments) {
65
+ const editedSuffix = c.edited ? ", edited" : "";
66
+ bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
67
+ }
68
+ return bullets;
69
+ }
@@ -10,16 +10,18 @@ export function resolveCliRunner(runner, cwd = process.cwd()) {
10
10
  const configured = parseCliRunner(runner);
11
11
  return configured === "auto" ? detectPackageRunner(cwd) : configured;
12
12
  }
13
+ const VALID_RUNNERS = ["auto", "npx", "pnpm", "yarn", "bun"];
14
+ const VALID_RUNNERS_LIST = VALID_RUNNERS.map((v) => `"${v}"`).join(", ");
13
15
  export function parseCliRunner(runner) {
14
16
  if (runner === undefined)
15
17
  return "auto";
16
18
  if (typeof runner !== "string") {
17
- throw new Error(`Invalid config: cli.runner must be one of "auto", "npx", "pnpm", or "yarn", got ${JSON.stringify(runner)}`);
19
+ throw new Error(`Invalid config: cli.runner must be one of ${VALID_RUNNERS_LIST}, got ${JSON.stringify(runner)}`);
18
20
  }
19
21
  const value = runner.trim();
20
- if (value === "auto" || value === "npx" || value === "pnpm" || value === "yarn")
22
+ if (VALID_RUNNERS.includes(value))
21
23
  return value;
22
- throw new Error(`Invalid config: cli.runner must be one of "auto", "npx", "pnpm", or "yarn", got ${JSON.stringify(runner)}`);
24
+ throw new Error(`Invalid config: cli.runner must be one of ${VALID_RUNNERS_LIST}, got ${JSON.stringify(runner)}`);
23
25
  }
24
26
  export function renderShellCommand(argv) {
25
27
  return argv.map(renderShellArg).join(" ");
@@ -32,6 +34,8 @@ function baseArgvForRunner(runner) {
32
34
  return ["pnpm", "exec", "pr-shepherd"];
33
35
  case "yarn":
34
36
  return ["yarn", "run", "pr-shepherd"];
37
+ case "bun":
38
+ return ["bunx", "pr-shepherd"];
35
39
  }
36
40
  }
37
41
  const runnerCache = new Map();
@@ -58,10 +62,16 @@ function detectPackageRunner(startDir) {
58
62
  return cacheRunner(startDir, "yarn");
59
63
  if (packageManager?.startsWith("npm@"))
60
64
  return cacheRunner(startDir, "npx");
65
+ if (packageManager?.startsWith("bun@"))
66
+ return cacheRunner(startDir, "bun");
61
67
  if (isFile(join(current, "pnpm-lock.yaml")))
62
68
  return cacheRunner(startDir, "pnpm");
63
69
  if (isFile(join(current, "yarn.lock")))
64
70
  return cacheRunner(startDir, "yarn");
71
+ if (isFile(join(current, "bun.lock")))
72
+ return cacheRunner(startDir, "bun");
73
+ if (isFile(join(current, "bun.lockb")))
74
+ return cacheRunner(startDir, "bun");
65
75
  if (isFile(join(current, "package-lock.json")))
66
76
  return cacheRunner(startDir, "npx");
67
77
  }
@@ -0,0 +1,45 @@
1
+ // @ts-nocheck
2
+ import { vi, beforeEach, afterEach } from "vitest";
3
+ vi.mock("./commands/clean.mts", () => ({
4
+ runClean: vi.fn(),
5
+ }));
6
+ vi.mock("./commands/resolve.mts", () => ({
7
+ runResolveFetch: vi.fn(),
8
+ runResolveMutate: vi.fn(),
9
+ }));
10
+ vi.mock("./commands/log-file.mts", () => ({
11
+ runLogFile: vi.fn(),
12
+ }));
13
+ vi.mock("./commands/commit-suggestion.mts", () => ({
14
+ runCommitSuggestion: vi.fn(),
15
+ }));
16
+ vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
17
+ const actual = await importOriginal();
18
+ return { ...actual, runIterate: vi.fn() };
19
+ });
20
+ import { main } from "./cli-parser.mjs";
21
+ import { runClean } from "./commands/clean.mjs";
22
+ export const mockRunClean = vi.mocked(runClean);
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ let stdoutSpy;
25
+ let stderrSpy;
26
+ export function getStdout() {
27
+ return stdoutSpy.mock.calls.map((c) => c[0]).join("");
28
+ }
29
+ export function getStderr() {
30
+ return stderrSpy.mock.calls.map((c) => c[0]).join("");
31
+ }
32
+ export function registerHooks() {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ process.exitCode = undefined;
36
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
37
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
38
+ });
39
+ afterEach(() => {
40
+ process.exitCode = undefined;
41
+ stdoutSpy.mockRestore();
42
+ stderrSpy.mockRestore();
43
+ });
44
+ }
45
+ export { main, stderrSpy, stdoutSpy };
@@ -64,7 +64,7 @@ export function makeIterateResult(action = "wait") {
64
64
  ambiguousComments: [],
65
65
  changesRequestedReviews: [],
66
66
  suggestion: "check manually",
67
- humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — needs human direction",
67
+ humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required",
68
68
  },
69
69
  };
70
70
  }
@@ -14,6 +14,7 @@
14
14
  * pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
15
15
  * [--stall-timeout <duration>] [--no-auto-mark-ready]
16
16
  * [--no-auto-cancel-actionable]
17
+ * pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]
17
18
  */
18
19
  import { readFileSync } from "node:fs";
19
20
  import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
@@ -21,7 +22,7 @@ import { runLogFile } from "./commands/log-file.mjs";
21
22
  import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
22
23
  import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
23
24
  import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
24
- import { handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
25
+ import { handleClean, handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
25
26
  import { setupLog } from "./log/setup.mjs";
26
27
  // ---------------------------------------------------------------------------
27
28
  // Entry
@@ -56,9 +57,12 @@ export async function main(argv) {
56
57
  case "iterate":
57
58
  await handleIterate(args.slice(1));
58
59
  break;
60
+ case "clean":
61
+ await handleClean(args.slice(1));
62
+ break;
59
63
  default:
60
64
  process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
61
- process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|log-file> [options]\n" +
65
+ process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|log-file|clean> [options]\n" +
62
66
  " pr-shepherd --version | -v\n");
63
67
  process.exitCode = 1;
64
68
  return;
@@ -0,0 +1,156 @@
1
+ import { rm, readdir, realpath, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { resolveStateBase } from "../state/base.mjs";
4
+ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch, getPrNumberForBranch, } from "../github/client.mjs";
5
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
6
+ export async function runClean(opts) {
7
+ const dryRun = opts.dryRun ?? false;
8
+ const rawBase = resolveStateBase();
9
+ const base = await realpath(rawBase).catch(() => rawBase);
10
+ let target;
11
+ try {
12
+ target = await resolveTarget(base, opts);
13
+ }
14
+ catch (e) {
15
+ return {
16
+ ok: false,
17
+ variant: opts.variant,
18
+ dryRun,
19
+ base,
20
+ target: "",
21
+ deleted: [],
22
+ skipped: [],
23
+ error: e instanceof Error ? e.message : String(e),
24
+ };
25
+ }
26
+ let targetExists = false;
27
+ try {
28
+ await stat(target);
29
+ targetExists = true;
30
+ }
31
+ catch (e) {
32
+ if (e.code !== "ENOENT") {
33
+ return {
34
+ ok: false,
35
+ variant: opts.variant,
36
+ dryRun,
37
+ base,
38
+ target,
39
+ deleted: [],
40
+ skipped: [],
41
+ error: `Failed to stat target: ${e.message}`,
42
+ };
43
+ }
44
+ }
45
+ if (!targetExists) {
46
+ return {
47
+ ok: true,
48
+ variant: opts.variant,
49
+ dryRun,
50
+ base,
51
+ target,
52
+ deleted: [],
53
+ skipped: [target],
54
+ };
55
+ }
56
+ let entries;
57
+ try {
58
+ const names = await readdir(target);
59
+ entries = names.map((n) => join(target, n));
60
+ }
61
+ catch {
62
+ entries = [target];
63
+ }
64
+ if (dryRun) {
65
+ return {
66
+ ok: true,
67
+ variant: opts.variant,
68
+ dryRun: true,
69
+ base,
70
+ target,
71
+ deleted: entries,
72
+ skipped: [],
73
+ };
74
+ }
75
+ try {
76
+ await rm(target, { recursive: true, force: true });
77
+ }
78
+ catch (e) {
79
+ return {
80
+ ok: false,
81
+ variant: opts.variant,
82
+ dryRun: false,
83
+ base,
84
+ target,
85
+ deleted: [],
86
+ skipped: [],
87
+ error: `Failed to remove target: ${e.message}`,
88
+ };
89
+ }
90
+ return {
91
+ ok: true,
92
+ variant: opts.variant,
93
+ dryRun: false,
94
+ base,
95
+ target,
96
+ deleted: entries,
97
+ skipped: [],
98
+ };
99
+ }
100
+ async function resolveTarget(base, opts) {
101
+ const { variant, value } = opts;
102
+ if (variant === "all") {
103
+ if (value !== undefined) {
104
+ throw new Error(`"clean all" does not accept a positional argument; got "${value}". Did you mean "clean repo" or "clean pr"?`);
105
+ }
106
+ return base;
107
+ }
108
+ const repo = await getRepoInfo();
109
+ const { owner, name } = repo;
110
+ for (const [field, val] of [
111
+ ["owner", owner],
112
+ ["repo", name],
113
+ ]) {
114
+ if (!SAFE_SEGMENT.test(val)) {
115
+ throw new Error(`Invalid repository segment "${field}": ${val}`);
116
+ }
117
+ }
118
+ const ownerRepo = `${owner}-${name}`;
119
+ if (variant === "repo") {
120
+ if (value !== undefined) {
121
+ throw new Error(`"clean repo" does not accept a positional argument; got "${value}". Did you mean "clean pr" or "clean branch"?`);
122
+ }
123
+ return join(base, ownerRepo);
124
+ }
125
+ let prNumber;
126
+ if (variant === "pr") {
127
+ if (value !== undefined) {
128
+ const n = parseInt(value, 10);
129
+ if (!Number.isFinite(n) || n <= 0 || String(n) !== value.trim()) {
130
+ throw new Error(`Invalid PR number: ${value}`);
131
+ }
132
+ prNumber = n;
133
+ }
134
+ else {
135
+ const n = await getCurrentPrNumber();
136
+ if (n === null)
137
+ throw new Error("No open PR found for current branch");
138
+ prNumber = n;
139
+ }
140
+ }
141
+ else {
142
+ // "branch" or "current"
143
+ if (variant === "current" && value !== undefined) {
144
+ throw new Error(`"clean current" does not accept a positional argument; got "${value}". Did you mean "clean branch"?`);
145
+ }
146
+ const branchName = value ?? (await getCurrentBranch());
147
+ if (branchName === "HEAD") {
148
+ throw new Error("Could not resolve current branch (detached HEAD)");
149
+ }
150
+ const n = await getPrNumberForBranch(branchName, owner, name);
151
+ if (n === null)
152
+ throw new Error(`No open PR found for branch: ${branchName}`);
153
+ prNumber = n;
154
+ }
155
+ return join(base, ownerRepo, String(prNumber));
156
+ }
@@ -0,0 +1,48 @@
1
+ // @ts-nocheck
2
+ import { vi, beforeEach, afterEach } from "vitest";
3
+ import { join } from "node:path";
4
+ import { mkdtemp, realpath, rm, mkdir, writeFile, stat } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ vi.mock("../github/client.mts", () => ({
7
+ getRepoInfo: vi.fn().mockResolvedValue({ owner: "acme", name: "widgets" }),
8
+ getCurrentBranch: vi.fn().mockResolvedValue("feature/test"),
9
+ getCurrentPrNumber: vi.fn().mockResolvedValue(42),
10
+ getPrNumberForBranch: vi.fn().mockResolvedValue(42),
11
+ }));
12
+ import { getRepoInfo, getCurrentBranch, getCurrentPrNumber, getPrNumberForBranch, } from "../github/client.mjs";
13
+ export const mockGetRepoInfo = vi.mocked(getRepoInfo);
14
+ export const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
15
+ export const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
16
+ export const mockGetPrNumberForBranch = vi.mocked(getPrNumberForBranch);
17
+ export let stateDir;
18
+ export function registerHooks() {
19
+ beforeEach(async () => {
20
+ const tmpPath = await mkdtemp(join(tmpdir(), "shepherd-clean-test-"));
21
+ stateDir = await realpath(tmpPath);
22
+ process.env["PR_SHEPHERD_STATE_DIR"] = stateDir;
23
+ vi.clearAllMocks();
24
+ mockGetRepoInfo.mockResolvedValue({ owner: "acme", name: "widgets" });
25
+ mockGetCurrentBranch.mockResolvedValue("feature/test");
26
+ mockGetCurrentPrNumber.mockResolvedValue(42);
27
+ mockGetPrNumberForBranch.mockResolvedValue(42);
28
+ });
29
+ afterEach(async () => {
30
+ delete process.env["PR_SHEPHERD_STATE_DIR"];
31
+ await rm(stateDir, { recursive: true, force: true });
32
+ });
33
+ }
34
+ export async function seedPrDir(dir, pr) {
35
+ const prDir = join(dir, "acme-widgets", String(pr));
36
+ await mkdir(join(prDir, "seen"), { recursive: true });
37
+ await writeFile(join(prDir, "fix-attempts.json"), "{}", "utf8");
38
+ return prDir;
39
+ }
40
+ export async function pathExists(p) {
41
+ try {
42
+ await stat(p);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
@@ -1,5 +1,16 @@
1
1
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
2
2
  import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
3
+ function dedupeIds(ids) {
4
+ const seen = new Set();
5
+ const out = [];
6
+ for (const id of ids) {
7
+ if (seen.has(id))
8
+ continue;
9
+ seen.add(id);
10
+ out.push(id);
11
+ }
12
+ return out;
13
+ }
3
14
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
4
15
  // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
5
16
  // they are already minimized server-side (body changed after minimize was applied).
@@ -28,26 +39,43 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
28
39
  }
29
40
  export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber, runner) {
30
41
  const argv = buildPrShepherdCommand(["resolve", String(prNumber)], { runner }).argv;
31
- const threadIds = [...threads.map((t) => t.id), ...resolutionOnlyThreads.map((t) => t.id)];
42
+ const resolveThreadIds = dedupeIds(threads.map((t) => t.id));
43
+ const threadIds = dedupeIds([...resolveThreadIds, ...resolutionOnlyThreads.map((t) => t.id)]);
32
44
  if (threadIds.length > 0) {
33
45
  argv.push("--resolve-thread-ids", threadIds.join(","));
34
46
  }
35
47
  if (allCommentIds.length > 0) {
36
48
  argv.push("--minimize-comment-ids", allCommentIds.join(","));
37
49
  }
38
- const hasDismiss = reviews.length > 0;
50
+ const commentIdSet = new Set(allCommentIds);
51
+ const filteredReviewIds = [];
52
+ const droppedDismissReviewIds = [];
53
+ for (const review of reviews) {
54
+ if (commentIdSet.has(review.id))
55
+ droppedDismissReviewIds.push(review.id);
56
+ else
57
+ filteredReviewIds.push(review.id);
58
+ }
59
+ const hasDismiss = filteredReviewIds.length > 0;
39
60
  if (hasDismiss) {
40
- argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
61
+ argv.push("--dismiss-review-ids", filteredReviewIds.join(","));
41
62
  argv.push("--message", "$DISMISS_MESSAGE");
42
63
  }
43
- // A push is required when threads, CI failures, or changes-requested reviews are present — the
44
- // CLI knows those imply code edits. Comments are surfaced for the agent to evaluate; the CLI
45
- // cannot know whether a given comment will require a push, so comments are excluded here.
46
- const requiresHeadSha = threads.length > 0 || checks.length > 0 || reviews.length > 0;
47
64
  // hasMutations = we appended at least one of --resolve-thread-ids,
48
65
  // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
49
66
  // (rather than derived from argv.length) so callers don't couple to the
50
67
  // base-argv shape.
51
- const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
52
- return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
68
+ const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || filteredReviewIds.length > 0;
69
+ // `requiresHeadSha` is only added when this resolve command includes a
70
+ // mutation that can race with a moving HEAD: resolving actionable threads,
71
+ // dismissing CHANGES_REQUESTED reviews, or addressing failing checks.
72
+ const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0 || filteredReviewIds.length > 0);
73
+ const requiresHeadSha = hasCodeMutations;
74
+ return {
75
+ argv,
76
+ requiresHeadSha,
77
+ requiresDismissMessage: hasDismiss,
78
+ ...(droppedDismissReviewIds.length > 0 ? { droppedDismissReviewIds } : undefined),
79
+ hasMutations,
80
+ };
53
81
  }
@@ -55,7 +55,7 @@ export function validateBaseBranch(raw) {
55
55
  }
56
56
  export function buildEscalateHumanMessage(escalate, pr) {
57
57
  const lines = [];
58
- lines.push("⚠️ /pr-shepherd:pr-shepherd paused — needs human direction");
58
+ lines.push("⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required");
59
59
  lines.push("");
60
60
  lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
61
61
  lines.push("");
@@ -66,6 +66,7 @@ export function buildEscalateHumanMessage(escalate, pr) {
66
66
  if (hasItems) {
67
67
  lines.push("");
68
68
  lines.push("## Items needing attention");
69
+ lines.push("");
69
70
  for (const t of escalate.unresolvedThreads) {
70
71
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
71
72
  const firstLine = t.body.split("\n")[0] ?? "";
@@ -83,6 +84,7 @@ export function buildEscalateHumanMessage(escalate, pr) {
83
84
  if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
84
85
  lines.push("");
85
86
  lines.push("## Fix attempts");
87
+ lines.push("");
86
88
  for (const a of escalate.thrashHistory) {
87
89
  lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
88
90
  }
@@ -90,26 +92,26 @@ export function buildEscalateHumanMessage(escalate, pr) {
90
92
  lines.push("");
91
93
  lines.push("---");
92
94
  lines.push("");
93
- lines.push(`After fixing manually, rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
95
+ lines.push(`After completing manual fixes (and pushing if required), rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
94
96
  return lines.join("\n");
95
97
  }
96
98
  export function buildEscalateSuggestion(triggers, detail) {
97
99
  if (triggers.includes("stall-timeout")) {
98
100
  const mins = detail ?? "30";
99
- return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. Inspect the PR and resume manually once the blocking issue is resolved.`;
101
+ return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
100
102
  }
101
103
  if (triggers.includes("base-branch-unknown")) {
102
104
  const reason = detail ? ` (${detail})` : "";
103
- return `Could not determine the PR's base branch${reason} — refusing to emit a rebase that could force-push onto the wrong base. Run the rebase manually against the PR's real target branch.`;
105
+ return `Could not determine the PR's base branch${reason} — automated rebases are paused because branch safety is unclear. Run the rebase manually against the PR's real target branch.`;
104
106
  }
105
107
  if (triggers.includes("fix-thrash")) {
106
- return "Same thread(s) attempted multiple times without resolutionfix manually then rerun /pr-shepherd:pr-shepherd";
108
+ return "Same thread(s) reached the automated attempt limittreat this as a manual handoff. Apply the fix by hand.";
107
109
  }
108
110
  if (triggers.includes("pr-level-changes-requested")) {
109
- return "Reviewer requested changes but left no inline comments — read the review and act manually";
111
+ return "Reviewer requested changes but left no inline comments — read the review and act manually.";
110
112
  }
111
113
  if (triggers.includes("thread-missing-location")) {
112
- return "Review thread has no file/line reference — cannot locate code to edit automatically";
114
+ return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
113
115
  }
114
- return "Ambiguous state — inspect the PR and act manually";
116
+ return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
115
117
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
2
3
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
3
4
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
@@ -52,14 +53,24 @@ export async function handleFixCode(ctx) {
52
53
  const checks = toAgentChecks(failingChecks);
53
54
  const { changesRequestedReviews } = report;
54
55
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
55
- const hasGuaranteedSupersedingPush = threads.length > 0 || checks.length > 0 || changesRequestedReviews.length > 0 || hasConflicts;
56
- const inProgressRunIds = hasGuaranteedSupersedingPush
57
- ? buildInProgressRunIds(report, cancelledSet)
58
- : [];
56
+ const hasReviewRequestedCodeLikeChanges = changesRequestedReviews.length > 0 &&
57
+ (actionableComments.length > 0 || resolutionOnlyThreads.length > 0);
58
+ const hasGuaranteedPush = threads.length > 0 || checks.length > 0 || hasConflicts || hasReviewRequestedCodeLikeChanges;
59
+ const shouldPush = hasGuaranteedPush;
60
+ // Only cancel in-progress runs for paths that produce a new code commit. A
61
+ // conflict-only rebase push will supersede any in-progress run on its own.
62
+ const hasCodeLikePush = threads.length > 0 || checks.length > 0 || hasReviewRequestedCodeLikeChanges;
63
+ const inProgressRunIds = hasCodeLikePush ? buildInProgressRunIds(report, cancelledSet) : [];
59
64
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
60
65
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
61
66
  const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
62
- if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
67
+ const overlappingReviewIds = resolveCommand.droppedDismissReviewIds ?? [];
68
+ if (overlappingReviewIds.length > 0) {
69
+ process.stderr.write(`pr-shepherd: resolve command overlap: ${overlappingReviewIds.length} ` +
70
+ `review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
71
+ `${overlappingReviewIds.join(", ")}\n`);
72
+ }
73
+ if (baseLookup.isFallback && shouldPush) {
63
74
  const fallbackEscalateBase = {
64
75
  triggers: ["base-branch-unknown"],
65
76
  unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
@@ -78,7 +89,7 @@ export async function handleFixCode(ctx) {
78
89
  }
79
90
  const firstLookThreads = report.threads.firstLook;
80
91
  const firstLookComments = report.comments.firstLook;
81
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner);
92
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner, shouldPush);
82
93
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
83
94
  ...base,
84
95
  baseBranch: baseLookup.branch,
@@ -16,8 +16,10 @@ export function renderResolveCommand(rc) {
16
16
  }
17
17
  return renderShellCommand(parts);
18
18
  }
19
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
19
+ export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner, needsPushInput) {
20
20
  const instructions = [];
21
+ const hasCodeWork = threads.length > 0 || checks.length > 0;
22
+ const needsPush = needsPushInput ?? (hasCodeWork || hasConflicts);
21
23
  if (inProgressRunIds.length > 0) {
22
24
  instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\` before applying code fixes. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
23
25
  }
@@ -35,15 +37,18 @@ export function buildFixInstructions(threads, actionableComments, checks, review
35
37
  instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
36
38
  }
37
39
  instructions.push(...buildFailingCheckInstructions(checks));
38
- if (reviews.length > 0) {
40
+ if (changesRequestedReviews.length > 0) {
39
41
  instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
40
42
  }
41
- const hasCodeChanges = threads.length > 0 || checks.length > 0 || reviews.length > 0;
42
- const needsPush = hasCodeChanges || hasConflicts;
43
- if (hasCodeChanges) {
43
+ if (needsPush && (hasCodeWork || changesRequestedReviews.length > 0)) {
44
44
  instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
45
+ }
46
+ if (changesRequestedReviews.length > 0) {
45
47
  instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
46
48
  }
49
+ if (!needsPush && resolveCommand.requiresHeadSha) {
50
+ instructions.push("Capture the current HEAD SHA before resolving with: `HEAD_SHA=$(git rev-parse HEAD)`.");
51
+ }
47
52
  if (needsPush) {
48
53
  const captureHint = resolveCommand.requiresHeadSha
49
54
  ? ` — capture \`HEAD_SHA=$(git rev-parse HEAD)\``
@@ -71,7 +76,8 @@ export function buildFixInstructions(threads, actionableComments, checks, review
71
76
  if (resolveCommand.hasMutations) {
72
77
  const substituteParts = [];
73
78
  if (resolveCommand.requiresHeadSha) {
74
- substituteParts.push(`"$HEAD_SHA" with the pushed commit SHA`);
79
+ const shaSource = needsPush ? "pushed commit SHA" : "current HEAD SHA";
80
+ substituteParts.push(`"$HEAD_SHA" with the ${shaSource}`);
75
81
  }
76
82
  if (resolveCommand.requiresDismissMessage) {
77
83
  substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
@@ -1,9 +1,48 @@
1
+ /* eslint-disable max-lines */
1
2
  import { graphqlWithRateLimit } from "../github/client.mjs";
2
3
  import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "./rate-limit.mjs";
3
4
  import { setPendingOps } from "./pending-ops.mjs";
4
5
  import { waitForSha } from "./sha-poll.mjs";
6
+ const COMMENTED_DISMISS_ERROR_PATTERNS = [
7
+ /can\s*not\s+dismiss[\s\S]*?commented pull request review/i,
8
+ ];
9
+ function dedupeIds(ids) {
10
+ const seen = new Set();
11
+ const out = [];
12
+ for (const id of ids) {
13
+ if (seen.has(id))
14
+ continue;
15
+ seen.add(id);
16
+ out.push(id);
17
+ }
18
+ return out;
19
+ }
20
+ function isCommentedDismissError(message) {
21
+ return COMMENTED_DISMISS_ERROR_PATTERNS.some((pattern) => pattern.test(message));
22
+ }
23
+ function dismissReviewNonDismissibleMessage(id) {
24
+ return `Not dismissed: ${id} is a COMMENTED review. Use --minimize-comment-ids instead; --dismiss-review-ids is only for CHANGES_REQUESTED reviews.`;
25
+ }
5
26
  export async function applyResolveOptions(pr, repo, opts) {
6
- if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
27
+ const resolveThreadIds = dedupeIds(opts.resolveThreadIds ?? []);
28
+ const minimizeCommentIds = opts.minimizeCommentIds ?? [];
29
+ const dismissReviewIds = dedupeIds(opts.dismissReviewIds ?? []);
30
+ const minimizeCommentIdSet = new Set(minimizeCommentIds);
31
+ const filteredDismissReviewIds = dismissReviewIds.filter((id) => !minimizeCommentIdSet.has(id));
32
+ const overlappingDismissIds = dismissReviewIds.filter((id) => minimizeCommentIdSet.has(id));
33
+ const result = {
34
+ resolvedThreads: [],
35
+ minimizedComments: [],
36
+ dismissedReviews: [],
37
+ errors: [],
38
+ };
39
+ if (overlappingDismissIds.length > 0) {
40
+ result.skippedDismissals = [];
41
+ for (const id of overlappingDismissIds) {
42
+ result.skippedDismissals.push(id);
43
+ }
44
+ }
45
+ if (filteredDismissReviewIds.length > 0 && !opts.dismissMessage) {
7
46
  throw new Error("--message is required when dismissing reviews");
8
47
  }
9
48
  if (opts.requireSha) {
@@ -11,13 +50,7 @@ export async function applyResolveOptions(pr, repo, opts) {
11
50
  // before reviewers see the fix.
12
51
  await waitForSha(pr, repo, opts.requireSha);
13
52
  }
14
- const result = {
15
- resolvedThreads: [],
16
- minimizedComments: [],
17
- dismissedReviews: [],
18
- errors: [],
19
- };
20
- await bulkApply(opts.resolveThreadIds ?? [], opts.minimizeCommentIds ?? [], opts.dismissReviewIds ?? [], opts.dismissMessage ?? "", result);
53
+ await bulkApply(resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
21
54
  return result;
22
55
  }
23
56
  export async function autoResolveOutdated(threadIds) {
@@ -65,13 +98,15 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
65
98
  if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
66
99
  return false;
67
100
  const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
68
- let data;
101
+ let data = {};
102
+ let graphQlErrors = [];
69
103
  let rateLimitStop;
70
104
  let suppressCurrentChunkErrors = false;
71
105
  try {
72
106
  const resp = await graphqlWithRateLimit(doc, {});
73
107
  data = resp.data;
74
- const graphQlErrorMessages = resp.errors?.map((e) => e.message) ?? [];
108
+ graphQlErrors = (resp.errors ?? []);
109
+ const graphQlErrorMessages = graphQlErrors.map((e) => e.message);
75
110
  suppressCurrentChunkErrors = graphQlErrorMessages.some(isRateLimitMessage);
76
111
  rateLimitStop = rateLimitFromGraphQlResult(graphQlErrorMessages, {
77
112
  rateLimit: resp.rateLimit,
@@ -109,12 +144,27 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
109
144
  else if (!suppressCurrentChunkErrors)
110
145
  result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
111
146
  }
147
+ const singleDismiss = dismissIds.length === 1;
148
+ const commentedDismissErrorIndexes = new Set();
149
+ let hasUnmappedCommentedDismissError = false;
150
+ for (const error of graphQlErrors) {
151
+ if (!isCommentedDismissError(error.message))
152
+ continue;
153
+ const alias = dismissErrorAliasIndex(error);
154
+ if (alias === undefined) {
155
+ hasUnmappedCommentedDismissError = true;
156
+ continue;
157
+ }
158
+ commentedDismissErrorIndexes.add(alias);
159
+ }
112
160
  for (let i = 0; i < dismissIds.length; i++) {
113
161
  const d = data[`d${i}`];
114
162
  if (d?.pullRequestReview != null)
115
163
  result.dismissedReviews.push(dismissIds[i]);
116
164
  else if (!suppressCurrentChunkErrors)
117
- result.errors.push(`${dismissIds[i]}: dismiss returned null`);
165
+ result.errors.push(commentedDismissErrorIndexes.has(i) || (singleDismiss && hasUnmappedCommentedDismissError)
166
+ ? dismissReviewNonDismissibleMessage(dismissIds[i])
167
+ : `${dismissIds[i]}: dismiss returned null`);
118
168
  }
119
169
  if (rateLimitStop) {
120
170
  result.errors.push(`rate limit: ${rateLimitStop.message}`);
@@ -123,3 +173,12 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
123
173
  }
124
174
  return false;
125
175
  }
176
+ function dismissErrorAliasIndex(error) {
177
+ if (!Array.isArray(error.path))
178
+ return undefined;
179
+ const alias = error.path.find((part) => typeof part === "string" && /^d\d+$/.test(part));
180
+ if (typeof alias !== "string")
181
+ return undefined;
182
+ const parsed = Number.parseInt(alias.slice(1), 10);
183
+ return Number.isNaN(parsed) ? undefined : parsed;
184
+ }
@@ -34,7 +34,16 @@ export async function getCurrentPrNumber() {
34
34
  if (branch === "HEAD")
35
35
  return null;
36
36
  const repo = await getRepoInfo();
37
- const result = await httpGraphql(PR_NUMBER_BY_BRANCH_QUERY, { owner: repo.owner, repo: repo.name, branch });
37
+ return getPrNumberForBranch(branch, repo.owner, repo.name);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** Returns the PR number for a given branch, or null if no open PR is found. */
44
+ export async function getPrNumberForBranch(branch, owner, repo) {
45
+ try {
46
+ const result = await httpGraphql(PR_NUMBER_BY_BRANCH_QUERY, { owner, repo, branch });
38
47
  return result.data.repository?.pullRequests.nodes[0]?.number ?? null;
39
48
  }
40
49
  catch {
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { tmpdir } from "node:os";
3
3
  export function resolveStateBase() {
4
- return process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
4
+ const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
5
+ return envDir ? envDir : join(tmpdir(), "pr-shepherd-state");
5
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -29,12 +29,12 @@ One-tick dispatcher for iterating a PR to completion.
29
29
  If `MERGED` or `CLOSED`, output: `PR #N is already merged/closed. Nothing to do.` and stop.
30
30
 
31
31
  3. **Select the package runner** from the target repository root:
32
- - Prefer `package.json` `packageManager`: `pnpm@...` → `pnpm exec`, `yarn@...` → `yarn run`, `npm@...` → `npx`.
33
- - If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` → `pnpm exec`, `yarn.lock` → `yarn run`, `package-lock.json` or no signal → `npx`.
32
+ - Prefer `package.json` `packageManager`: `pnpm@...` → `pnpm exec`, `yarn@...` → `yarn run`, `bun@...` → `bunx`, `npm@...` → `npx`.
33
+ - If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` → `pnpm exec`, `yarn.lock` → `yarn run`, `bun.lock` / `bun.lockb` → `bunx`, `package-lock.json` or no signal → `npx`.
34
34
 
35
35
  4. **Run one iterate tick:**
36
36
 
37
- If the package is missing in the target repository, tell the user to install pr-shepherd with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, or `npm install --save-dev pr-shepherd`.
37
+ If the package is missing in the target repository, tell the user to install pr-shepherd with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, `bun add -d pr-shepherd`, or `npm install --save-dev pr-shepherd`.
38
38
 
39
39
  ```bash
40
40
  <runner> pr-shepherd <N>
@@ -45,4 +45,4 @@ One-tick dispatcher for iterating a PR to completion.
45
45
  5. **Stop conditions:**
46
46
  - Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
47
47
  - Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures.
48
- - All other actions (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) are non-terminal: follow the `## Instructions`. For Claude, schedule exactly one next session-only iteration and end the turn; do not sleep inline and do not create a recurring cron.
48
+ - All other actions (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) are non-terminal: follow the `## Instructions`. For Claude, schedule exactly one next session-only iteration and end the turn; do not sleep inline and do not create a recurring cron or polling loop (`while true`, repeated polling, etc.).