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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -2
- package/bin/cli/clean-formatter.mjs +20 -0
- package/bin/cli/fix-formatter.mjs +3 -10
- package/bin/cli/formatters.mjs +6 -10
- package/bin/cli/handlers.mjs +57 -1
- package/bin/cli/iterate-instructions.mjs +4 -2
- package/bin/cli/list-formatters.mjs +21 -1
- package/bin/cli/runner.mjs +13 -3
- package/bin/cli-parser.clean.test-support.mjs +45 -0
- package/bin/cli-parser.iterate-fixtures.mjs +1 -1
- package/bin/cli-parser.mjs +6 -2
- package/bin/commands/clean.mjs +156 -0
- package/bin/commands/clean.test-support.mjs +48 -0
- package/bin/commands/iterate/classify.mjs +37 -9
- package/bin/commands/iterate/escalate.mjs +10 -8
- package/bin/commands/iterate/fix-code.mjs +17 -6
- package/bin/commands/iterate/render.mjs +12 -6
- package/bin/comments/resolve.mjs +70 -11
- package/bin/github/client.mjs +10 -1
- package/bin/state/base.mjs +2 -1
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +4 -4
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 `
|
|
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,
|
|
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
|
|
96
|
-
|
|
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");
|
package/bin/cli/formatters.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
50
|
-
|
|
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
|
package/bin/cli/handlers.mjs
CHANGED
|
@@ -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 `
|
|
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 [
|
|
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
|
|
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
|
+
}
|
package/bin/cli/runner.mjs
CHANGED
|
@@ -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
|
|
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
|
|
22
|
+
if (VALID_RUNNERS.includes(value))
|
|
21
23
|
return value;
|
|
22
|
-
throw new Error(`Invalid config: cli.runner must be one of
|
|
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 —
|
|
67
|
+
humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required",
|
|
68
68
|
},
|
|
69
69
|
};
|
|
70
70
|
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
|
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 ||
|
|
52
|
-
|
|
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 —
|
|
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
|
|
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.
|
|
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} —
|
|
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)
|
|
108
|
+
return "Same thread(s) reached the automated attempt limit — treat 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 —
|
|
114
|
+
return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
|
|
113
115
|
}
|
|
114
|
-
return "Ambiguous state —
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|
package/bin/comments/resolve.mjs
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/bin/github/client.mjs
CHANGED
|
@@ -34,7 +34,16 @@ export async function getCurrentPrNumber() {
|
|
|
34
34
|
if (branch === "HEAD")
|
|
35
35
|
return null;
|
|
36
36
|
const repo = await getRepoInfo();
|
|
37
|
-
|
|
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 {
|
package/bin/state/base.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
export function resolveStateBase() {
|
|
4
|
-
|
|
4
|
+
const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
|
|
5
|
+
return envDir ? envDir : join(tmpdir(), "pr-shepherd-state");
|
|
5
6
|
}
|
package/package.json
CHANGED
|
@@ -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.).
|