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/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
|
|
23
|
+
The CLI emits unified recheck instructions. 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
|
|
|
@@ -97,7 +97,7 @@ Some other workflow improvements:
|
|
|
97
97
|
|
|
98
98
|
Recommendations:
|
|
99
99
|
|
|
100
|
-
- Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling the reusable command until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).
|
|
100
|
+
- Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling the reusable command until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).
|
|
101
101
|
- Instruct your agents to write comments in a single review (comment, changes requested, or approved). This allows the review's comments/threads to be minimized or resolved together, keeping your pull request history clean. If you write inline comments outside of a review, each comment would still show up in the pull request history and take up space.
|
|
102
102
|
- Avoid sticky comments as they will continue to be hidden. Instead, just make a new comment, especially on reviews. If you really want sticky comments, instruct your agent to unhide/unminimize them when updating them.
|
|
103
103
|
- Avoid having automation edit comments, reviews, or threads in place because updated items get minimized. Instead, always make a new review, comment, thread, etc.
|
|
@@ -134,7 +134,19 @@ npx pr-shepherd iterate 42 # legacy-compatible spelling
|
|
|
134
134
|
|
|
135
135
|
## Iterate decision loop
|
|
136
136
|
|
|
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`).
|
|
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`). 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
|
+
|
|
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.
|
|
138
150
|
|
|
139
151
|
## Install
|
|
140
152
|
|
|
@@ -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,26 +210,21 @@ 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
|
|
|
203
217
|
The plugin only installs the skill; it does not install the CLI into target repositories. To install the CLI globally instead, use `npm install -g pr-shepherd`.
|
|
204
218
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
```bash
|
|
208
|
-
export AGENT=codex
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
Then iterate a PR from Codex with the target repository's package runner:
|
|
219
|
+
Iterate a PR from Codex with the target repository's package runner:
|
|
212
220
|
|
|
213
221
|
```bash
|
|
214
222
|
<runner> pr-shepherd iterate 42
|
|
215
223
|
```
|
|
216
224
|
|
|
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`.
|
|
225
|
+
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
226
|
|
|
219
|
-
Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`.
|
|
227
|
+
Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. 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
228
|
|
|
221
229
|
### As a global CLI
|
|
222
230
|
|
|
@@ -241,7 +249,7 @@ actions:
|
|
|
241
249
|
autoMarkReady: false # disable to stay draft until you manually promote
|
|
242
250
|
```
|
|
243
251
|
|
|
244
|
-
Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`, then `GITHUB_PERSONAL_ACCESS_TOKEN`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log)
|
|
252
|
+
Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`, then `GITHUB_PERSONAL_ACCESS_TOKEN`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log).
|
|
245
253
|
|
|
246
254
|
See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
|
|
247
255
|
|
package/bin/cli/args.mjs
CHANGED
|
@@ -15,6 +15,8 @@ const FLAGS_WITH_VALUES = new Set([
|
|
|
15
15
|
"--resolve-thread-ids",
|
|
16
16
|
"--minimize-comment-ids",
|
|
17
17
|
"--dismiss-review-ids",
|
|
18
|
+
"--interval",
|
|
19
|
+
"--timeout",
|
|
18
20
|
]);
|
|
19
21
|
// Boolean flags that do NOT consume the next argument. Any --flag not in this
|
|
20
22
|
// set and not in FLAGS_WITH_VALUES is treated conservatively as value-taking
|
|
@@ -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
|
+
}
|
|
@@ -43,7 +43,7 @@ function isDefaultIterateFlag(arg) {
|
|
|
43
43
|
function writeDefaultUsageError(arg) {
|
|
44
44
|
process.stderr.write(`Unknown subcommand: ${arg}\n`);
|
|
45
45
|
process.stderr.write("Usage: pr-shepherd [PR] [options]\n" +
|
|
46
|
-
" pr-shepherd <resolve|commit-suggestion|iterate|log-file> [options]\n" +
|
|
46
|
+
" pr-shepherd <resolve|commit-suggestion|iterate|poll|log-file|clean> [options]\n" +
|
|
47
47
|
" pr-shepherd --version | -v\n");
|
|
48
48
|
process.exitCode = 1;
|
|
49
49
|
}
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
export function validateSecondsDurationFlag(command, flag, value, presentAsSeparateArg) {
|
|
2
|
+
if (value === null) {
|
|
3
|
+
if (presentAsSeparateArg) {
|
|
4
|
+
process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} 30s)\n`);
|
|
5
|
+
process.exitCode = 1;
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
if (trimmed.startsWith("--")) {
|
|
12
|
+
process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} 30s)\n`);
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (!/^[1-9]\d*(?:s|sec|seconds?|m|min|minutes?|h|hours?)?$/.test(trimmed)) {
|
|
17
|
+
process.stderr.write(`${command}: invalid ${flag}: ${value}. Expected a duration like 30s, 5m, 1h, or bare seconds (e.g. 30).\n`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
1
23
|
export function validateDurationFlag(command, flag, value, presentAsSeparateArg) {
|
|
2
24
|
if (value === null) {
|
|
3
25
|
if (presentAsSeparateArg) {
|
package/bin/cli/exit-codes.mjs
CHANGED
|
@@ -9,6 +9,20 @@ export function parseDurationToMinutes(s, defaultMinutes) {
|
|
|
9
9
|
return n * 60;
|
|
10
10
|
return n;
|
|
11
11
|
}
|
|
12
|
+
export function parseDurationToSeconds(s, defaultSeconds) {
|
|
13
|
+
const m = /^(\d+)(s|sec|seconds?|m|min|minutes?|h|hours?)?$/.exec(s.trim());
|
|
14
|
+
if (!m)
|
|
15
|
+
return defaultSeconds;
|
|
16
|
+
const n = parseInt(m[1], 10);
|
|
17
|
+
if (!Number.isFinite(n))
|
|
18
|
+
return defaultSeconds;
|
|
19
|
+
const unit = m[2] ?? "s";
|
|
20
|
+
if (unit.startsWith("h"))
|
|
21
|
+
return n * 3600;
|
|
22
|
+
if (unit.startsWith("m"))
|
|
23
|
+
return n * 60;
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
12
26
|
export function statusToExitCode(status) {
|
|
13
27
|
switch (status) {
|
|
14
28
|
case "MERGED":
|
|
@@ -1,10 +1,9 @@
|
|
|
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
|
-
const runtime = opts?.runtime ?? "claude";
|
|
8
7
|
const readyDelaySuffix = opts?.readyDelaySuffix;
|
|
9
8
|
const runner = opts?.runner;
|
|
10
9
|
const sections = [header];
|
|
@@ -92,15 +91,8 @@ export function formatFixCodeResult(header, result, opts) {
|
|
|
92
91
|
const firstLookTotal = result.fix.firstLookThreads.length + result.fix.firstLookComments.length;
|
|
93
92
|
if (firstLookTotal > 0) {
|
|
94
93
|
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"));
|
|
94
|
+
const resolutionOnlyIds = new Set(result.fix.resolutionOnlyThreads.map((t) => t.id));
|
|
95
|
+
sections.push(buildFirstLookBullets(result.fix.firstLookThreads, resolutionOnlyIds, result.fix.firstLookComments).join("\n"));
|
|
104
96
|
}
|
|
105
97
|
if (result.fix.inProgressRunIds.length > 0) {
|
|
106
98
|
sections.push("## In-progress runs");
|
|
@@ -117,7 +109,7 @@ export function formatFixCodeResult(header, result, opts) {
|
|
|
117
109
|
}
|
|
118
110
|
sections.push(postFixLines.join("\n"));
|
|
119
111
|
sections.push("## Instructions");
|
|
120
|
-
sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr,
|
|
112
|
+
sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner)));
|
|
121
113
|
return joinSections(sections);
|
|
122
114
|
}
|
|
123
115
|
function blockquote(body) {
|
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,66 @@
|
|
|
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
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
5
|
+
import { parseCommonArgs, getFlag } from "./args.mjs";
|
|
6
|
+
import { formatCommitSuggestionResult, formatCleanResult } from "./formatters.mjs";
|
|
7
|
+
import { parseIterateFlags } from "./iterate-flags.mjs";
|
|
8
|
+
import { emitIterateResult } from "./iterate-emitter.mjs";
|
|
9
|
+
const CLEAN_VARIANTS = new Set(["pr", "branch", "current", "repo", "all"]);
|
|
10
|
+
export async function handleClean(args) {
|
|
11
|
+
const variant = args[0];
|
|
12
|
+
if (!variant || !CLEAN_VARIANTS.has(variant)) {
|
|
13
|
+
process.stderr.write("Usage: pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]\n");
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const rest = args.slice(1);
|
|
18
|
+
for (const a of rest) {
|
|
19
|
+
if (!a.startsWith("--"))
|
|
20
|
+
continue;
|
|
21
|
+
if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
|
|
22
|
+
continue;
|
|
23
|
+
process.stderr.write(`pr-shepherd: clean: unknown flag: "${a}"\n`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const fmtIdx = rest.indexOf("--format");
|
|
28
|
+
const fmtEqEntry = rest.find((a) => a.startsWith("--format="));
|
|
29
|
+
let formatValue;
|
|
30
|
+
if (fmtEqEntry !== undefined) {
|
|
31
|
+
formatValue = fmtEqEntry.slice("--format=".length);
|
|
32
|
+
}
|
|
33
|
+
else if (fmtIdx !== -1 && fmtIdx + 1 < rest.length && !rest[fmtIdx + 1].startsWith("--")) {
|
|
34
|
+
formatValue = rest[fmtIdx + 1];
|
|
35
|
+
}
|
|
36
|
+
if (formatValue !== undefined && formatValue !== "text" && formatValue !== "json") {
|
|
37
|
+
process.stderr.write(`pr-shepherd: clean: invalid --format value: "${formatValue}". Expected "text" or "json".\n`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const jsonOut = formatValue === "json";
|
|
42
|
+
const dryRun = rest.includes("--dry-run");
|
|
43
|
+
// Skip the value consumed by --format <value> so it isn't mistaken for the positional.
|
|
44
|
+
const flagConsumedIndices = new Set();
|
|
45
|
+
if (fmtIdx !== -1 && fmtIdx + 1 < rest.length && !rest[fmtIdx + 1].startsWith("--")) {
|
|
46
|
+
flagConsumedIndices.add(fmtIdx);
|
|
47
|
+
flagConsumedIndices.add(fmtIdx + 1);
|
|
48
|
+
}
|
|
49
|
+
const positionals = rest.filter((a, i) => !flagConsumedIndices.has(i) && !a.startsWith("--"));
|
|
50
|
+
if (positionals.length > 1) {
|
|
51
|
+
process.stderr.write(`pr-shepherd: clean: too many positional arguments (expected at most 1, got ${positionals.length})\n`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const value = positionals[0];
|
|
56
|
+
const result = await runClean({ variant: variant, value, dryRun });
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
process.stderr.write(`pr-shepherd: clean: ${result.error}\n`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${formatCleanResult(result)}\n`);
|
|
63
|
+
}
|
|
9
64
|
export async function handleCommitSuggestion(args) {
|
|
10
65
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
11
66
|
const threadId = getFlag(extra, "--thread-id");
|
|
@@ -34,41 +89,22 @@ export async function handleCommitSuggestion(args) {
|
|
|
34
89
|
}
|
|
35
90
|
export async function handleIterate(args) {
|
|
36
91
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
37
|
-
const runtime = detectAgentRuntime();
|
|
38
|
-
const readyDelayStr = getFlag(extra, "--ready-delay");
|
|
39
|
-
const readyDelaySuffix = validateDurationFlag("pr-shepherd", "--ready-delay", readyDelayStr, hasFlag(extra, "--ready-delay"));
|
|
40
|
-
if (readyDelaySuffix === null)
|
|
41
|
-
return;
|
|
42
92
|
const cfg = loadConfig();
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const stallTimeoutStr = getFlag(extra, "--stall-timeout");
|
|
47
|
-
const stallTimeoutSeconds = stallTimeoutStr
|
|
48
|
-
? parseDurationToMinutes(stallTimeoutStr, cfg.iterate.stallTimeoutMinutes) * 60
|
|
49
|
-
: cfg.iterate.stallTimeoutMinutes * 60;
|
|
93
|
+
const flags = parseIterateFlags(extra, cfg);
|
|
94
|
+
if (flags.readyDelaySuffix === null)
|
|
95
|
+
return;
|
|
50
96
|
const result = await runIterate({
|
|
51
97
|
...globalOpts,
|
|
52
98
|
prNumber,
|
|
53
|
-
readyDelaySeconds,
|
|
54
|
-
stallTimeoutSeconds,
|
|
55
|
-
noAutoMarkReady,
|
|
56
|
-
noAutoCancelActionable,
|
|
99
|
+
readyDelaySeconds: flags.readyDelaySeconds,
|
|
100
|
+
stallTimeoutSeconds: flags.stallTimeoutSeconds,
|
|
101
|
+
noAutoMarkReady: flags.noAutoMarkReady,
|
|
102
|
+
noAutoCancelActionable: flags.noAutoCancelActionable,
|
|
57
103
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
emitIterateResult(result, {
|
|
105
|
+
format: globalOpts.format,
|
|
106
|
+
verbose: globalOpts.verbose ?? false,
|
|
107
|
+
readyDelaySuffix: flags.readyDelaySuffix ?? undefined,
|
|
61
108
|
runner: cfg.cli?.runner,
|
|
62
|
-
};
|
|
63
|
-
if (globalOpts.format === "json") {
|
|
64
|
-
const output = globalOpts.verbose
|
|
65
|
-
? projectIterateVerbose(result, projectionOpts)
|
|
66
|
-
: projectIterateLean(result, projectionOpts);
|
|
67
|
-
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
const text = formatIterateResult(result, { verbose: globalOpts.verbose, ...projectionOpts });
|
|
71
|
-
process.stdout.write(`${text}\n`);
|
|
72
|
-
}
|
|
73
|
-
process.exitCode = iterateActionToExitCode(result.action);
|
|
109
|
+
});
|
|
74
110
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { iterateActionToExitCode } from "./exit-codes.mjs";
|
|
2
|
+
import { formatIterateResult, projectIterateLean, projectIterateVerbose } from "./formatters.mjs";
|
|
3
|
+
export function emitIterateResult(result, opts) {
|
|
4
|
+
const projectionOpts = {
|
|
5
|
+
readyDelaySuffix: opts.readyDelaySuffix,
|
|
6
|
+
runner: opts.runner,
|
|
7
|
+
};
|
|
8
|
+
if (opts.format === "json") {
|
|
9
|
+
const output = opts.verbose
|
|
10
|
+
? projectIterateVerbose(result, projectionOpts)
|
|
11
|
+
: projectIterateLean(result, projectionOpts);
|
|
12
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
const text = formatIterateResult(result, { verbose: opts.verbose, ...projectionOpts });
|
|
16
|
+
process.stdout.write(`${text}\n`);
|
|
17
|
+
}
|
|
18
|
+
process.exitCode = iterateActionToExitCode(result.action);
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getFlag, hasFlag } from "./args.mjs";
|
|
2
|
+
import { parseDurationToMinutes } from "./exit-codes.mjs";
|
|
3
|
+
import { validateDurationFlag } from "./duration-flag.mjs";
|
|
4
|
+
export function parseIterateFlags(extra, cfg) {
|
|
5
|
+
const readyDelayStr = getFlag(extra, "--ready-delay");
|
|
6
|
+
const readyDelaySuffix = validateDurationFlag("pr-shepherd", "--ready-delay", readyDelayStr, hasFlag(extra, "--ready-delay"));
|
|
7
|
+
const readyDelaySeconds = parseDurationToMinutes(readyDelaySuffix ?? "", cfg.watch.readyDelayMinutes) * 60;
|
|
8
|
+
const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
|
|
9
|
+
const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
|
|
10
|
+
const stallTimeoutStr = getFlag(extra, "--stall-timeout");
|
|
11
|
+
const stallTimeoutSeconds = stallTimeoutStr
|
|
12
|
+
? parseDurationToMinutes(stallTimeoutStr, cfg.iterate.stallTimeoutMinutes) * 60
|
|
13
|
+
: cfg.iterate.stallTimeoutMinutes * 60;
|
|
14
|
+
return {
|
|
15
|
+
readyDelaySuffix,
|
|
16
|
+
readyDelaySeconds,
|
|
17
|
+
stallTimeoutSeconds,
|
|
18
|
+
noAutoMarkReady,
|
|
19
|
+
noAutoCancelActionable,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -17,7 +17,6 @@ import { adaptIterateLog, buildSimpleIterateInstructions, numberInstructions, }
|
|
|
17
17
|
*/
|
|
18
18
|
export function formatIterateResult(result, opts) {
|
|
19
19
|
const verbose = opts?.verbose ?? false;
|
|
20
|
-
const runtime = opts?.runtime ?? "claude";
|
|
21
20
|
const readyDelaySuffix = opts?.readyDelaySuffix;
|
|
22
21
|
const runner = opts?.runner;
|
|
23
22
|
const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
|
|
@@ -27,7 +26,14 @@ export function formatIterateResult(result, opts) {
|
|
|
27
26
|
const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
|
|
28
27
|
let summaryLine;
|
|
29
28
|
if (verbose) {
|
|
30
|
-
|
|
29
|
+
let verboseBranch = "";
|
|
30
|
+
if (result.mergeStatus === "BEHIND" && result.baseBranch) {
|
|
31
|
+
verboseBranch = ` · **branch** behind \`origin/${result.baseBranch}\``;
|
|
32
|
+
}
|
|
33
|
+
else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
|
|
34
|
+
verboseBranch = ` · **branch** conflicts with \`origin/${result.baseBranch}\``;
|
|
35
|
+
}
|
|
36
|
+
summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **blockingBotReviewInProgress** ${result.blockingBotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}${verboseBranch}`;
|
|
31
37
|
}
|
|
32
38
|
else {
|
|
33
39
|
const counts = [`${result.summary.passing} passing`];
|
|
@@ -45,37 +51,68 @@ export function formatIterateResult(result, opts) {
|
|
|
45
51
|
segs.push(`**blockingBotReviewInProgress**`);
|
|
46
52
|
if (result.isDraft)
|
|
47
53
|
segs.push(`**isDraft**`);
|
|
54
|
+
if (result.mergeStatus === "BEHIND" && result.baseBranch) {
|
|
55
|
+
segs.push(`**branch** behind \`origin/${result.baseBranch}\``);
|
|
56
|
+
}
|
|
57
|
+
else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
|
|
58
|
+
segs.push(`**branch** conflicts with \`origin/${result.baseBranch}\``);
|
|
59
|
+
}
|
|
48
60
|
summaryLine = segs.join(" · ");
|
|
49
61
|
}
|
|
50
|
-
const
|
|
62
|
+
const bp = result.branchProtection;
|
|
63
|
+
const requiredParts = [];
|
|
64
|
+
if (bp) {
|
|
65
|
+
if (bp.requiresApprovingReviews && bp.requiredApprovingReviewCount > 0) {
|
|
66
|
+
requiredParts.push(`approvals \`${bp.requiredApprovingReviewCount}\``);
|
|
67
|
+
}
|
|
68
|
+
if (bp.requiresConversationResolution) {
|
|
69
|
+
requiredParts.push("conversation-resolution required");
|
|
70
|
+
}
|
|
71
|
+
if (bp.requiresStatusChecks) {
|
|
72
|
+
if (bp.requiredStatusCheckContexts.length > 0) {
|
|
73
|
+
requiredParts.push(`checks: ${bp.requiredStatusCheckContexts.map((c) => `\`${c}\``).join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
requiredParts.push("status checks required");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const requiredLine = requiredParts.length > 0 ? `**required** ${requiredParts.join(", ")}` : null;
|
|
81
|
+
const headerLines = [heading, "", baseLine, summaryLine];
|
|
82
|
+
if (requiredLine)
|
|
83
|
+
headerLines.push(requiredLine);
|
|
84
|
+
const header = headerLines.join("\n");
|
|
51
85
|
switch (result.action) {
|
|
52
86
|
case "wait":
|
|
53
87
|
return joinSections([
|
|
54
88
|
header,
|
|
55
|
-
adaptIterateLog(result.log
|
|
56
|
-
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result,
|
|
89
|
+
adaptIterateLog(result.log),
|
|
90
|
+
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
|
|
57
91
|
]);
|
|
58
92
|
case "mark_ready":
|
|
59
93
|
return joinSections([
|
|
60
94
|
header,
|
|
61
|
-
adaptIterateLog(result.log
|
|
62
|
-
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result,
|
|
95
|
+
adaptIterateLog(result.log),
|
|
96
|
+
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
|
|
63
97
|
]);
|
|
64
|
-
case "cancel":
|
|
98
|
+
case "cancel": {
|
|
99
|
+
const cancelHeaderLines = [`${heading} — ${result.reason}`, "", baseLine, summaryLine];
|
|
100
|
+
if (requiredLine)
|
|
101
|
+
cancelHeaderLines.push(requiredLine);
|
|
65
102
|
return joinSections([
|
|
66
|
-
|
|
67
|
-
adaptIterateLog(result.log
|
|
68
|
-
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result,
|
|
103
|
+
cancelHeaderLines.join("\n"),
|
|
104
|
+
adaptIterateLog(result.log),
|
|
105
|
+
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
|
|
69
106
|
]);
|
|
107
|
+
}
|
|
70
108
|
case "escalate":
|
|
71
109
|
return joinSections([
|
|
72
110
|
header,
|
|
73
111
|
result.escalate.humanMessage,
|
|
74
|
-
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result,
|
|
112
|
+
`## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
|
|
75
113
|
]);
|
|
76
114
|
case "fix_code":
|
|
77
115
|
return formatFixCodeResult(header, result, {
|
|
78
|
-
runtime,
|
|
79
116
|
readyDelaySuffix,
|
|
80
117
|
runner,
|
|
81
118
|
});
|
|
@@ -1,39 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FIX_INSTRUCTION_STOP } from "../commands/iterate/render.mjs";
|
|
2
2
|
import { buildPrShepherdCommand } from "./runner.mjs";
|
|
3
|
-
function buildRecheckInstruction(
|
|
4
|
-
|
|
5
|
-
return `Pick a fresh sleep/timeout between 30 seconds and 4 minutes, wait that long, then rerun \`${rerunCommand}\` to ${purpose}.`;
|
|
6
|
-
}
|
|
7
|
-
return `Schedule one session-only follow-up task to run \`${rerunCommand}\` to ${purpose} once after a fresh delay between 30 seconds and 4 minutes, then end this turn. Do not sleep or rerun inline.`;
|
|
3
|
+
function buildRecheckInstruction(rerunCommand, purpose) {
|
|
4
|
+
return `Recheck: rerun \`${rerunCommand}\` to ${purpose} once after a fresh 30s–4m delay.`;
|
|
8
5
|
}
|
|
9
|
-
export function buildSimpleIterateInstructions(result,
|
|
6
|
+
export function buildSimpleIterateInstructions(result, readyDelaySuffix, runner) {
|
|
10
7
|
const rerunCommand = buildIterateCommand(result.pr, readyDelaySuffix, runner);
|
|
11
8
|
switch (result.action) {
|
|
12
9
|
case "wait":
|
|
13
|
-
return [buildRecheckInstruction(
|
|
10
|
+
return [buildRecheckInstruction(rerunCommand, "continue the active goal")];
|
|
14
11
|
case "mark_ready":
|
|
15
12
|
return [
|
|
16
|
-
`The CLI already marked the PR ready for review. ${buildRecheckInstruction(
|
|
13
|
+
`The CLI already marked the PR ready for review. ${buildRecheckInstruction(rerunCommand, "recheck")}`,
|
|
17
14
|
];
|
|
18
15
|
case "cancel":
|
|
19
16
|
return ["Stop — the active goal is complete."];
|
|
20
17
|
case "escalate":
|
|
21
|
-
return [
|
|
18
|
+
return [
|
|
19
|
+
"Stop — the PR needs human direction before iterating can resume. This is a manual handoff; do not continue automated fix attempts.",
|
|
20
|
+
];
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
|
-
export function adaptFixCodeInstructions(instructions, pr,
|
|
23
|
+
export function adaptFixCodeInstructions(instructions, pr, readyDelaySuffix, runner) {
|
|
25
24
|
const rerunCommand = buildIterateCommand(pr, readyDelaySuffix, runner);
|
|
26
25
|
return instructions.map((instruction) => {
|
|
27
|
-
if (instruction ===
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
if (instruction === FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK) {
|
|
31
|
-
return buildRecheckInstruction(runtime, rerunCommand, "recheck");
|
|
26
|
+
if (instruction === FIX_INSTRUCTION_STOP) {
|
|
27
|
+
return `${instruction} ${buildRecheckInstruction(rerunCommand, "recheck")}`;
|
|
32
28
|
}
|
|
33
29
|
return instruction;
|
|
34
30
|
});
|
|
35
31
|
}
|
|
36
|
-
export function adaptIterateLog(log
|
|
32
|
+
export function adaptIterateLog(log) {
|
|
37
33
|
return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
|
|
38
34
|
}
|
|
39
35
|
export function buildIterateCommand(pr, readyDelaySuffix, runner) {
|