pr-shepherd 0.16.4 → 0.18.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -13
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/clean-formatter.mjs +20 -0
  5. package/bin/cli/default-iterate.mjs +1 -1
  6. package/bin/cli/duration-flag.mjs +22 -0
  7. package/bin/cli/exit-codes.mjs +14 -0
  8. package/bin/cli/fix-formatter.mjs +4 -12
  9. package/bin/cli/formatters.mjs +6 -10
  10. package/bin/cli/handlers.mjs +72 -36
  11. package/bin/cli/iterate-emitter.mjs +19 -0
  12. package/bin/cli/iterate-flags.mjs +21 -0
  13. package/bin/cli/iterate-formatter.mjs +50 -13
  14. package/bin/cli/iterate-instructions.mjs +13 -17
  15. package/bin/cli/iterate-lean.mjs +9 -12
  16. package/bin/cli/list-formatters.mjs +21 -1
  17. package/bin/cli/poll-handler.mjs +42 -0
  18. package/bin/cli/runner.mjs +13 -3
  19. package/bin/cli-parser.clean.test-support.mjs +45 -0
  20. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
  21. package/bin/cli-parser.iterate-fixtures.mjs +5 -2
  22. package/bin/cli-parser.iterate.test-support.mjs +0 -4
  23. package/bin/cli-parser.mjs +13 -2
  24. package/bin/commands/check-terminal-report.mjs +1 -0
  25. package/bin/commands/check.mjs +1 -0
  26. package/bin/commands/check.test-support.mjs +1 -0
  27. package/bin/commands/clean.mjs +156 -0
  28. package/bin/commands/clean.test-support.mjs +48 -0
  29. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
  30. package/bin/commands/commit-suggestion.test-support.mjs +1 -0
  31. package/bin/commands/iterate/check-instructions.mjs +19 -17
  32. package/bin/commands/iterate/classify.mjs +37 -9
  33. package/bin/commands/iterate/escalate.mjs +26 -28
  34. package/bin/commands/iterate/fix-code.mjs +28 -6
  35. package/bin/commands/iterate/index.mjs +2 -0
  36. package/bin/commands/iterate/render.mjs +51 -40
  37. package/bin/commands/iterate-test-support.mjs +1 -0
  38. package/bin/commands/poll.mjs +32 -0
  39. package/bin/commands/poll.test-support.mjs +77 -0
  40. package/bin/commands/resolve-instructions.mjs +2 -5
  41. package/bin/comments/resolve.mjs +70 -11
  42. package/bin/github/batch-parser-helpers.mjs +38 -0
  43. package/bin/github/batch-parsers.mjs +12 -38
  44. package/bin/github/client.mjs +10 -1
  45. package/bin/github/gql/batch-pr.gql +9 -0
  46. package/bin/state/base.mjs +2 -1
  47. package/bin/state/seen-comments.mjs +29 -15
  48. package/package.json +1 -1
  49. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  50. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +16 -5
  51. package/bin/agent-runtime.mjs +0 -7
@@ -5,10 +5,9 @@ import { adaptIterateLog, adaptFixCodeInstructions, buildSimpleIterateInstructio
5
5
  * outside the state where they are meaningful.
6
6
  */
7
7
  export function projectIterateLean(result, opts) {
8
- const runtime = opts?.runtime ?? "claude";
9
8
  const readyDelaySuffix = opts?.readyDelaySuffix;
10
9
  const runner = opts?.runner;
11
- const simpleInstructions = (r) => buildSimpleIterateInstructions(r, runtime, readyDelaySuffix, runner);
10
+ const simpleInstructions = (r) => buildSimpleIterateInstructions(r, readyDelaySuffix, runner);
12
11
  const base = {
13
12
  action: result.action,
14
13
  pr: result.pr,
@@ -32,26 +31,27 @@ export function projectIterateLean(result, opts) {
32
31
  remainingSeconds: result.remainingSeconds,
33
32
  }),
34
33
  ...(result.baseBranch && { baseBranch: result.baseBranch }),
34
+ ...(result.branchProtection !== null && { branchProtection: result.branchProtection }),
35
35
  };
36
36
  switch (result.action) {
37
37
  case "wait":
38
38
  return {
39
39
  ...base,
40
- log: adaptIterateLog(result.log, runtime),
40
+ log: adaptIterateLog(result.log),
41
41
  instructions: simpleInstructions(result),
42
42
  };
43
43
  case "cancel":
44
44
  return {
45
45
  ...base,
46
46
  reason: result.reason,
47
- log: adaptIterateLog(result.log, runtime),
47
+ log: adaptIterateLog(result.log),
48
48
  instructions: simpleInstructions(result),
49
49
  };
50
50
  case "mark_ready":
51
51
  // drop markedReady — always true, redundant with action discriminator
52
52
  return {
53
53
  ...base,
54
- log: adaptIterateLog(result.log, runtime),
54
+ log: adaptIterateLog(result.log),
55
55
  instructions: simpleInstructions(result),
56
56
  };
57
57
  case "fix_code":
@@ -94,7 +94,7 @@ export function projectIterateLean(result, opts) {
94
94
  }),
95
95
  resolveCommand: result.fix.resolveCommand,
96
96
  ...(result.fix.instructions.length > 0 && {
97
- instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, runner),
97
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner),
98
98
  }),
99
99
  },
100
100
  };
@@ -124,7 +124,6 @@ export function projectIterateLean(result, opts) {
124
124
  }
125
125
  }
126
126
  export function projectIterateVerbose(result, opts) {
127
- const runtime = opts?.runtime ?? "claude";
128
127
  const readyDelaySuffix = opts?.readyDelaySuffix;
129
128
  const runner = opts?.runner;
130
129
  if (result.action === "fix_code") {
@@ -132,16 +131,14 @@ export function projectIterateVerbose(result, opts) {
132
131
  ...result,
133
132
  fix: {
134
133
  ...result.fix,
135
- instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, runner),
134
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner),
136
135
  },
137
136
  };
138
137
  }
139
- const log = "log" in result && typeof result.log === "string"
140
- ? { log: adaptIterateLog(result.log, runtime) }
141
- : {};
138
+ const log = "log" in result && typeof result.log === "string" ? { log: adaptIterateLog(result.log) } : {};
142
139
  return {
143
140
  ...result,
144
141
  ...log,
145
- instructions: buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner),
142
+ instructions: buildSimpleIterateInstructions(result, readyDelaySuffix, runner),
146
143
  };
147
144
  }
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ import { runPoll } from "../commands/poll.mjs";
2
+ import { loadConfig } from "../config/load.mjs";
3
+ import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
4
+ import { parseDurationToSeconds } from "./exit-codes.mjs";
5
+ import { validateSecondsDurationFlag } from "./duration-flag.mjs";
6
+ import { parseIterateFlags } from "./iterate-flags.mjs";
7
+ import { emitIterateResult } from "./iterate-emitter.mjs";
8
+ const DEFAULT_POLL_INTERVAL_SECONDS = 30;
9
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 300;
10
+ export async function handlePoll(args) {
11
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
12
+ const cfg = loadConfig();
13
+ const flags = parseIterateFlags(extra, cfg);
14
+ if (flags.readyDelaySuffix === null)
15
+ return;
16
+ const intervalStr = getFlag(extra, "--interval");
17
+ const intervalSuffix = validateSecondsDurationFlag("pr-shepherd poll", "--interval", intervalStr, hasFlag(extra, "--interval"));
18
+ if (intervalSuffix === null)
19
+ return;
20
+ const intervalSeconds = parseDurationToSeconds(intervalSuffix ?? "", DEFAULT_POLL_INTERVAL_SECONDS);
21
+ const timeoutStr = getFlag(extra, "--timeout");
22
+ const timeoutSuffix = validateSecondsDurationFlag("pr-shepherd poll", "--timeout", timeoutStr, hasFlag(extra, "--timeout"));
23
+ if (timeoutSuffix === null)
24
+ return;
25
+ const timeoutSeconds = parseDurationToSeconds(timeoutSuffix ?? "", DEFAULT_POLL_TIMEOUT_SECONDS);
26
+ const result = await runPoll({
27
+ ...globalOpts,
28
+ prNumber,
29
+ readyDelaySeconds: flags.readyDelaySeconds,
30
+ stallTimeoutSeconds: flags.stallTimeoutSeconds,
31
+ noAutoMarkReady: flags.noAutoMarkReady,
32
+ noAutoCancelActionable: flags.noAutoCancelActionable,
33
+ intervalSeconds,
34
+ timeoutSeconds,
35
+ });
36
+ emitIterateResult(result, {
37
+ format: globalOpts.format,
38
+ verbose: globalOpts.verbose ?? false,
39
+ readyDelaySuffix: flags.readyDelaySuffix ?? undefined,
40
+ runner: cfg.cli?.runner,
41
+ });
42
+ }
@@ -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 };
@@ -32,15 +32,11 @@ export function registerHooks() {
32
32
  beforeEach(() => {
33
33
  vi.clearAllMocks();
34
34
  process.exitCode = undefined;
35
- delete process.env.AGENT;
36
- delete process.env.CODEX_CI;
37
35
  stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
38
36
  stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
39
37
  });
40
38
  afterEach(() => {
41
39
  process.exitCode = undefined;
42
- delete process.env.AGENT;
43
- delete process.env.CODEX_CI;
44
40
  stdoutSpy.mockRestore();
45
41
  stderrSpy.mockRestore();
46
42
  });
@@ -13,6 +13,7 @@ export function makeIterateResult(action = "wait") {
13
13
  remainingSeconds: 60,
14
14
  summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 1 },
15
15
  baseBranch: "main",
16
+ branchProtection: null,
16
17
  checks: [],
17
18
  };
18
19
  if (action === "wait")
@@ -39,7 +40,9 @@ export function makeIterateResult(action = "wait") {
39
40
  requiresDismissMessage: false,
40
41
  hasMutations: false,
41
42
  },
42
- instructions: ["Stop this iteration before the next tick."],
43
+ instructions: [
44
+ "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.",
45
+ ],
43
46
  firstLookThreads: [],
44
47
  firstLookComments: [],
45
48
  inProgressRunIds: [],
@@ -64,7 +67,7 @@ export function makeIterateResult(action = "wait") {
64
67
  ambiguousComments: [],
65
68
  changesRequestedReviews: [],
66
69
  suggestion: "check manually",
67
- humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — needs human direction",
70
+ humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required",
68
71
  },
69
72
  };
70
73
  }
@@ -33,15 +33,11 @@ export function registerHooks() {
33
33
  beforeEach(() => {
34
34
  vi.clearAllMocks();
35
35
  process.exitCode = undefined;
36
- delete process.env.AGENT;
37
- delete process.env.CODEX_CI;
38
36
  stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
39
37
  stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
40
38
  });
41
39
  afterEach(() => {
42
40
  process.exitCode = undefined;
43
- delete process.env.AGENT;
44
- delete process.env.CODEX_CI;
45
41
  stdoutSpy.mockRestore();
46
42
  stderrSpy.mockRestore();
47
43
  });
@@ -14,6 +14,10 @@
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 poll [PR] [--interval 30s] [--timeout 5m] [--format text|json] [--ready-delay Nm]
18
+ * [--stall-timeout <duration>] [--no-auto-mark-ready]
19
+ * [--no-auto-cancel-actionable]
20
+ * pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]
17
21
  */
18
22
  import { readFileSync } from "node:fs";
19
23
  import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
@@ -21,7 +25,8 @@ import { runLogFile } from "./commands/log-file.mjs";
21
25
  import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
22
26
  import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
23
27
  import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
24
- import { handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
28
+ import { handleClean, handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
29
+ import { handlePoll } from "./cli/poll-handler.mjs";
25
30
  import { setupLog } from "./log/setup.mjs";
26
31
  // ---------------------------------------------------------------------------
27
32
  // Entry
@@ -56,9 +61,15 @@ export async function main(argv) {
56
61
  case "iterate":
57
62
  await handleIterate(args.slice(1));
58
63
  break;
64
+ case "poll":
65
+ await handlePoll(args.slice(1));
66
+ break;
67
+ case "clean":
68
+ await handleClean(args.slice(1));
69
+ break;
59
70
  default:
60
71
  process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
61
- process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|log-file> [options]\n" +
72
+ process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|poll|log-file|clean> [options]\n" +
62
73
  " pr-shepherd --version | -v\n");
63
74
  process.exitCode = 1;
64
75
  return;
@@ -31,5 +31,6 @@ export function buildTerminalReport(prNumber, repo, batchData, mergeStatus, stat
31
31
  firstLookSummaries: [],
32
32
  editedSummaries: [],
33
33
  approvedReviews: [],
34
+ branchProtection: batchData.branchProtection,
34
35
  };
35
36
  }
@@ -151,5 +151,6 @@ export async function runCheck(opts) {
151
151
  firstLookSummaries,
152
152
  editedSummaries,
153
153
  approvedReviews: batchData.approvedReviews,
154
+ branchProtection: batchData.branchProtection,
154
155
  };
155
156
  }
@@ -94,6 +94,7 @@ function makeBatchData(overrides = {}) {
94
94
  changesRequestedReviews: [],
95
95
  reviewSummaries: [],
96
96
  approvedReviews: [],
97
+ branchProtection: null,
97
98
  checks: [makeCheck()],
98
99
  ...overrides,
99
100
  };
@@ -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
+ }
@@ -68,6 +68,7 @@ function makeBatch(threads) {
68
68
  changesRequestedReviews: [],
69
69
  reviewSummaries: [],
70
70
  approvedReviews: [],
71
+ branchProtection: null,
71
72
  };
72
73
  }
73
74
  const FILE_CONTENT = "line1\n" +
@@ -76,6 +76,7 @@ function makeBatch(threads, headRepoWithOwner = "owner/repo") {
76
76
  changesRequestedReviews: [],
77
77
  reviewSummaries: [],
78
78
  approvedReviews: [],
79
+ branchProtection: null,
79
80
  };
80
81
  }
81
82
  const FILE_CONTENT = "line1\n" +
@@ -1,24 +1,26 @@
1
1
  export function buildFailingCheckInstructions(checks) {
2
- const instructions = [];
3
- const failedRunIdChecks = checks.filter((c) => c.runId && c.conclusion !== "CANCELLED" && c.conclusion !== "STARTUP_FAILURE");
4
- const cancelledRunIdChecks = checks.filter((c) => c.runId && c.conclusion === "CANCELLED");
5
- const startupFailureRunIdChecks = checks.filter((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
6
- const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
7
- const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
8
- if (failedRunIdChecks.length > 0) {
9
- instructions.push(`For each failing check under \`## Failing checks\` with a run ID and no \`[conclusion: CANCELLED]\` or \`[conclusion: STARTUP_FAILURE]\` tag: run \`gh run view <runId> --log-failed\` to fetch the failing job's log. If the log shows a transient infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\`. If the log shows a real test/build failure, apply a code fix.`);
2
+ if (checks.length === 0)
3
+ return [];
4
+ const hasRunId = checks.some((c) => c.runId && c.conclusion !== "CANCELLED" && c.conclusion !== "STARTUP_FAILURE");
5
+ const hasCancelled = checks.some((c) => c.runId && c.conclusion === "CANCELLED");
6
+ const hasStartupFailure = checks.some((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
7
+ const hasExternal = checks.some((c) => !c.runId && c.detailsUrl);
8
+ const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
9
+ const parts = [];
10
+ if (hasRunId) {
11
+ parts.push("fetch the log with `gh run view <runId> --log-failed` and decide: rerun with `gh run rerun <runId> --failed` for transient infrastructure failures (network timeout, OOM kill, runner crash), or apply a code fix for real test/build failures");
10
12
  }
11
- if (cancelledRunIdChecks.length > 0) {
12
- instructions.push(`For each \`[conclusion: CANCELLED]\` bullet under \`## Failing checks\`: the run was cancelled outside Shepherd's control (manual cancel, newer push, concurrency-group eviction). Run \`gh run rerun <runId>\` only if the cancellation looks unintended; otherwise treat it as resolved by the superseding run. Do NOT confuse these with IDs under \`## Cancelled runs\` — those were cancelled by Shepherd itself.`);
13
+ if (hasCancelled) {
14
+ parts.push("for `[conclusion: CANCELLED]` entries: rerun with `gh run rerun <runId>` if the cancellation looks unintended (not superseded by a newer push or concurrency-group eviction); otherwise treat as resolved do NOT confuse with IDs under `## Cancelled runs`");
13
15
  }
14
- if (startupFailureRunIdChecks.length > 0) {
15
- instructions.push(`For each \`[conclusion: STARTUP_FAILURE]\` bullet under \`## Failing checks\`: the workflow failed before jobs/logs were created. Run \`gh run view <runId>\` to inspect the run metadata, then run \`gh run rerun <runId>\` if the workflow should be attempted again.`);
16
+ if (hasStartupFailure) {
17
+ parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId>` and rerun with `gh run rerun <runId>` if the workflow should be retried");
16
18
  }
17
- if (externalChecks.length > 0) {
18
- instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — log tails are not available for external checks.`);
19
+ if (hasExternal) {
20
+ parts.push("for `external` entries (no run ID, has URL): open the URL to inspect the failure");
19
21
  }
20
- if (bareChecks.length > 0) {
21
- instructions.push(`For each bullet in \`## Failing checks\` starting with \`(no runId)\`: there is no run or details URL to inspect. Escalate these to a human they require manual investigation outside the pr-shepherd flow.`);
22
+ if (hasBare) {
23
+ parts.push("for `(no runId)` entries: no log or URL is available escalate to a human for manual investigation");
22
24
  }
23
- return instructions;
25
+ return [`For each failing check under \`## Failing checks\`: ${parts.join("; ")}.`];
24
26
  }