pr-shepherd 0.16.4 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -13
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/clean-formatter.mjs +20 -0
- package/bin/cli/default-iterate.mjs +1 -1
- package/bin/cli/duration-flag.mjs +22 -0
- package/bin/cli/exit-codes.mjs +14 -0
- package/bin/cli/fix-formatter.mjs +4 -12
- package/bin/cli/formatters.mjs +6 -10
- package/bin/cli/handlers.mjs +72 -36
- package/bin/cli/iterate-emitter.mjs +19 -0
- package/bin/cli/iterate-flags.mjs +21 -0
- package/bin/cli/iterate-formatter.mjs +50 -13
- package/bin/cli/iterate-instructions.mjs +13 -17
- package/bin/cli/iterate-lean.mjs +9 -12
- package/bin/cli/list-formatters.mjs +21 -1
- package/bin/cli/poll-handler.mjs +42 -0
- package/bin/cli/runner.mjs +13 -3
- package/bin/cli-parser.clean.test-support.mjs +45 -0
- package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
- package/bin/cli-parser.iterate-fixtures.mjs +5 -2
- package/bin/cli-parser.iterate.test-support.mjs +0 -4
- package/bin/cli-parser.mjs +13 -2
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/check.test-support.mjs +1 -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 +1 -0
- package/bin/commands/commit-suggestion.test-support.mjs +1 -0
- package/bin/commands/iterate/check-instructions.mjs +19 -17
- package/bin/commands/iterate/classify.mjs +37 -9
- package/bin/commands/iterate/escalate.mjs +26 -28
- package/bin/commands/iterate/fix-code.mjs +28 -6
- package/bin/commands/iterate/index.mjs +2 -0
- package/bin/commands/iterate/render.mjs +51 -40
- package/bin/commands/iterate-test-support.mjs +1 -0
- package/bin/commands/poll.mjs +32 -0
- package/bin/commands/poll.test-support.mjs +77 -0
- package/bin/commands/resolve-instructions.mjs +2 -5
- package/bin/comments/resolve.mjs +70 -11
- package/bin/github/batch-parser-helpers.mjs +38 -0
- package/bin/github/batch-parsers.mjs +12 -38
- package/bin/github/client.mjs +10 -1
- package/bin/github/gql/batch-pr.gql +9 -0
- package/bin/state/base.mjs +2 -1
- package/bin/state/seen-comments.mjs +29 -15
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +16 -5
- package/bin/agent-runtime.mjs +0 -7
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -5,10 +5,9 @@ import { adaptIterateLog, adaptFixCodeInstructions, buildSimpleIterateInstructio
|
|
|
5
5
|
* outside the state where they are meaningful.
|
|
6
6
|
*/
|
|
7
7
|
export function projectIterateLean(result, opts) {
|
|
8
|
-
const runtime = opts?.runtime ?? "claude";
|
|
9
8
|
const readyDelaySuffix = opts?.readyDelaySuffix;
|
|
10
9
|
const runner = opts?.runner;
|
|
11
|
-
const simpleInstructions = (r) => buildSimpleIterateInstructions(r,
|
|
10
|
+
const simpleInstructions = (r) => buildSimpleIterateInstructions(r, readyDelaySuffix, runner);
|
|
12
11
|
const base = {
|
|
13
12
|
action: result.action,
|
|
14
13
|
pr: result.pr,
|
|
@@ -32,26 +31,27 @@ export function projectIterateLean(result, opts) {
|
|
|
32
31
|
remainingSeconds: result.remainingSeconds,
|
|
33
32
|
}),
|
|
34
33
|
...(result.baseBranch && { baseBranch: result.baseBranch }),
|
|
34
|
+
...(result.branchProtection !== null && { branchProtection: result.branchProtection }),
|
|
35
35
|
};
|
|
36
36
|
switch (result.action) {
|
|
37
37
|
case "wait":
|
|
38
38
|
return {
|
|
39
39
|
...base,
|
|
40
|
-
log: adaptIterateLog(result.log
|
|
40
|
+
log: adaptIterateLog(result.log),
|
|
41
41
|
instructions: simpleInstructions(result),
|
|
42
42
|
};
|
|
43
43
|
case "cancel":
|
|
44
44
|
return {
|
|
45
45
|
...base,
|
|
46
46
|
reason: result.reason,
|
|
47
|
-
log: adaptIterateLog(result.log
|
|
47
|
+
log: adaptIterateLog(result.log),
|
|
48
48
|
instructions: simpleInstructions(result),
|
|
49
49
|
};
|
|
50
50
|
case "mark_ready":
|
|
51
51
|
// drop markedReady — always true, redundant with action discriminator
|
|
52
52
|
return {
|
|
53
53
|
...base,
|
|
54
|
-
log: adaptIterateLog(result.log
|
|
54
|
+
log: adaptIterateLog(result.log),
|
|
55
55
|
instructions: simpleInstructions(result),
|
|
56
56
|
};
|
|
57
57
|
case "fix_code":
|
|
@@ -94,7 +94,7 @@ export function projectIterateLean(result, opts) {
|
|
|
94
94
|
}),
|
|
95
95
|
resolveCommand: result.fix.resolveCommand,
|
|
96
96
|
...(result.fix.instructions.length > 0 && {
|
|
97
|
-
instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr,
|
|
97
|
+
instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner),
|
|
98
98
|
}),
|
|
99
99
|
},
|
|
100
100
|
};
|
|
@@ -124,7 +124,6 @@ export function projectIterateLean(result, opts) {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
export function projectIterateVerbose(result, opts) {
|
|
127
|
-
const runtime = opts?.runtime ?? "claude";
|
|
128
127
|
const readyDelaySuffix = opts?.readyDelaySuffix;
|
|
129
128
|
const runner = opts?.runner;
|
|
130
129
|
if (result.action === "fix_code") {
|
|
@@ -132,16 +131,14 @@ export function projectIterateVerbose(result, opts) {
|
|
|
132
131
|
...result,
|
|
133
132
|
fix: {
|
|
134
133
|
...result.fix,
|
|
135
|
-
instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr,
|
|
134
|
+
instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner),
|
|
136
135
|
},
|
|
137
136
|
};
|
|
138
137
|
}
|
|
139
|
-
const log = "log" in result && typeof result.log === "string"
|
|
140
|
-
? { log: adaptIterateLog(result.log, runtime) }
|
|
141
|
-
: {};
|
|
138
|
+
const log = "log" in result && typeof result.log === "string" ? { log: adaptIterateLog(result.log) } : {};
|
|
142
139
|
return {
|
|
143
140
|
...result,
|
|
144
141
|
...log,
|
|
145
|
-
instructions: buildSimpleIterateInstructions(result,
|
|
142
|
+
instructions: buildSimpleIterateInstructions(result, readyDelaySuffix, runner),
|
|
146
143
|
};
|
|
147
144
|
}
|
|
@@ -27,7 +27,8 @@ export function renderThreadBullet(t, opts = {}) {
|
|
|
27
27
|
: "`(no location)`";
|
|
28
28
|
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
29
29
|
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
30
|
-
const
|
|
30
|
+
const bodySuffix = opts.noBody ? "" : `: ${renderBodyPreview(t.body)}`;
|
|
31
|
+
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}${bodySuffix}`;
|
|
31
32
|
if (t.suggestion && opts.renderSuggestion) {
|
|
32
33
|
return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
|
|
33
34
|
}
|
|
@@ -47,3 +48,22 @@ export function renderReviewListSection(heading, items) {
|
|
|
47
48
|
return null;
|
|
48
49
|
return `## ${heading}\n\n${items.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n")}`;
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Build bullet strings for the `## First-look items` section.
|
|
53
|
+
* Threads that also appear in resolutionOnlyIds have their body suppressed
|
|
54
|
+
* (already shown in `## Review threads to resolve`).
|
|
55
|
+
*/
|
|
56
|
+
export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, firstLookComments) {
|
|
57
|
+
const bullets = [];
|
|
58
|
+
for (const t of firstLookThreads) {
|
|
59
|
+
bullets.push(renderThreadBullet(t, {
|
|
60
|
+
statusTag: renderFirstLookStatusTag(t),
|
|
61
|
+
noBody: resolutionOnlyIds.has(t.id),
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
for (const c of firstLookComments) {
|
|
65
|
+
const editedSuffix = c.edited ? ", edited" : "";
|
|
66
|
+
bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
|
|
67
|
+
}
|
|
68
|
+
return bullets;
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { runPoll } from "../commands/poll.mjs";
|
|
2
|
+
import { loadConfig } from "../config/load.mjs";
|
|
3
|
+
import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
|
|
4
|
+
import { parseDurationToSeconds } from "./exit-codes.mjs";
|
|
5
|
+
import { validateSecondsDurationFlag } from "./duration-flag.mjs";
|
|
6
|
+
import { parseIterateFlags } from "./iterate-flags.mjs";
|
|
7
|
+
import { emitIterateResult } from "./iterate-emitter.mjs";
|
|
8
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 30;
|
|
9
|
+
const DEFAULT_POLL_TIMEOUT_SECONDS = 300;
|
|
10
|
+
export async function handlePoll(args) {
|
|
11
|
+
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
12
|
+
const cfg = loadConfig();
|
|
13
|
+
const flags = parseIterateFlags(extra, cfg);
|
|
14
|
+
if (flags.readyDelaySuffix === null)
|
|
15
|
+
return;
|
|
16
|
+
const intervalStr = getFlag(extra, "--interval");
|
|
17
|
+
const intervalSuffix = validateSecondsDurationFlag("pr-shepherd poll", "--interval", intervalStr, hasFlag(extra, "--interval"));
|
|
18
|
+
if (intervalSuffix === null)
|
|
19
|
+
return;
|
|
20
|
+
const intervalSeconds = parseDurationToSeconds(intervalSuffix ?? "", DEFAULT_POLL_INTERVAL_SECONDS);
|
|
21
|
+
const timeoutStr = getFlag(extra, "--timeout");
|
|
22
|
+
const timeoutSuffix = validateSecondsDurationFlag("pr-shepherd poll", "--timeout", timeoutStr, hasFlag(extra, "--timeout"));
|
|
23
|
+
if (timeoutSuffix === null)
|
|
24
|
+
return;
|
|
25
|
+
const timeoutSeconds = parseDurationToSeconds(timeoutSuffix ?? "", DEFAULT_POLL_TIMEOUT_SECONDS);
|
|
26
|
+
const result = await runPoll({
|
|
27
|
+
...globalOpts,
|
|
28
|
+
prNumber,
|
|
29
|
+
readyDelaySeconds: flags.readyDelaySeconds,
|
|
30
|
+
stallTimeoutSeconds: flags.stallTimeoutSeconds,
|
|
31
|
+
noAutoMarkReady: flags.noAutoMarkReady,
|
|
32
|
+
noAutoCancelActionable: flags.noAutoCancelActionable,
|
|
33
|
+
intervalSeconds,
|
|
34
|
+
timeoutSeconds,
|
|
35
|
+
});
|
|
36
|
+
emitIterateResult(result, {
|
|
37
|
+
format: globalOpts.format,
|
|
38
|
+
verbose: globalOpts.verbose ?? false,
|
|
39
|
+
readyDelaySuffix: flags.readyDelaySuffix ?? undefined,
|
|
40
|
+
runner: cfg.cli?.runner,
|
|
41
|
+
});
|
|
42
|
+
}
|
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 };
|
|
@@ -32,15 +32,11 @@ export function registerHooks() {
|
|
|
32
32
|
beforeEach(() => {
|
|
33
33
|
vi.clearAllMocks();
|
|
34
34
|
process.exitCode = undefined;
|
|
35
|
-
delete process.env.AGENT;
|
|
36
|
-
delete process.env.CODEX_CI;
|
|
37
35
|
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
38
36
|
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
39
37
|
});
|
|
40
38
|
afterEach(() => {
|
|
41
39
|
process.exitCode = undefined;
|
|
42
|
-
delete process.env.AGENT;
|
|
43
|
-
delete process.env.CODEX_CI;
|
|
44
40
|
stdoutSpy.mockRestore();
|
|
45
41
|
stderrSpy.mockRestore();
|
|
46
42
|
});
|
|
@@ -13,6 +13,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
13
13
|
remainingSeconds: 60,
|
|
14
14
|
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 1 },
|
|
15
15
|
baseBranch: "main",
|
|
16
|
+
branchProtection: null,
|
|
16
17
|
checks: [],
|
|
17
18
|
};
|
|
18
19
|
if (action === "wait")
|
|
@@ -39,7 +40,9 @@ export function makeIterateResult(action = "wait") {
|
|
|
39
40
|
requiresDismissMessage: false,
|
|
40
41
|
hasMutations: false,
|
|
41
42
|
},
|
|
42
|
-
instructions: [
|
|
43
|
+
instructions: [
|
|
44
|
+
"Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.",
|
|
45
|
+
],
|
|
43
46
|
firstLookThreads: [],
|
|
44
47
|
firstLookComments: [],
|
|
45
48
|
inProgressRunIds: [],
|
|
@@ -64,7 +67,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
64
67
|
ambiguousComments: [],
|
|
65
68
|
changesRequestedReviews: [],
|
|
66
69
|
suggestion: "check manually",
|
|
67
|
-
humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused —
|
|
70
|
+
humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required",
|
|
68
71
|
},
|
|
69
72
|
};
|
|
70
73
|
}
|
|
@@ -33,15 +33,11 @@ export function registerHooks() {
|
|
|
33
33
|
beforeEach(() => {
|
|
34
34
|
vi.clearAllMocks();
|
|
35
35
|
process.exitCode = undefined;
|
|
36
|
-
delete process.env.AGENT;
|
|
37
|
-
delete process.env.CODEX_CI;
|
|
38
36
|
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
39
37
|
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
40
38
|
});
|
|
41
39
|
afterEach(() => {
|
|
42
40
|
process.exitCode = undefined;
|
|
43
|
-
delete process.env.AGENT;
|
|
44
|
-
delete process.env.CODEX_CI;
|
|
45
41
|
stdoutSpy.mockRestore();
|
|
46
42
|
stderrSpy.mockRestore();
|
|
47
43
|
});
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
|
|
15
15
|
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
16
16
|
* [--no-auto-cancel-actionable]
|
|
17
|
+
* pr-shepherd poll [PR] [--interval 30s] [--timeout 5m] [--format text|json] [--ready-delay Nm]
|
|
18
|
+
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
19
|
+
* [--no-auto-cancel-actionable]
|
|
20
|
+
* pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]
|
|
17
21
|
*/
|
|
18
22
|
import { readFileSync } from "node:fs";
|
|
19
23
|
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
@@ -21,7 +25,8 @@ import { runLogFile } from "./commands/log-file.mjs";
|
|
|
21
25
|
import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
22
26
|
import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
|
|
23
27
|
import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
|
|
24
|
-
import { handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
|
|
28
|
+
import { handleClean, handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
|
|
29
|
+
import { handlePoll } from "./cli/poll-handler.mjs";
|
|
25
30
|
import { setupLog } from "./log/setup.mjs";
|
|
26
31
|
// ---------------------------------------------------------------------------
|
|
27
32
|
// Entry
|
|
@@ -56,9 +61,15 @@ export async function main(argv) {
|
|
|
56
61
|
case "iterate":
|
|
57
62
|
await handleIterate(args.slice(1));
|
|
58
63
|
break;
|
|
64
|
+
case "poll":
|
|
65
|
+
await handlePoll(args.slice(1));
|
|
66
|
+
break;
|
|
67
|
+
case "clean":
|
|
68
|
+
await handleClean(args.slice(1));
|
|
69
|
+
break;
|
|
59
70
|
default:
|
|
60
71
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
61
|
-
process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|log-file> [options]\n" +
|
|
72
|
+
process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|poll|log-file|clean> [options]\n" +
|
|
62
73
|
" pr-shepherd --version | -v\n");
|
|
63
74
|
process.exitCode = 1;
|
|
64
75
|
return;
|
package/bin/commands/check.mjs
CHANGED
|
@@ -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,24 +1,26 @@
|
|
|
1
1
|
export function buildFailingCheckInstructions(checks) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
if (checks.length === 0)
|
|
3
|
+
return [];
|
|
4
|
+
const hasRunId = checks.some((c) => c.runId && c.conclusion !== "CANCELLED" && c.conclusion !== "STARTUP_FAILURE");
|
|
5
|
+
const hasCancelled = checks.some((c) => c.runId && c.conclusion === "CANCELLED");
|
|
6
|
+
const hasStartupFailure = checks.some((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
|
|
7
|
+
const hasExternal = checks.some((c) => !c.runId && c.detailsUrl);
|
|
8
|
+
const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
|
|
9
|
+
const parts = [];
|
|
10
|
+
if (hasRunId) {
|
|
11
|
+
parts.push("fetch the log with `gh run view <runId> --log-failed` and decide: rerun with `gh run rerun <runId> --failed` for transient infrastructure failures (network timeout, OOM kill, runner crash), or apply a code fix for real test/build failures");
|
|
10
12
|
}
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
+
if (hasCancelled) {
|
|
14
|
+
parts.push("for `[conclusion: CANCELLED]` entries: rerun with `gh run rerun <runId>` if the cancellation looks unintended (not superseded by a newer push or concurrency-group eviction); otherwise treat as resolved — do NOT confuse with IDs under `## Cancelled runs`");
|
|
13
15
|
}
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
+
if (hasStartupFailure) {
|
|
17
|
+
parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId>` and rerun with `gh run rerun <runId>` if the workflow should be retried");
|
|
16
18
|
}
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
+
if (hasExternal) {
|
|
20
|
+
parts.push("for `external` entries (no run ID, has URL): open the URL to inspect the failure");
|
|
19
21
|
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
+
if (hasBare) {
|
|
23
|
+
parts.push("for `(no runId)` entries: no log or URL is available — escalate to a human for manual investigation");
|
|
22
24
|
}
|
|
23
|
-
return
|
|
25
|
+
return [`For each failing check under \`## Failing checks\`: ${parts.join("; ")}.`];
|
|
24
26
|
}
|