pr-shepherd 0.33.0 → 0.34.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 +7 -2
- package/bin/cli/default-poll.mjs +2 -1
- package/bin/cli/duration-flag.mjs +5 -4
- package/bin/cli/{exit-codes.mjs → duration.mjs} +0 -26
- package/bin/cli/handlers.mjs +9 -8
- package/bin/cli/help-command-pages.mjs +11 -72
- package/bin/cli/help-iterate-poll-pages.mjs +72 -0
- package/bin/cli/help-top-page.mjs +8 -5
- package/bin/cli/iterate-emitter.mjs +2 -2
- package/bin/cli/iterate-flags.mjs +1 -1
- package/bin/cli/journal-handler.mjs +6 -5
- package/bin/cli/poll-handler.mjs +1 -1
- package/bin/cli/resolve-validators.mjs +3 -2
- package/bin/cli-parser.mjs +5 -4
- package/bin/commands/check.mjs +2 -1
- package/bin/commands/commit-suggestion.mjs +18 -17
- package/bin/commands/iterate/index.mjs +5 -3
- package/bin/commands/mark-files-as-viewed.mjs +7 -5
- package/bin/commands/resolve-mutate.mjs +2 -1
- package/bin/exit-codes.mjs +74 -0
- package/bin/github/batch-response.mjs +8 -3
- package/bin/github/errors.mjs +26 -2
- package/bin/github/graphql-response.mjs +5 -0
- package/bin/github/http-auth.mjs +2 -1
- package/bin/github/rest-http.mjs +19 -5
- package/bin/index.mjs +2 -1
- package/package.json +3 -3
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -228,6 +228,11 @@ Ready-to-use examples for common patterns are in [`examples/classification/`](ex
|
|
|
228
228
|
|
|
229
229
|
Full reference: [docs/README.md](docs/README.md).
|
|
230
230
|
|
|
231
|
-
##
|
|
231
|
+
## Harness Ecosystem
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
This is part of the following harness ecosystem:
|
|
234
|
+
|
|
235
|
+
- [auto-harness](https://github.com/jonathanong/auto-harness) - non-interactive agent CLI orchestration across sandboxes
|
|
236
|
+
- [agent-blackboard](https://github.com/jonathanong/agent-blackboard) - session-scoped telemetry for autonomous agents
|
|
237
|
+
- [pr-shepherd](https://github.com/jonathanong/pr-shepherd) - autonomous pull request shepherd
|
|
238
|
+
- [no-mistakes](https://github.com/jonathanong/no-mistakes) - deterministic AST-based codebase intelligence, test selection, and linting for agents
|
package/bin/cli/default-poll.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EXIT } from "../exit-codes.mjs";
|
|
1
2
|
import { parsePrNumber } from "./args.mjs";
|
|
2
3
|
import { validateDefaultArgs } from "./validate-default-args.mjs";
|
|
3
4
|
import { USAGE } from "./help.mjs";
|
|
@@ -30,5 +31,5 @@ function isDefaultPollFlag(arg) {
|
|
|
30
31
|
function writeDefaultUsageError(arg) {
|
|
31
32
|
process.stderr.write(`Unknown subcommand: ${arg}\n`);
|
|
32
33
|
process.stderr.write(`${USAGE.top}\n`);
|
|
33
|
-
process.exitCode =
|
|
34
|
+
process.exitCode = EXIT.USAGE;
|
|
34
35
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EXIT } from "../exit-codes.mjs";
|
|
2
|
+
import { parseSecondsDurationParts } from "./duration.mjs";
|
|
2
3
|
export function validateSecondsDurationFlag(command, flag, value, presentAsSeparateArg, opts = {}) {
|
|
3
4
|
const bareUnit = opts.defaultUnit === "m" ? "minutes" : "seconds";
|
|
4
5
|
const example = opts.defaultUnit === "m" ? "15m" : "30s";
|
|
5
6
|
if (value === null) {
|
|
6
7
|
if (presentAsSeparateArg) {
|
|
7
8
|
process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} ${example})\n`);
|
|
8
|
-
process.exitCode =
|
|
9
|
+
process.exitCode = EXIT.USAGE;
|
|
9
10
|
return null;
|
|
10
11
|
}
|
|
11
12
|
return undefined;
|
|
@@ -13,12 +14,12 @@ export function validateSecondsDurationFlag(command, flag, value, presentAsSepar
|
|
|
13
14
|
const trimmed = value.trim();
|
|
14
15
|
if (trimmed.startsWith("--")) {
|
|
15
16
|
process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} ${example})\n`);
|
|
16
|
-
process.exitCode =
|
|
17
|
+
process.exitCode = EXIT.USAGE;
|
|
17
18
|
return null;
|
|
18
19
|
}
|
|
19
20
|
if (!parseSecondsDurationParts(trimmed, opts)) {
|
|
20
21
|
process.stderr.write(`${command}: invalid ${flag}: ${value}. Expected a duration like 30s, 4.5m, 1h, or a bare number (${bareUnit}).\n`);
|
|
21
|
-
process.exitCode =
|
|
22
|
+
process.exitCode = EXIT.USAGE;
|
|
22
23
|
return null;
|
|
23
24
|
}
|
|
24
25
|
return trimmed;
|
|
@@ -40,29 +40,3 @@ export function parseDurationToSeconds(s, defaultSeconds, opts = {}) {
|
|
|
40
40
|
return parsed.value * 60;
|
|
41
41
|
return parsed.value;
|
|
42
42
|
}
|
|
43
|
-
export function statusToExitCode(status) {
|
|
44
|
-
switch (status) {
|
|
45
|
-
case "MERGED":
|
|
46
|
-
case "CLOSED":
|
|
47
|
-
case "READY":
|
|
48
|
-
return 0;
|
|
49
|
-
case "IN_PROGRESS":
|
|
50
|
-
return 2;
|
|
51
|
-
case "UNRESOLVED_COMMENTS":
|
|
52
|
-
return 3;
|
|
53
|
-
default:
|
|
54
|
-
return 1;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
export function iterateActionToExitCode(action) {
|
|
58
|
-
switch (action) {
|
|
59
|
-
case "fix_code":
|
|
60
|
-
return 1;
|
|
61
|
-
case "cancel":
|
|
62
|
-
return 2;
|
|
63
|
-
case "escalate":
|
|
64
|
-
return 3;
|
|
65
|
-
default:
|
|
66
|
-
return 0;
|
|
67
|
-
}
|
|
68
|
-
}
|
package/bin/cli/handlers.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { runMarkFilesAsViewed } from "../commands/mark-files-as-viewed.mjs";
|
|
|
3
3
|
import { runIterate } from "../commands/iterate/index.mjs";
|
|
4
4
|
import { runClean } from "../commands/clean.mjs";
|
|
5
5
|
import { loadConfig } from "../config/load.mjs";
|
|
6
|
+
import { EXIT } from "../exit-codes.mjs";
|
|
6
7
|
import { parseCommonArgs, getFlag } from "./args.mjs";
|
|
7
8
|
import { USAGE } from "./help.mjs";
|
|
8
9
|
import { formatCommitSuggestionResult, formatCleanResult, formatMarkFilesAsViewedResult, } from "./formatters.mjs";
|
|
@@ -14,7 +15,7 @@ export async function handleClean(args) {
|
|
|
14
15
|
const variant = args[0];
|
|
15
16
|
if (!variant || !CLEAN_VARIANTS.has(variant)) {
|
|
16
17
|
process.stderr.write(`${USAGE.clean}\n`);
|
|
17
|
-
process.exitCode =
|
|
18
|
+
process.exitCode = EXIT.USAGE;
|
|
18
19
|
return;
|
|
19
20
|
}
|
|
20
21
|
const rest = args.slice(1);
|
|
@@ -24,7 +25,7 @@ export async function handleClean(args) {
|
|
|
24
25
|
if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
|
|
25
26
|
continue;
|
|
26
27
|
process.stderr.write(`pr-shepherd: clean: unknown flag: "${a}"\n`);
|
|
27
|
-
process.exitCode =
|
|
28
|
+
process.exitCode = EXIT.USAGE;
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
31
|
const fmtIdx = rest.indexOf("--format");
|
|
@@ -38,7 +39,7 @@ export async function handleClean(args) {
|
|
|
38
39
|
}
|
|
39
40
|
if (formatValue !== undefined && formatValue !== "text" && formatValue !== "json") {
|
|
40
41
|
process.stderr.write(`pr-shepherd: clean: invalid --format value: "${formatValue}". Expected "text" or "json".\n`);
|
|
41
|
-
process.exitCode =
|
|
42
|
+
process.exitCode = EXIT.USAGE;
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
44
45
|
const jsonOut = formatValue === "json";
|
|
@@ -52,14 +53,14 @@ export async function handleClean(args) {
|
|
|
52
53
|
const positionals = rest.filter((a, i) => !flagConsumedIndices.has(i) && !a.startsWith("--"));
|
|
53
54
|
if (positionals.length > 1) {
|
|
54
55
|
process.stderr.write(`pr-shepherd: clean: too many positional arguments (expected at most 1, got ${positionals.length})\n`);
|
|
55
|
-
process.exitCode =
|
|
56
|
+
process.exitCode = EXIT.USAGE;
|
|
56
57
|
return;
|
|
57
58
|
}
|
|
58
59
|
const value = positionals[0];
|
|
59
60
|
const result = await runClean({ variant: variant, value, dryRun });
|
|
60
61
|
if (!result.ok) {
|
|
61
62
|
process.stderr.write(`pr-shepherd: clean: ${result.error}\n`);
|
|
62
|
-
process.exitCode =
|
|
63
|
+
process.exitCode = EXIT.SOFTWARE;
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
66
|
process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${formatCleanResult(result)}\n`);
|
|
@@ -69,13 +70,13 @@ export async function handleCommitSuggestion(args) {
|
|
|
69
70
|
const threadId = getFlag(extra, "--thread-id");
|
|
70
71
|
if (!threadId) {
|
|
71
72
|
process.stderr.write(`${USAGE["commit-suggestion"]}\n`);
|
|
72
|
-
process.exitCode =
|
|
73
|
+
process.exitCode = EXIT.USAGE;
|
|
73
74
|
return;
|
|
74
75
|
}
|
|
75
76
|
const message = getFlag(extra, "--message") ?? undefined;
|
|
76
77
|
if (!message || message.trim() === "") {
|
|
77
78
|
process.stderr.write("--message is required and must be non-empty\n");
|
|
78
|
-
process.exitCode =
|
|
79
|
+
process.exitCode = EXIT.USAGE;
|
|
79
80
|
return;
|
|
80
81
|
}
|
|
81
82
|
const description = getFlag(extra, "--description") ?? undefined;
|
|
@@ -115,7 +116,7 @@ export async function handleMarkFilesAsViewed(args) {
|
|
|
115
116
|
const parsed = parseMarkFilesAsViewedArgs(extra);
|
|
116
117
|
if (!parsed.ok) {
|
|
117
118
|
process.stderr.write(`pr-shepherd: mark-files-as-viewed: ${parsed.error}\n`);
|
|
118
|
-
process.exitCode =
|
|
119
|
+
process.exitCode = EXIT.USAGE;
|
|
119
120
|
return;
|
|
120
121
|
}
|
|
121
122
|
const result = await runMarkFilesAsViewed({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LOG_FILE_USAGE } from "./help-log-file-page.mjs";
|
|
2
|
+
import { ITERATE_USAGE, POLL_USAGE } from "./help-iterate-poll-pages.mjs";
|
|
2
3
|
export const COMMAND_USAGE = {
|
|
3
4
|
resolve: `pr-shepherd resolve
|
|
4
5
|
|
|
@@ -28,7 +29,7 @@ At least one non-empty action flag is required:
|
|
|
28
29
|
--reply-thread-ids, --resolve-thread-ids, --minimize-comment-ids, or --dismiss-review-ids.
|
|
29
30
|
|
|
30
31
|
PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
|
|
31
|
-
Exit code: 0 on success;
|
|
32
|
+
Exit code: 0 on success; nonzero on failure (sysexits.h — see docs/exit-codes.md).`,
|
|
32
33
|
"commit-suggestion": `pr-shepherd commit-suggestion
|
|
33
34
|
|
|
34
35
|
Build a patch and commit instructions for one GitHub review thread containing a suggestion block.
|
|
@@ -49,8 +50,10 @@ Preconditions:
|
|
|
49
50
|
The current branch must match the PR head ref, and local HEAD must match the PR head SHA.
|
|
50
51
|
|
|
51
52
|
Exit codes:
|
|
52
|
-
0
|
|
53
|
-
|
|
53
|
+
0 suggestion patch and instructions produced
|
|
54
|
+
64 usage error (missing/invalid flag)
|
|
55
|
+
69 precondition unmet (thread ineligible, branch/SHA mismatch, no open PR)
|
|
56
|
+
See docs/exit-codes.md for the full sysexits.h table.`,
|
|
54
57
|
"mark-files-as-viewed": `pr-shepherd mark-files-as-viewed
|
|
55
58
|
|
|
56
59
|
Mark changed files as viewed in the GitHub pull request diff.
|
|
@@ -70,73 +73,9 @@ Flags:
|
|
|
70
73
|
--help, -h Print this help and exit before GitHub I/O.
|
|
71
74
|
|
|
72
75
|
PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
|
|
73
|
-
Exit code: 0 on success;
|
|
74
|
-
iterate:
|
|
75
|
-
|
|
76
|
-
Run one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.
|
|
77
|
-
The output contains one action and an action-specific ## Instructions section.
|
|
78
|
-
|
|
79
|
-
Usage:
|
|
80
|
-
pr-shepherd iterate [PR] [iterate-flags]
|
|
81
|
-
|
|
82
|
-
Iterate flags:
|
|
83
|
-
--ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
|
|
84
|
-
--stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
|
|
85
|
-
--no-auto-mark-ready Do not convert draft PRs to ready for review.
|
|
86
|
-
--no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
|
|
87
|
-
--format text|json Output Markdown text or JSON. Default: text.
|
|
88
|
-
--verbose Include verbose iterate fields.
|
|
89
|
-
--help, -h Print this help and exit before GitHub, git, config, or log I/O.
|
|
90
|
-
|
|
91
|
-
Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).
|
|
92
|
-
|
|
93
|
-
Actions:
|
|
94
|
-
WAIT No immediate code action; recheck later or use pr-shepherd poll.
|
|
95
|
-
MARK_READY Draft PR was marked ready for review.
|
|
96
|
-
FIX_CODE Apply fixes, commit, push, and run the printed resolve command.
|
|
97
|
-
CANCEL Terminal state: merged/closed or ready-delay elapsed.
|
|
98
|
-
ESCALATE Terminal state requiring human direction.
|
|
99
|
-
|
|
100
|
-
Exit codes:
|
|
101
|
-
0 WAIT or MARK_READY
|
|
102
|
-
1 FIX_CODE, or a command/validation error
|
|
103
|
-
2 CANCEL
|
|
104
|
-
3 ESCALATE`,
|
|
105
|
-
poll: `pr-shepherd poll
|
|
106
|
-
|
|
107
|
-
Run iterate repeatedly while the action is WAIT. Print only the final tick to stdout.
|
|
108
|
-
Poll exits as soon as iterate returns MARK_READY, FIX_CODE, CANCEL, or ESCALATE, or when timeout
|
|
109
|
-
returns the last WAIT result. With --until-terminal, poll also continues through MARK_READY.
|
|
110
|
-
|
|
111
|
-
Usage:
|
|
112
|
-
pr-shepherd poll [PR] [poll-flags] [iterate-flags]
|
|
113
|
-
|
|
114
|
-
Poll flags:
|
|
115
|
-
--interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: 60s.
|
|
116
|
-
--timeout <duration> Maximum wall-clock wait. Bare number = seconds. Default: 4.5m.
|
|
117
|
-
--quiet-status During WAIT polling, print only changed status snapshots.
|
|
118
|
-
--until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.
|
|
119
|
-
|
|
120
|
-
Forwarded iterate flags:
|
|
121
|
-
--ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
|
|
122
|
-
--stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
|
|
123
|
-
--no-auto-mark-ready Do not convert draft PRs to ready for review.
|
|
124
|
-
--no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
|
|
125
|
-
--format text|json Output Markdown text or JSON. Default: text.
|
|
126
|
-
--verbose Include verbose iterate fields and detailed per-tick lines.
|
|
127
|
-
--help, -h Print this help and exit before GitHub, git, config, or log I/O.
|
|
128
|
-
|
|
129
|
-
Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds
|
|
130
|
-
for --interval/--timeout, minutes for --ready-delay/--stall-timeout); decimals are allowed only with
|
|
131
|
-
an explicit unit (4.5m).
|
|
132
|
-
Each WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.
|
|
133
|
-
With --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.
|
|
134
|
-
|
|
135
|
-
Exit codes:
|
|
136
|
-
0 WAIT timeout or MARK_READY
|
|
137
|
-
1 FIX_CODE, or a command/validation error
|
|
138
|
-
2 CANCEL
|
|
139
|
-
3 ESCALATE`,
|
|
76
|
+
Exit code: 0 on success; nonzero on failure (sysexits.h — see docs/exit-codes.md).`,
|
|
77
|
+
iterate: ITERATE_USAGE,
|
|
78
|
+
poll: POLL_USAGE,
|
|
140
79
|
clean: `pr-shepherd clean
|
|
141
80
|
|
|
142
81
|
Remove pr-shepherd state files from PR_SHEPHERD_STATE_DIR.
|
|
@@ -161,7 +100,7 @@ Flags:
|
|
|
161
100
|
--format text|json Output format. Default: text.
|
|
162
101
|
--help, -h Print this help and exit before any cleanup.
|
|
163
102
|
|
|
164
|
-
Exit code: 0 on success;
|
|
103
|
+
Exit code: 0 on success (including a no-op --dry-run on a nonexistent target); nonzero on failure (sysexits.h — see docs/exit-codes.md).`,
|
|
165
104
|
journal: `pr-shepherd journal
|
|
166
105
|
|
|
167
106
|
Append a list item to the ## Shepherd Journal section of a PR body.
|
|
@@ -185,6 +124,6 @@ Flags:
|
|
|
185
124
|
--format text|json Output format. Default: text.
|
|
186
125
|
--help, -h Print this help and exit before any GitHub I/O.
|
|
187
126
|
|
|
188
|
-
Exit code: 0 on success (including no-change no-op);
|
|
127
|
+
Exit code: 0 on success (including no-change no-op); nonzero on failure (sysexits.h — see docs/exit-codes.md).`,
|
|
189
128
|
"log-file": LOG_FILE_USAGE,
|
|
190
129
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const ITERATE_USAGE = `pr-shepherd iterate
|
|
2
|
+
|
|
3
|
+
Run one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.
|
|
4
|
+
The output contains one action and an action-specific ## Instructions section.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
pr-shepherd iterate [PR] [iterate-flags]
|
|
8
|
+
|
|
9
|
+
Iterate flags:
|
|
10
|
+
--ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
|
|
11
|
+
--stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
|
|
12
|
+
--no-auto-mark-ready Do not convert draft PRs to ready for review.
|
|
13
|
+
--no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
|
|
14
|
+
--format text|json Output Markdown text or JSON. Default: text.
|
|
15
|
+
--verbose Include verbose iterate fields.
|
|
16
|
+
--help, -h Print this help and exit before GitHub, git, config, or log I/O.
|
|
17
|
+
|
|
18
|
+
Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).
|
|
19
|
+
|
|
20
|
+
Actions:
|
|
21
|
+
WAIT No immediate code action; recheck later or use pr-shepherd poll.
|
|
22
|
+
MARK_READY Draft PR was marked ready for review.
|
|
23
|
+
FIX_CODE Apply fixes, commit, push, and run the printed resolve command.
|
|
24
|
+
CANCEL Terminal state: merged/closed or ready-delay elapsed.
|
|
25
|
+
ESCALATE Terminal state requiring human direction.
|
|
26
|
+
|
|
27
|
+
Exit codes:
|
|
28
|
+
0 CANCEL (merged or ready-delay elapsed)
|
|
29
|
+
10 WAIT
|
|
30
|
+
11 MARK_READY
|
|
31
|
+
12 FIX_CODE
|
|
32
|
+
13 ESCALATE
|
|
33
|
+
14 CANCEL (closed without merging)
|
|
34
|
+
A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).`;
|
|
35
|
+
export const POLL_USAGE = `pr-shepherd poll
|
|
36
|
+
|
|
37
|
+
Run iterate repeatedly while the action is WAIT. Print only the final tick to stdout.
|
|
38
|
+
Poll exits as soon as iterate returns MARK_READY, FIX_CODE, CANCEL, or ESCALATE, or when timeout
|
|
39
|
+
returns the last WAIT result. With --until-terminal, poll also continues through MARK_READY.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
pr-shepherd poll [PR] [poll-flags] [iterate-flags]
|
|
43
|
+
|
|
44
|
+
Poll flags:
|
|
45
|
+
--interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: 60s.
|
|
46
|
+
--timeout <duration> Maximum wall-clock wait. Bare number = seconds. Default: 4.5m.
|
|
47
|
+
--quiet-status During WAIT polling, print only changed status snapshots.
|
|
48
|
+
--until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.
|
|
49
|
+
|
|
50
|
+
Forwarded iterate flags:
|
|
51
|
+
--ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
|
|
52
|
+
--stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
|
|
53
|
+
--no-auto-mark-ready Do not convert draft PRs to ready for review.
|
|
54
|
+
--no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
|
|
55
|
+
--format text|json Output Markdown text or JSON. Default: text.
|
|
56
|
+
--verbose Include verbose iterate fields and detailed per-tick lines.
|
|
57
|
+
--help, -h Print this help and exit before GitHub, git, config, or log I/O.
|
|
58
|
+
|
|
59
|
+
Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds
|
|
60
|
+
for --interval/--timeout, minutes for --ready-delay/--stall-timeout); decimals are allowed only with
|
|
61
|
+
an explicit unit (4.5m).
|
|
62
|
+
Each WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.
|
|
63
|
+
With --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.
|
|
64
|
+
|
|
65
|
+
Exit codes: same as iterate (the final tick's action/reason decides the code).
|
|
66
|
+
0 CANCEL (merged or ready-delay elapsed)
|
|
67
|
+
10 WAIT (including a WAIT returned by --timeout)
|
|
68
|
+
11 MARK_READY
|
|
69
|
+
12 FIX_CODE
|
|
70
|
+
13 ESCALATE
|
|
71
|
+
14 CANCEL (closed without merging)
|
|
72
|
+
A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).`;
|
|
@@ -54,11 +54,14 @@ Clean variants:
|
|
|
54
54
|
repo Remove all state for the current repository.
|
|
55
55
|
all Remove all pr-shepherd state.
|
|
56
56
|
|
|
57
|
-
Exit codes
|
|
58
|
-
0
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
Exit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).
|
|
58
|
+
0 CANCEL (merged or ready-delay elapsed)
|
|
59
|
+
10 WAIT
|
|
60
|
+
11 MARK_READY
|
|
61
|
+
12 FIX_CODE
|
|
62
|
+
13 ESCALATE
|
|
63
|
+
14 CANCEL (closed without merging)
|
|
64
|
+
See docs/exit-codes.md for the full sysexits.h error-code table.
|
|
62
65
|
|
|
63
66
|
Duration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).
|
|
64
67
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { iterateResultToExitCode } from "../exit-codes.mjs";
|
|
2
2
|
import { formatIterateResult, projectIterateLean, projectIterateVerbose } from "./formatters.mjs";
|
|
3
3
|
export function emitIterateResult(result, opts) {
|
|
4
4
|
const projectionOpts = {
|
|
@@ -14,5 +14,5 @@ export function emitIterateResult(result, opts) {
|
|
|
14
14
|
const text = formatIterateResult(result, { verbose: opts.verbose, ...projectionOpts });
|
|
15
15
|
process.stdout.write(`${text}\n`);
|
|
16
16
|
}
|
|
17
|
-
process.exitCode =
|
|
17
|
+
process.exitCode = iterateResultToExitCode(result);
|
|
18
18
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getFlag, hasFlag } from "./args.mjs";
|
|
2
|
-
import { parseDurationToSeconds } from "./
|
|
2
|
+
import { parseDurationToSeconds } from "./duration.mjs";
|
|
3
3
|
import { validateSecondsDurationFlag } from "./duration-flag.mjs";
|
|
4
4
|
// --ready-delay and --stall-timeout are minute-family flags: a bare number means minutes, and 0 is a
|
|
5
5
|
// valid value (it disables the ready-delay settle window / stall-timeout escalation, respectively).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { EXIT, errorToExitCode } from "../exit-codes.mjs";
|
|
2
3
|
import { runJournal } from "../commands/journal/index.mjs";
|
|
3
4
|
import { getFlag, parsePrNumber } from "./args.mjs";
|
|
4
5
|
import { USAGE } from "./help.mjs";
|
|
@@ -11,14 +12,14 @@ export async function handleJournal(args) {
|
|
|
11
12
|
if (a === "--file" || a.startsWith("--file="))
|
|
12
13
|
continue;
|
|
13
14
|
process.stderr.write(`pr-shepherd: journal: unknown flag: "${a}"\n`);
|
|
14
|
-
process.exitCode =
|
|
15
|
+
process.exitCode = EXIT.USAGE;
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
17
18
|
const { prNumber, extra } = parseJournalArgs(args);
|
|
18
19
|
const filePath = getFlag(args, "--file");
|
|
19
20
|
if (filePath !== null && extra[0]) {
|
|
20
21
|
process.stderr.write(`pr-shepherd: journal: provide the entry as a positional argument or via --file, not both\n`);
|
|
21
|
-
process.exitCode =
|
|
22
|
+
process.exitCode = EXIT.USAGE;
|
|
22
23
|
return;
|
|
23
24
|
}
|
|
24
25
|
let rawItem;
|
|
@@ -27,12 +28,12 @@ export async function handleJournal(args) {
|
|
|
27
28
|
}
|
|
28
29
|
catch (e) {
|
|
29
30
|
process.stderr.write(`pr-shepherd: journal: ${String(e)}\n`);
|
|
30
|
-
process.exitCode =
|
|
31
|
+
process.exitCode = EXIT.NOINPUT;
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
34
|
if (rawItem === undefined) {
|
|
34
35
|
process.stderr.write(`${USAGE.journal}\n`);
|
|
35
|
-
process.exitCode =
|
|
36
|
+
process.exitCode = EXIT.USAGE;
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
39
|
const dryRun = args.includes("--dry-run");
|
|
@@ -49,7 +50,7 @@ export async function handleJournal(args) {
|
|
|
49
50
|
}
|
|
50
51
|
catch (e) {
|
|
51
52
|
process.stderr.write(`pr-shepherd: journal: ${String(e)}\n`);
|
|
52
|
-
process.exitCode =
|
|
53
|
+
process.exitCode = errorToExitCode(e);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
/** Reads the journal entry from a file, or from stdin when `filePath` is `-`. */
|
package/bin/cli/poll-handler.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { runPoll } from "../commands/poll.mjs";
|
|
2
2
|
import { loadConfig } from "../config/load.mjs";
|
|
3
3
|
import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
|
|
4
|
-
import { parseDurationToSeconds } from "./
|
|
4
|
+
import { parseDurationToSeconds } from "./duration.mjs";
|
|
5
5
|
import { validateSecondsDurationFlag } from "./duration-flag.mjs";
|
|
6
6
|
import { parseIterateFlags } from "./iterate-flags.mjs";
|
|
7
7
|
import { emitIterateResult } from "./iterate-emitter.mjs";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { EXIT } from "../exit-codes.mjs";
|
|
1
2
|
export function rejectPrrcMinimizeIds(ids) {
|
|
2
3
|
const prrcIds = ids.filter((id) => id.startsWith("PRRC_"));
|
|
3
4
|
if (prrcIds.length > 0) {
|
|
4
5
|
process.stderr.write(`pr-shepherd: resolve: --minimize-comment-ids contains thread comment IDs (PRRC_*): ${prrcIds.join(", ")}. Thread comments cannot be minimized individually — resolve the parent thread using --resolve-thread-ids with the PRRT_* thread ID instead.\n`);
|
|
5
|
-
process.exitCode =
|
|
6
|
+
process.exitCode = EXIT.DATAERR;
|
|
6
7
|
}
|
|
7
8
|
return prrcIds;
|
|
8
9
|
}
|
|
@@ -19,6 +20,6 @@ export function validateRequireSha(sha) {
|
|
|
19
20
|
if (/^[0-9a-f]{40}$/.test(sha))
|
|
20
21
|
return true;
|
|
21
22
|
process.stderr.write(`pr-shepherd: resolve: --require-sha must be a full 40-character lowercase hex SHA, got "${sha}". Short SHAs will never match GitHub's headRefOid. Use $(git rev-parse HEAD) to get the full SHA.\n`);
|
|
22
|
-
process.exitCode =
|
|
23
|
+
process.exitCode = EXIT.DATAERR;
|
|
23
24
|
return false;
|
|
24
25
|
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** CLI argument parsing and subcommand dispatch for pr-shepherd. See --help for usage. */
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { EXIT, errorToExitCode } from "./exit-codes.mjs";
|
|
3
4
|
import { runResolveMutate } from "./commands/resolve.mjs";
|
|
4
5
|
import { runLogFile } from "./commands/log-file.mjs";
|
|
5
6
|
import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
@@ -76,7 +77,7 @@ export async function main(argv) {
|
|
|
76
77
|
default:
|
|
77
78
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
78
79
|
process.stderr.write(`${USAGE.top}\n`);
|
|
79
|
-
process.exitCode =
|
|
80
|
+
process.exitCode = EXIT.USAGE;
|
|
80
81
|
return;
|
|
81
82
|
}
|
|
82
83
|
}
|
|
@@ -102,7 +103,7 @@ async function handleLogFile(args) {
|
|
|
102
103
|
}
|
|
103
104
|
catch (e) {
|
|
104
105
|
process.stderr.write(`pr-shepherd: log-file: ${String(e)}\n`);
|
|
105
|
-
process.exitCode =
|
|
106
|
+
process.exitCode = errorToExitCode(e);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
async function handleResolve(args) {
|
|
@@ -120,7 +121,7 @@ async function handleResolve(args) {
|
|
|
120
121
|
return;
|
|
121
122
|
if (hasFlag(extra, "--fetch")) {
|
|
122
123
|
process.stderr.write("pr-shepherd: resolve: --fetch has been removed; run pr-shepherd iterate or poll to fetch the next action.\n");
|
|
123
|
-
process.exitCode =
|
|
124
|
+
process.exitCode = EXIT.USAGE;
|
|
124
125
|
return;
|
|
125
126
|
}
|
|
126
127
|
const hasAction = resolveThreadIds.length > 0 ||
|
|
@@ -129,7 +130,7 @@ async function handleResolve(args) {
|
|
|
129
130
|
dismissReviewIds.length > 0;
|
|
130
131
|
if (!hasAction) {
|
|
131
132
|
process.stderr.write("pr-shepherd: resolve: an action flag is required (--reply-thread-ids, --resolve-thread-ids, --minimize-comment-ids, or --dismiss-review-ids).\n");
|
|
132
|
-
process.exitCode =
|
|
133
|
+
process.exitCode = EXIT.USAGE;
|
|
133
134
|
return;
|
|
134
135
|
}
|
|
135
136
|
const result = await runResolveMutate({
|
package/bin/commands/check.mjs
CHANGED
|
@@ -19,11 +19,12 @@ import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers
|
|
|
19
19
|
import { normalizeBotUsernames } from "../comments/authors.mjs";
|
|
20
20
|
import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
|
|
21
21
|
import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
|
|
22
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
22
23
|
export async function runCheck(opts) {
|
|
23
24
|
const repo = await getRepoInfo();
|
|
24
25
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
25
26
|
if (prNumber === null) {
|
|
26
|
-
throw new
|
|
27
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
27
28
|
}
|
|
28
29
|
const config = loadConfig();
|
|
29
30
|
const paginateApprovedReviews = config.iterate.minimizeApprovals;
|
|
@@ -5,50 +5,51 @@ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/cli
|
|
|
5
5
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
6
6
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
7
7
|
import { buildUnifiedDiff } from "../suggestions/patch.mjs";
|
|
8
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
8
9
|
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
9
10
|
const execFile = promisify(execFileCb);
|
|
10
11
|
export async function runCommitSuggestion(opts) {
|
|
11
12
|
if (!opts.threadId) {
|
|
12
|
-
throw new
|
|
13
|
+
throw new ShepherdError("--thread-id is required", EXIT.USAGE);
|
|
13
14
|
}
|
|
14
15
|
if (!opts.message || opts.message.trim() === "") {
|
|
15
|
-
throw new
|
|
16
|
+
throw new ShepherdError("--message is required and must be non-empty", EXIT.USAGE);
|
|
16
17
|
}
|
|
17
18
|
const repo = await getRepoInfo();
|
|
18
19
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
19
20
|
if (prNumber === null) {
|
|
20
|
-
throw new
|
|
21
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
21
22
|
}
|
|
22
23
|
const currentBranch = await getCurrentBranch();
|
|
23
24
|
const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
24
25
|
const localHeadSha = localHeadOut.trim();
|
|
25
26
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
26
27
|
if (!data.headRepoWithOwner) {
|
|
27
|
-
throw new
|
|
28
|
+
throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
|
|
28
29
|
}
|
|
29
30
|
if (currentBranch !== data.headRefName) {
|
|
30
|
-
throw new
|
|
31
|
-
`Check out "${data.headRefName}" before applying suggestions
|
|
31
|
+
throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
|
|
32
|
+
`Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
|
|
32
33
|
}
|
|
33
34
|
if (localHeadSha !== data.headRefOid) {
|
|
34
|
-
throw new
|
|
35
|
-
`Pull/rebase "${data.headRefName}" to the latest PR head and try again
|
|
35
|
+
throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
|
|
36
|
+
`Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
|
|
36
37
|
}
|
|
37
38
|
const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
|
|
38
39
|
if (!thread) {
|
|
39
|
-
throw new
|
|
40
|
+
throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
|
|
40
41
|
}
|
|
41
42
|
if (thread.isResolved) {
|
|
42
|
-
throw new
|
|
43
|
+
throw new ShepherdError(`Thread ${opts.threadId} is already resolved.`, EXIT.UNAVAILABLE);
|
|
43
44
|
}
|
|
44
45
|
if (thread.isOutdated) {
|
|
45
|
-
throw new
|
|
46
|
+
throw new ShepherdError(`Thread ${opts.threadId} is outdated.`, EXIT.UNAVAILABLE);
|
|
46
47
|
}
|
|
47
48
|
if (thread.isMinimized) {
|
|
48
|
-
throw new
|
|
49
|
+
throw new ShepherdError(`Thread ${opts.threadId} is minimized.`, EXIT.UNAVAILABLE);
|
|
49
50
|
}
|
|
50
51
|
if (!thread.path || thread.line === null) {
|
|
51
|
-
throw new
|
|
52
|
+
throw new ShepherdError(`Thread ${opts.threadId} has no file/line anchor.`, EXIT.UNAVAILABLE);
|
|
52
53
|
}
|
|
53
54
|
// Validate the target file is clean before generating the patch, so the emitted
|
|
54
55
|
// `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
|
|
@@ -59,15 +60,15 @@ export async function runCommitSuggestion(opts) {
|
|
|
59
60
|
thread.path,
|
|
60
61
|
]);
|
|
61
62
|
if (fileStatus.trim() !== "") {
|
|
62
|
-
throw new
|
|
63
|
+
throw new ShepherdError(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`, EXIT.UNAVAILABLE);
|
|
63
64
|
}
|
|
64
65
|
const parsed = parseSuggestion(thread.body);
|
|
65
66
|
if (!parsed) {
|
|
66
|
-
throw new
|
|
67
|
+
throw new ShepherdError(`Thread ${opts.threadId} has no suggestion block in the comment body.`, EXIT.UNAVAILABLE);
|
|
67
68
|
}
|
|
68
69
|
if (!isCommittableSuggestion(parsed)) {
|
|
69
|
-
throw new
|
|
70
|
-
`3+ backtick fences — refusing to apply (could silently truncate)
|
|
70
|
+
throw new ShepherdError(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
|
|
71
|
+
`3+ backtick fences — refusing to apply (could silently truncate).`, EXIT.UNAVAILABLE);
|
|
71
72
|
}
|
|
72
73
|
const startLine = thread.startLine ?? thread.line;
|
|
73
74
|
const endLine = thread.line;
|
|
@@ -4,6 +4,7 @@ import { getCurrentPrNumber } from "../../github/client.mjs";
|
|
|
4
4
|
import { graphql } from "../../github/http.mjs";
|
|
5
5
|
import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
|
|
6
6
|
import { loadConfig } from "../../config/load.mjs";
|
|
7
|
+
import { EXIT, ShepherdError } from "../../exit-codes.mjs";
|
|
7
8
|
import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, } from "./helpers.mjs";
|
|
8
9
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
9
10
|
import { applyStallGuard } from "./stall.mjs";
|
|
@@ -17,8 +18,9 @@ export async function runIterate(opts) {
|
|
|
17
18
|
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
18
19
|
const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
|
|
19
20
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
20
|
-
if (prNumber === null)
|
|
21
|
-
throw new
|
|
21
|
+
if (prNumber === null) {
|
|
22
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
23
|
+
}
|
|
22
24
|
const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
|
|
23
25
|
const report = await runCheck({
|
|
24
26
|
...opts,
|
|
@@ -28,7 +30,7 @@ export async function runIterate(opts) {
|
|
|
28
30
|
});
|
|
29
31
|
const [repoOwner, repoName] = report.repo.split("/");
|
|
30
32
|
if (!repoOwner || !repoName) {
|
|
31
|
-
throw new
|
|
33
|
+
throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
|
|
32
34
|
}
|
|
33
35
|
const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
34
36
|
if (report.mergeStatus.state !== "OPEN") {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
|
|
3
3
|
import { paginateForward } from "../github/pagination.mjs";
|
|
4
4
|
import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
|
|
5
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
5
6
|
const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
|
|
6
7
|
repository(owner: $owner, name: $repo) {
|
|
7
8
|
pullRequest(number: $pr) {
|
|
@@ -25,8 +26,9 @@ const BULK_CHUNK_SIZE = 10;
|
|
|
25
26
|
export async function runMarkFilesAsViewed(opts) {
|
|
26
27
|
const repo = await getRepoInfo();
|
|
27
28
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
28
|
-
if (!prNumber)
|
|
29
|
-
throw new
|
|
29
|
+
if (!prNumber) {
|
|
30
|
+
throw new ShepherdError("No PR number provided and no current branch PR found", EXIT.UNAVAILABLE);
|
|
31
|
+
}
|
|
30
32
|
const matchPatterns = opts.matchPatterns ?? [];
|
|
31
33
|
const matchRegexes = matchPatterns.map((pattern) => compilePattern(pattern));
|
|
32
34
|
const fetched = await fetchPullRequestFiles(prNumber, repo);
|
|
@@ -61,7 +63,7 @@ async function fetchPullRequestFiles(pr, repo) {
|
|
|
61
63
|
});
|
|
62
64
|
const raw = first.data.repository?.pullRequest;
|
|
63
65
|
if (!raw)
|
|
64
|
-
throw new
|
|
66
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
65
67
|
let files = raw.files.nodes;
|
|
66
68
|
if (raw.files.pageInfo.hasNextPage && raw.files.pageInfo.endCursor) {
|
|
67
69
|
const extra = await paginateForward(async (cursor) => {
|
|
@@ -73,7 +75,7 @@ async function fetchPullRequestFiles(pr, repo) {
|
|
|
73
75
|
});
|
|
74
76
|
const pr2 = res.data.repository?.pullRequest;
|
|
75
77
|
if (!pr2)
|
|
76
|
-
throw new
|
|
78
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
77
79
|
return pr2.files;
|
|
78
80
|
}, raw.files.pageInfo.endCursor);
|
|
79
81
|
files = [...files, ...extra];
|
|
@@ -86,7 +88,7 @@ function compilePattern(pattern) {
|
|
|
86
88
|
}
|
|
87
89
|
catch (e) {
|
|
88
90
|
const msg = e instanceof Error ? e.message : String(e);
|
|
89
|
-
throw new
|
|
91
|
+
throw new ShepherdError(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`, EXIT.USAGE);
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
function selectChangedFiles(changedFiles, opts) {
|
|
@@ -6,11 +6,12 @@ import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "..
|
|
|
6
6
|
import { markReplySeen } from "../state/seen-comments.mjs";
|
|
7
7
|
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
8
8
|
import { addPrShepherdMarker } from "../comments/marker.mjs";
|
|
9
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
9
10
|
export async function runResolveMutate(opts) {
|
|
10
11
|
const repo = await getRepoInfo();
|
|
11
12
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
12
13
|
if (prNumber === null) {
|
|
13
|
-
throw new
|
|
14
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
14
15
|
}
|
|
15
16
|
const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
|
|
16
17
|
const config = loadConfig();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process exit codes for the pr-shepherd CLI.
|
|
3
|
+
*
|
|
4
|
+
* Three bands:
|
|
5
|
+
* 0 done — shepherd finished and the PR is in a good terminal state
|
|
6
|
+
* 10-19 shepherd RAN successfully; the code reports PR state
|
|
7
|
+
* 64-78 shepherd FAILED (BSD `sysexits.h` codes)
|
|
8
|
+
*
|
|
9
|
+
* Caller rule: `$? >= 64` means shepherd itself failed. `0` or `10-19` means it
|
|
10
|
+
* ran to completion and is reporting PR state. See docs/exit-codes.md.
|
|
11
|
+
*/
|
|
12
|
+
export const EXIT = Object.freeze({
|
|
13
|
+
/** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
|
|
14
|
+
OK: 0,
|
|
15
|
+
/** Nothing to do yet; CI still in progress. */
|
|
16
|
+
WAIT: 10,
|
|
17
|
+
/** Draft PR converted to ready for review. */
|
|
18
|
+
MARK_READY: 11,
|
|
19
|
+
/** Agent work required. */
|
|
20
|
+
FIX_CODE: 12,
|
|
21
|
+
/** Human attention required. */
|
|
22
|
+
ESCALATE: 13,
|
|
23
|
+
/** `cancel` + `closed` — PR closed without merging. */
|
|
24
|
+
CLOSED: 14,
|
|
25
|
+
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
26
|
+
USAGE: 64,
|
|
27
|
+
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
|
28
|
+
DATAERR: 65,
|
|
29
|
+
/** Input file/stdin could not be read. */
|
|
30
|
+
NOINPUT: 66,
|
|
31
|
+
/** Precondition unmet: no open PR for branch, thread not eligible, unclassified 4xx. */
|
|
32
|
+
UNAVAILABLE: 69,
|
|
33
|
+
/** Unexpected/unclassified internal error — the fallback. */
|
|
34
|
+
SOFTWARE: 70,
|
|
35
|
+
/** Retryable GitHub failure: 429, 5xx, rate limit exhausted, `Retry-After` present. */
|
|
36
|
+
TEMPFAIL: 75,
|
|
37
|
+
/** GitHub 401/403 — missing token or insufficient PAT scopes. */
|
|
38
|
+
NOPERM: 77,
|
|
39
|
+
/** `.pr-shepherdrc.yml` validation failure. */
|
|
40
|
+
CONFIG: 78,
|
|
41
|
+
});
|
|
42
|
+
/** An error that carries its own exit code, so the top-level handler doesn't have to guess. */
|
|
43
|
+
export class ShepherdError extends Error {
|
|
44
|
+
exitCode;
|
|
45
|
+
constructor(message, exitCode, opts) {
|
|
46
|
+
super(message, opts);
|
|
47
|
+
this.name = "ShepherdError";
|
|
48
|
+
this.exitCode = exitCode;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const CANCEL_REASON_EXIT_CODE = {
|
|
52
|
+
merged: EXIT.OK,
|
|
53
|
+
"ready-delay-elapsed": EXIT.OK,
|
|
54
|
+
closed: EXIT.CLOSED,
|
|
55
|
+
};
|
|
56
|
+
export function iterateResultToExitCode(result) {
|
|
57
|
+
switch (result.action) {
|
|
58
|
+
case "cancel":
|
|
59
|
+
return CANCEL_REASON_EXIT_CODE[result.reason];
|
|
60
|
+
case "wait":
|
|
61
|
+
return EXIT.WAIT;
|
|
62
|
+
case "mark_ready":
|
|
63
|
+
return EXIT.MARK_READY;
|
|
64
|
+
case "fix_code":
|
|
65
|
+
return EXIT.FIX_CODE;
|
|
66
|
+
case "escalate":
|
|
67
|
+
return EXIT.ESCALATE;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function errorToExitCode(err) {
|
|
71
|
+
if (err instanceof ShepherdError)
|
|
72
|
+
return err.exitCode;
|
|
73
|
+
return EXIT.SOFTWARE;
|
|
74
|
+
}
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
1
2
|
import { GitHubRequestError } from "./errors.mjs";
|
|
2
3
|
export function requireRawPr(response, pr, repo) {
|
|
3
4
|
if (!response?.repository) {
|
|
4
5
|
throw new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
|
|
5
6
|
}
|
|
6
|
-
if (!response.repository.pullRequest)
|
|
7
|
-
throw new
|
|
7
|
+
if (!response.repository.pullRequest) {
|
|
8
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
9
|
+
}
|
|
8
10
|
return response.repository.pullRequest;
|
|
9
11
|
}
|
|
10
12
|
export function requireContextNodes(nodes) {
|
|
11
13
|
const nullIndex = nodes.findIndex((node) => node === null);
|
|
12
14
|
if (nullIndex !== -1) {
|
|
13
|
-
|
|
15
|
+
// A null context node is an unexpected/malformed shape, not a precondition or
|
|
16
|
+
// permission problem — force EX_SOFTWARE rather than falling through to the
|
|
17
|
+
// (200-status-derived) EX_UNAVAILABLE default.
|
|
18
|
+
throw new GitHubRequestError(`Malformed GitHub GraphQL response: null check context at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts.nodes.${nullIndex}`, { status: 200, exitCodeOverride: EXIT.SOFTWARE });
|
|
14
19
|
}
|
|
15
20
|
return nodes;
|
|
16
21
|
}
|
package/bin/github/errors.mjs
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
|
+
// GitHub's GraphQL API reports field-level permission failures (e.g. a fine-grained
|
|
3
|
+
// PAT missing a scope) as an `errors[].message` entry at HTTP 200, not as an HTTP
|
|
4
|
+
// 401/403 — the transport-level request succeeded even though one field could not
|
|
5
|
+
// be resolved. Status alone can't see this, so classification must also inspect the
|
|
6
|
+
// GraphQL error messages themselves.
|
|
7
|
+
const GRAPHQL_PERMISSION_ERROR = /resource not accessible/i;
|
|
8
|
+
function hasPermissionError(graphqlErrors) {
|
|
9
|
+
return graphqlErrors?.some((e) => GRAPHQL_PERMISSION_ERROR.test(e.message)) ?? false;
|
|
10
|
+
}
|
|
11
|
+
function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
|
|
12
|
+
// Retry signals take priority over everything else: GitHub's secondary rate limit
|
|
13
|
+
// returns 403 with a Retry-After header, which is a transient throttle — not the
|
|
14
|
+
// permission-denied 403 a bad/missing token produces. Treat any retry signal as
|
|
15
|
+
// TEMPFAIL first so it isn't shadowed by the checks below.
|
|
16
|
+
const rateLimitExhausted = rateLimit !== undefined && rateLimit.remaining <= 0;
|
|
17
|
+
if (status === 429 || status >= 500 || retryAfterSeconds !== undefined || rateLimitExhausted) {
|
|
18
|
+
return EXIT.TEMPFAIL;
|
|
19
|
+
}
|
|
20
|
+
if (status === 401 || status === 403 || hasPermissionError(graphqlErrors))
|
|
21
|
+
return EXIT.NOPERM;
|
|
22
|
+
return EXIT.UNAVAILABLE;
|
|
23
|
+
}
|
|
24
|
+
export class GitHubRequestError extends ShepherdError {
|
|
2
25
|
status;
|
|
3
26
|
rateLimit;
|
|
4
27
|
retryAfterSeconds;
|
|
5
28
|
graphqlErrors;
|
|
6
29
|
constructor(message, opts) {
|
|
7
|
-
super(message
|
|
30
|
+
super(message, opts.exitCodeOverride ??
|
|
31
|
+
classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
|
|
8
32
|
this.name = "GitHubRequestError";
|
|
9
33
|
this.status = opts.status;
|
|
10
34
|
this.rateLimit = opts.rateLimit;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EXIT } from "../exit-codes.mjs";
|
|
1
2
|
import { GitHubRequestError } from "./errors.mjs";
|
|
2
3
|
export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds) {
|
|
3
4
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
@@ -34,9 +35,13 @@ export function formatGraphQlErrors(errors) {
|
|
|
34
35
|
.join("; ");
|
|
35
36
|
}
|
|
36
37
|
function malformedGraphQlResponse(detail, status, rateLimit, retryAfterSeconds) {
|
|
38
|
+
// A response that fails to parse as valid GraphQL shape is an internal/unexpected
|
|
39
|
+
// failure, not a precondition or permission problem — force EX_SOFTWARE rather
|
|
40
|
+
// than letting the (likely 200) status fall through to EX_UNAVAILABLE.
|
|
37
41
|
return new GitHubRequestError(`Malformed GitHub GraphQL response: ${detail}`, {
|
|
38
42
|
status,
|
|
39
43
|
rateLimit: rateLimit ?? undefined,
|
|
40
44
|
retryAfterSeconds,
|
|
45
|
+
exitCodeOverride: EXIT.SOFTWARE,
|
|
41
46
|
});
|
|
42
47
|
}
|
package/bin/github/http-auth.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile as execFileCb } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
3
4
|
const execFile = promisify(execFileCb);
|
|
4
5
|
let _token;
|
|
5
6
|
export function _resetTokenCache() {
|
|
@@ -35,7 +36,7 @@ async function resolveToken() {
|
|
|
35
36
|
_token = codexToken;
|
|
36
37
|
return _token;
|
|
37
38
|
}
|
|
38
|
-
throw new
|
|
39
|
+
throw new ShepherdError("No GitHub token found. Set GH_TOKEN, GITHUB_TOKEN, or GITHUB_PERSONAL_ACCESS_TOKEN, or run `gh auth login`.", EXIT.NOPERM);
|
|
39
40
|
}
|
|
40
41
|
export async function makeHeaders() {
|
|
41
42
|
return {
|
package/bin/github/rest-http.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { appendEntry, nextEntry } from "../log/log-file.mjs";
|
|
2
2
|
import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
3
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
3
4
|
import { makeHeaders } from "./http-auth.mjs";
|
|
4
5
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
5
|
-
import { redactToken, redactUrl, sanitizeBody } from "./http-utils.mjs";
|
|
6
|
+
import { parseRateLimit, parseRetryAfter, redactToken, redactUrl, sanitizeBody, } from "./http-utils.mjs";
|
|
6
7
|
const BASE_URL = "https://api.github.com";
|
|
7
8
|
export async function rest(method, path, body) {
|
|
8
9
|
const url = `${BASE_URL}${path}`;
|
|
@@ -28,7 +29,11 @@ export async function rest(method, path, body) {
|
|
|
28
29
|
textBody: redactToken(text),
|
|
29
30
|
attempt: attempt > 1 ? attempt : undefined,
|
|
30
31
|
}));
|
|
31
|
-
throw new
|
|
32
|
+
throw new GitHubRequestError(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
|
|
33
|
+
status: res.status,
|
|
34
|
+
rateLimit: parseRateLimit(res.headers) ?? undefined,
|
|
35
|
+
retryAfterSeconds: parseRetryAfter(res.headers),
|
|
36
|
+
});
|
|
32
37
|
}
|
|
33
38
|
if (ct.includes("application/json")) {
|
|
34
39
|
const json = (await res.json());
|
|
@@ -80,7 +85,11 @@ export async function restText(path) {
|
|
|
80
85
|
durationMs,
|
|
81
86
|
attempt: attempt > 1 ? attempt : undefined,
|
|
82
87
|
}));
|
|
83
|
-
throw new
|
|
88
|
+
throw new GitHubRequestError(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
|
|
89
|
+
status: res.status,
|
|
90
|
+
rateLimit: parseRateLimit(res.headers) ?? undefined,
|
|
91
|
+
retryAfterSeconds: parseRetryAfter(res.headers),
|
|
92
|
+
});
|
|
84
93
|
}
|
|
85
94
|
appendEntry(formatResponseEntry({
|
|
86
95
|
n,
|
|
@@ -121,8 +130,13 @@ async function followRestTextRedirect(res, entry) {
|
|
|
121
130
|
durationMs: Math.round(performance.now() - t1),
|
|
122
131
|
contentLength: parseContentLength(redirectRes.headers),
|
|
123
132
|
}));
|
|
124
|
-
if (!redirectRes.ok)
|
|
125
|
-
throw new
|
|
133
|
+
if (!redirectRes.ok) {
|
|
134
|
+
throw new GitHubRequestError(`redirect target ${location} failed: ${redirectRes.status}`, {
|
|
135
|
+
status: redirectRes.status,
|
|
136
|
+
rateLimit: parseRateLimit(redirectRes.headers) ?? undefined,
|
|
137
|
+
retryAfterSeconds: parseRetryAfter(redirectRes.headers),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
126
140
|
return redirectRes.text();
|
|
127
141
|
}
|
|
128
142
|
function parseContentLength(headers) {
|
package/bin/index.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* pr-shepherd iterate [PR]
|
|
9
9
|
*/
|
|
10
10
|
import { main } from "./cli-parser.mjs";
|
|
11
|
+
import { errorToExitCode } from "./exit-codes.mjs";
|
|
11
12
|
function formatCause(cause, seen = new Set(), depth = 0) {
|
|
12
13
|
if (depth > 5 || seen.has(cause))
|
|
13
14
|
return "[circular or deep cause chain]";
|
|
@@ -23,5 +24,5 @@ main(process.argv).catch((err) => {
|
|
|
23
24
|
const msg = err instanceof Error ? err.message : String(err);
|
|
24
25
|
const causeStr = err instanceof Error && err.cause != null ? formatCause(err.cause) : null;
|
|
25
26
|
process.stderr.write(`pr-shepherd error: ${msg}${causeStr !== null ? ` (cause: ${causeStr})` : ""}\n`);
|
|
26
|
-
process.exit(
|
|
27
|
+
process.exit(errorToExitCode(err));
|
|
27
28
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"@vitest/coverage-v8": "^4.1.4",
|
|
39
39
|
"husky": "^9.1.7",
|
|
40
40
|
"knip": "^6.14.1",
|
|
41
|
-
"oxfmt": "^0.
|
|
41
|
+
"oxfmt": "^0.60.0",
|
|
42
42
|
"oxlint": "^1.60.0",
|
|
43
|
-
"typescript": "^
|
|
43
|
+
"typescript": "^7.0.2",
|
|
44
44
|
"vitest": "^4.1.4"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
@@ -20,7 +20,7 @@ Poll dispatcher for iterating a PR to completion.
|
|
|
20
20
|
|
|
21
21
|
3. **Loop:** Run the poll, print its full output, and follow its `## Instructions` section exactly. Then run the poll again. Repeat until the CLI emits `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. `[FIX_CODE]` is non-terminal: do its instructions, then poll again. The poll already waits between ticks via `--interval`; do not add manual `sleep`s between ticks.
|
|
22
22
|
|
|
23
|
-
4. **
|
|
23
|
+
4. **Exit codes:** an exit code of `64` or higher means the `pr-shepherd` command itself failed (bad flag, GitHub auth/permission error, transient failure, etc.) — surface the error and stop instead of looping. Any other exit code (`0` or `10`–`19`) means the command ran and the output above is real PR state — proceed to step 5.
|
|
24
24
|
|
|
25
25
|
5. **Terminal states (stop):**
|
|
26
26
|
- `[CANCEL]` — ready-delay completed, or PR merged/closed.
|