pr-shepherd 0.32.5 → 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 +9 -4
- 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 +17 -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 +40 -7
- 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/check-instructions.mjs +39 -5
- package/bin/commands/iterate/fix-code.mjs +4 -1
- package/bin/commands/iterate/index.mjs +5 -3
- package/bin/commands/iterate/render.mjs +9 -23
- package/bin/commands/mark-files-as-viewed.mjs +8 -8
- package/bin/commands/resolve-mutate.mjs +2 -1
- package/bin/comments/resolve.mjs +3 -1
- package/bin/config.json +2 -1
- package/bin/exit-codes.mjs +74 -0
- package/bin/github/batch-response.mjs +21 -0
- package/bin/github/batch.mjs +12 -22
- package/bin/github/errors.mjs +28 -2
- package/bin/github/graphql-http.mjs +45 -13
- package/bin/github/graphql-response.mjs +47 -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
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { appendEntry, nextEntry } from "../log/log-file.mjs";
|
|
2
2
|
import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
3
3
|
import { GitHubRequestError } from "./errors.mjs";
|
|
4
|
+
import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
|
|
4
5
|
import { makeHeaders } from "./http-auth.mjs";
|
|
5
6
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
6
7
|
import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
|
|
7
8
|
const BASE_URL = "https://api.github.com";
|
|
8
|
-
async function graphqlInner(query, vars) {
|
|
9
|
+
async function graphqlInner(query, vars, opts) {
|
|
9
10
|
const url = `${BASE_URL}/graphql`;
|
|
10
11
|
const n = nextEntry();
|
|
11
12
|
appendEntry(formatRequestEntry({
|
|
@@ -38,7 +39,28 @@ async function graphqlInner(query, vars) {
|
|
|
38
39
|
}));
|
|
39
40
|
throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = await res.json();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const detail = err instanceof Error ? `: ${err.message}` : "";
|
|
48
|
+
appendEntry(formatResponseEntry({
|
|
49
|
+
n,
|
|
50
|
+
kind: "GraphQL",
|
|
51
|
+
method: "POST",
|
|
52
|
+
url,
|
|
53
|
+
status: res.status,
|
|
54
|
+
durationMs,
|
|
55
|
+
textBody: `Invalid JSON response${detail}`,
|
|
56
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
57
|
+
}));
|
|
58
|
+
throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
|
|
59
|
+
status: res.status,
|
|
60
|
+
rateLimit: rateLimit ?? undefined,
|
|
61
|
+
retryAfterSeconds,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
42
64
|
appendEntry(formatResponseEntry({
|
|
43
65
|
n,
|
|
44
66
|
kind: "GraphQL",
|
|
@@ -49,25 +71,35 @@ async function graphqlInner(query, vars) {
|
|
|
49
71
|
body: parsed,
|
|
50
72
|
attempt: attempt > 1 ? attempt : undefined,
|
|
51
73
|
}));
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
|
|
75
|
+
if (payload.data == null) {
|
|
76
|
+
const detail = formatGraphQlErrors(payload.errors);
|
|
77
|
+
throw new GitHubRequestError(`GitHub GraphQL error (no data)${detail ? `: ${detail}` : ""}`, {
|
|
78
|
+
status: res.status,
|
|
79
|
+
rateLimit: rateLimit ?? undefined,
|
|
80
|
+
retryAfterSeconds,
|
|
81
|
+
graphqlErrors: payload.errors,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (payload.errors?.length && !opts.allowPartialData) {
|
|
85
|
+
throw new GitHubRequestError(`GitHub GraphQL error: ${formatGraphQlErrors(payload.errors)}`, {
|
|
55
86
|
status: res.status,
|
|
56
87
|
rateLimit: rateLimit ?? undefined,
|
|
57
88
|
retryAfterSeconds,
|
|
89
|
+
graphqlErrors: payload.errors,
|
|
58
90
|
});
|
|
59
91
|
}
|
|
60
|
-
if (
|
|
61
|
-
const messages =
|
|
92
|
+
if (payload.errors?.length) {
|
|
93
|
+
const messages = payload.errors.map((e) => e.message).join("; ");
|
|
62
94
|
process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
|
|
63
95
|
}
|
|
64
|
-
return { data:
|
|
96
|
+
return { data: payload.data, rateLimit, retryAfterSeconds, errors: payload.errors };
|
|
65
97
|
}
|
|
66
|
-
export async function graphql(query, vars = {}) {
|
|
67
|
-
const { data } = await graphqlInner(query, vars);
|
|
68
|
-
return { data };
|
|
98
|
+
export async function graphql(query, vars = {}, opts = {}) {
|
|
99
|
+
const { data, errors } = await graphqlInner(query, vars, opts);
|
|
100
|
+
return { data, errors };
|
|
69
101
|
}
|
|
70
|
-
export async function graphqlWithRateLimit(query, vars = {}) {
|
|
71
|
-
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
|
|
102
|
+
export async function graphqlWithRateLimit(query, vars = {}, opts = {}) {
|
|
103
|
+
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars, opts);
|
|
72
104
|
return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
|
|
73
105
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { EXIT } from "../exit-codes.mjs";
|
|
2
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
3
|
+
export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds) {
|
|
4
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
5
|
+
throw malformedGraphQlResponse("expected a JSON object", status, rateLimit, retryAfterSeconds);
|
|
6
|
+
}
|
|
7
|
+
const record = parsed;
|
|
8
|
+
let errors;
|
|
9
|
+
if (record["errors"] !== undefined) {
|
|
10
|
+
if (!Array.isArray(record["errors"]) ||
|
|
11
|
+
!record["errors"].every((error) => typeof error === "object" &&
|
|
12
|
+
error !== null &&
|
|
13
|
+
typeof error["message"] === "string")) {
|
|
14
|
+
throw malformedGraphQlResponse("errors field is not an array of GraphQL errors", status, rateLimit, retryAfterSeconds);
|
|
15
|
+
}
|
|
16
|
+
errors = record["errors"];
|
|
17
|
+
}
|
|
18
|
+
if (!("data" in record)) {
|
|
19
|
+
if (errors?.length)
|
|
20
|
+
return { data: null, errors };
|
|
21
|
+
throw malformedGraphQlResponse("missing data field", status, rateLimit, retryAfterSeconds);
|
|
22
|
+
}
|
|
23
|
+
if (record["data"] !== null &&
|
|
24
|
+
(typeof record["data"] !== "object" || Array.isArray(record["data"]))) {
|
|
25
|
+
throw malformedGraphQlResponse("data field is not an object or null", status, rateLimit, retryAfterSeconds);
|
|
26
|
+
}
|
|
27
|
+
return { data: record["data"] ?? null, errors };
|
|
28
|
+
}
|
|
29
|
+
export function formatGraphQlErrors(errors) {
|
|
30
|
+
return (errors ?? [])
|
|
31
|
+
.map((error) => {
|
|
32
|
+
const path = Array.isArray(error.path) ? error.path.map(String).join(".") : "";
|
|
33
|
+
return path ? `${error.message} (path: ${path})` : error.message;
|
|
34
|
+
})
|
|
35
|
+
.join("; ");
|
|
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.
|
|
41
|
+
return new GitHubRequestError(`Malformed GitHub GraphQL response: ${detail}`, {
|
|
42
|
+
status,
|
|
43
|
+
rateLimit: rateLimit ?? undefined,
|
|
44
|
+
retryAfterSeconds,
|
|
45
|
+
exitCodeOverride: EXIT.SOFTWARE,
|
|
46
|
+
});
|
|
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.
|