pr-shepherd 0.16.3 → 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/checks/triage.test-support.mjs +61 -0
- 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/iterate-lean.test-support.mjs +5 -0
- 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.commit-suggestion.test-support.mjs +66 -0
- package/bin/cli-parser.iterate-fix.test-support.mjs +48 -0
- package/bin/cli-parser.iterate-fixtures.mjs +1 -1
- package/bin/cli-parser.iterate.test-support.mjs +49 -0
- package/bin/cli-parser.mjs +6 -2
- package/bin/cli-parser.test-support.mjs +43 -0
- package/bin/commands/check.test-support.mjs +140 -0
- package/bin/commands/clean.mjs +156 -0
- package/bin/commands/clean.test-support.mjs +48 -0
- package/bin/commands/commit-suggestion.apply.test-support.mjs +87 -0
- package/bin/commands/commit-suggestion.test-support.mjs +112 -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/index.mjs +19 -16
- package/bin/commands/iterate/render.mjs +12 -6
- package/bin/commands/iterate-stall.test-support.mjs +25 -0
- package/bin/commands/iterate-test-support.mjs +149 -0
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +118 -0
- package/bin/commands/resolve.test-support.mjs +114 -0
- package/bin/commands/shepherd-journal.test-support.mjs +9 -0
- package/bin/comments/resolve.mjs +70 -11
- package/bin/comments/resolve.test-support.mjs +40 -0
- package/bin/github/batch-parsers.test-support.mjs +67 -0
- package/bin/github/batch.test-support.mjs +67 -0
- package/bin/github/client.mjs +10 -1
- package/bin/github/graphql-http.mjs +73 -0
- package/bin/github/http-auth.mjs +48 -0
- package/bin/github/http-request.mjs +15 -0
- package/bin/github/http-utils.mjs +34 -0
- package/bin/github/http.mjs +4 -319
- package/bin/github/http.test-support.mjs +52 -0
- package/bin/github/rest-http.mjs +131 -0
- package/bin/state/base.mjs +2 -1
- package/bin/suggestions/patch.test-support.mjs +4 -0
- package/package.json +2 -2
- 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,61 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Stub fetch globally so http.mts uses our mock.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
const mockFetch = vi.fn();
|
|
7
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
8
|
+
import { fetchStartupFailureChecks, triageFailingChecks } from "./triage.mjs";
|
|
9
|
+
import { mergeStartupFailureChecks } from "./startup-failures.mjs";
|
|
10
|
+
const REPO = { owner: "owner", name: "repo" };
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function makeCheck(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
name: "tests",
|
|
17
|
+
status: "COMPLETED",
|
|
18
|
+
conclusion: "FAILURE",
|
|
19
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/99/jobs/1",
|
|
20
|
+
event: "pull_request",
|
|
21
|
+
runId: "run-99",
|
|
22
|
+
category: "failing",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function makeJobsResponse(jobs) {
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
31
|
+
json: () => Promise.resolve({ jobs }),
|
|
32
|
+
text: () => Promise.resolve(JSON.stringify({ jobs })),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function makeErrorResponse(status) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
status,
|
|
39
|
+
headers: new Headers(),
|
|
40
|
+
text: () => Promise.resolve("error"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function makeWorkflowRunsResponse(runs) {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
48
|
+
json: () => Promise.resolve({ workflow_runs: runs }),
|
|
49
|
+
text: () => Promise.resolve(JSON.stringify({ workflow_runs: runs })),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Tests
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
export function registerHooks() {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
mockFetch.mockReset();
|
|
58
|
+
process.env["GH_TOKEN"] = "test-token";
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export { REPO, fetchStartupFailureChecks, makeCheck, makeErrorResponse, makeJobsResponse, makeWorkflowRunsResponse, mergeStartupFailureChecks, mockFetch, triageFailingChecks, };
|
|
@@ -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) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { projectIterateLean, projectIterateVerbose } from "./iterate-lean.mjs";
|
|
4
|
+
import { makeIterateResult } from "../cli-parser.iterate-fixtures.mjs";
|
|
5
|
+
export { makeIterateResult, projectIterateLean, projectIterateVerbose };
|
|
@@ -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 };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
4
|
+
vi.mock("./commands/resolve.mts", () => ({
|
|
5
|
+
runResolveFetch: vi.fn(),
|
|
6
|
+
runResolveMutate: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("./commands/commit-suggestion.mts", () => ({
|
|
9
|
+
runCommitSuggestion: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal();
|
|
13
|
+
return { ...actual, runIterate: vi.fn() };
|
|
14
|
+
});
|
|
15
|
+
vi.mock("./github/client.mts", () => ({
|
|
16
|
+
getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
|
|
17
|
+
}));
|
|
18
|
+
import { main } from "./cli-parser.mjs";
|
|
19
|
+
import { runCommitSuggestion } from "./commands/commit-suggestion.mjs";
|
|
20
|
+
const mockRunCommitSuggestion = vi.mocked(runCommitSuggestion);
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
let stdoutSpy;
|
|
23
|
+
let stderrSpy;
|
|
24
|
+
function getStdout() {
|
|
25
|
+
return stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Fixtures
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const SUGGESTION_RESULT = {
|
|
31
|
+
pr: 42,
|
|
32
|
+
repo: "owner/repo",
|
|
33
|
+
threadId: "t1",
|
|
34
|
+
path: "a.ts",
|
|
35
|
+
startLine: 5,
|
|
36
|
+
endLine: 5,
|
|
37
|
+
author: "alice",
|
|
38
|
+
patch: "--- a/a.ts\n+++ b/a.ts\n@@ -5,1 +5,1 @@\n-old\n+new\n",
|
|
39
|
+
commitMessage: "apply fix",
|
|
40
|
+
commitBody: "Co-authored-by: alice <alice@users.noreply.github.com>",
|
|
41
|
+
filesToStage: ["a.ts"],
|
|
42
|
+
postActionInstructions: [
|
|
43
|
+
"Apply the patch to `a.ts`: run `git apply` with the diff shown above.",
|
|
44
|
+
"Stage the file: `git add -- a.ts`",
|
|
45
|
+
'Commit: `git commit -m "apply fix" -m "Co-authored-by: alice <alice@users.noreply.github.com>"`',
|
|
46
|
+
"Resolve the thread on GitHub: `npx pr-shepherd resolve 42 --resolve-thread-ids t1`",
|
|
47
|
+
"Push when ready: `git push` (or `git push --force-with-lease` after rebasing).",
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// commit-suggestion dispatch
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export function registerHooks() {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
process.exitCode = undefined;
|
|
57
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
58
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
process.exitCode = undefined;
|
|
62
|
+
stdoutSpy.mockRestore();
|
|
63
|
+
stderrSpy.mockRestore();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export { SUGGESTION_RESULT, getStdout, main, mockRunCommitSuggestion, runCommitSuggestion, stderrSpy, stdoutSpy, };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
4
|
+
vi.mock("./commands/resolve.mts", () => ({
|
|
5
|
+
runResolveFetch: vi.fn(),
|
|
6
|
+
runResolveMutate: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("./commands/commit-suggestion.mts", () => ({
|
|
9
|
+
runCommitSuggestion: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal();
|
|
13
|
+
return { ...actual, runIterate: vi.fn() };
|
|
14
|
+
});
|
|
15
|
+
vi.mock("./github/client.mts", () => ({
|
|
16
|
+
getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
|
|
17
|
+
}));
|
|
18
|
+
import { main } from "./cli-parser.mjs";
|
|
19
|
+
import { runIterate } from "./commands/iterate/index.mjs";
|
|
20
|
+
import { makeIterateResult } from "./cli-parser.iterate-fixtures.mjs";
|
|
21
|
+
const mockRunIterate = vi.mocked(runIterate);
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
let stdoutSpy;
|
|
24
|
+
let stderrSpy;
|
|
25
|
+
function getStdout() {
|
|
26
|
+
return stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
|
27
|
+
}
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// formatIterateResult — fix_code actions and ## Checks section
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
export function registerHooks() {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
process.exitCode = undefined;
|
|
35
|
+
delete process.env.AGENT;
|
|
36
|
+
delete process.env.CODEX_CI;
|
|
37
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
38
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
process.exitCode = undefined;
|
|
42
|
+
delete process.env.AGENT;
|
|
43
|
+
delete process.env.CODEX_CI;
|
|
44
|
+
stdoutSpy.mockRestore();
|
|
45
|
+
stderrSpy.mockRestore();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export { getStdout, main, makeIterateResult, mockRunIterate, runIterate, 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
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
4
|
+
vi.mock("./commands/resolve.mts", () => ({
|
|
5
|
+
runResolveFetch: vi.fn(),
|
|
6
|
+
runResolveMutate: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("./commands/commit-suggestion.mts", () => ({
|
|
9
|
+
runCommitSuggestion: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal();
|
|
13
|
+
return { ...actual, runIterate: vi.fn() };
|
|
14
|
+
});
|
|
15
|
+
import { main } from "./cli-parser.mjs";
|
|
16
|
+
import { runIterate } from "./commands/iterate/index.mjs";
|
|
17
|
+
import { formatIterateResult } from "./cli/iterate-formatter.mjs";
|
|
18
|
+
import { makeIterateResult } from "./cli-parser.iterate-fixtures.mjs";
|
|
19
|
+
const mockRunIterate = vi.mocked(runIterate);
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
let stdoutSpy;
|
|
22
|
+
let stderrSpy;
|
|
23
|
+
function getStdout() {
|
|
24
|
+
return stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
|
25
|
+
}
|
|
26
|
+
function getStderr() {
|
|
27
|
+
return stderrSpy.mock.calls.map((c) => c[0]).join("");
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// iterate dispatch
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
export function registerHooks() {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
process.exitCode = undefined;
|
|
36
|
+
delete process.env.AGENT;
|
|
37
|
+
delete process.env.CODEX_CI;
|
|
38
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
39
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
process.exitCode = undefined;
|
|
43
|
+
delete process.env.AGENT;
|
|
44
|
+
delete process.env.CODEX_CI;
|
|
45
|
+
stdoutSpy.mockRestore();
|
|
46
|
+
stderrSpy.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export { formatIterateResult, getStderr, getStdout, main, makeIterateResult, mockRunIterate, runIterate, stderrSpy, stdoutSpy, };
|
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;
|