pr-shepherd 0.44.0 → 0.44.1
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/bin/exit-codes.d.mts +1 -1
- package/bin/exit-codes.mjs +1 -1
- package/bin/github/errors.d.mts +6 -0
- package/bin/github/errors.mjs +27 -1
- package/bin/github/graphql-http.mjs +3 -2
- package/bin/github/graphql-internal-retry.d.mts +2 -0
- package/bin/github/graphql-internal-retry.mjs +50 -0
- package/bin/github/graphql-response.mjs +11 -1
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
package/bin/exit-codes.d.mts
CHANGED
|
@@ -35,7 +35,7 @@ export declare const EXIT: Readonly<{
|
|
|
35
35
|
readonly UNAVAILABLE: 69;
|
|
36
36
|
/** Unexpected/unclassified internal error — the fallback. */
|
|
37
37
|
readonly SOFTWARE: 70;
|
|
38
|
-
/** Retryable GitHub failure: 429, 5xx, rate limit exhausted,
|
|
38
|
+
/** Retryable GitHub failure: 429, 5xx, rate limit exhausted, Retry-After, GraphQL INTERNAL. */
|
|
39
39
|
readonly TEMPFAIL: 75;
|
|
40
40
|
/** GitHub 401/403 — missing token or insufficient PAT scopes. */
|
|
41
41
|
readonly NOPERM: 77;
|
package/bin/exit-codes.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export const EXIT = Object.freeze({
|
|
|
34
34
|
UNAVAILABLE: 69,
|
|
35
35
|
/** Unexpected/unclassified internal error — the fallback. */
|
|
36
36
|
SOFTWARE: 70,
|
|
37
|
-
/** Retryable GitHub failure: 429, 5xx, rate limit exhausted,
|
|
37
|
+
/** Retryable GitHub failure: 429, 5xx, rate limit exhausted, Retry-After, GraphQL INTERNAL. */
|
|
38
38
|
TEMPFAIL: 75,
|
|
39
39
|
/** GitHub 401/403 — missing token or insufficient PAT scopes. */
|
|
40
40
|
NOPERM: 77,
|
package/bin/github/errors.d.mts
CHANGED
|
@@ -3,7 +3,13 @@ import type { RateLimitInfo } from "./http.mts";
|
|
|
3
3
|
export interface GitHubGraphQlError {
|
|
4
4
|
message: string;
|
|
5
5
|
path?: unknown;
|
|
6
|
+
/** GitHub's GraphQL `type` field — `INTERNAL` on engine crashes. */
|
|
7
|
+
type?: string;
|
|
8
|
+
/** GraphQL `extensions`; GitHub often sets `{ code: "INTERNAL" }`. */
|
|
9
|
+
extensions?: unknown;
|
|
6
10
|
}
|
|
11
|
+
/** GitHub GraphQL engine crash: HTTP 200, `data: null`, INTERNAL type/code or message. */
|
|
12
|
+
export declare function isRetryableGraphQlInternal(graphqlErrors?: GitHubGraphQlError[]): boolean;
|
|
7
13
|
export declare class GitHubRequestError extends ShepherdError {
|
|
8
14
|
readonly status: number;
|
|
9
15
|
readonly rateLimit?: RateLimitInfo;
|
package/bin/github/errors.mjs
CHANGED
|
@@ -5,16 +5,42 @@ import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
|
5
5
|
// be resolved. Status alone can't see this, so classification must also inspect the
|
|
6
6
|
// GraphQL error messages themselves.
|
|
7
7
|
const GRAPHQL_PERMISSION_ERROR = /resource not accessible/i;
|
|
8
|
+
const GRAPHQL_INTERNAL_MESSAGE = /something went wrong while executing your query/i;
|
|
9
|
+
function isInternalToken(value) {
|
|
10
|
+
return typeof value === "string" && value.toUpperCase() === "INTERNAL";
|
|
11
|
+
}
|
|
12
|
+
function extensionsCode(extensions) {
|
|
13
|
+
if (typeof extensions !== "object" || extensions === null || Array.isArray(extensions)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return extensions["code"];
|
|
17
|
+
}
|
|
8
18
|
function hasPermissionError(graphqlErrors) {
|
|
9
19
|
return graphqlErrors?.some((e) => GRAPHQL_PERMISSION_ERROR.test(e.message)) ?? false;
|
|
10
20
|
}
|
|
21
|
+
/** GitHub GraphQL engine crash: HTTP 200, `data: null`, INTERNAL type/code or message. */
|
|
22
|
+
export function isRetryableGraphQlInternal(graphqlErrors) {
|
|
23
|
+
if (!graphqlErrors?.length)
|
|
24
|
+
return false;
|
|
25
|
+
return graphqlErrors.some((error) => {
|
|
26
|
+
if (isInternalToken(error.type))
|
|
27
|
+
return true;
|
|
28
|
+
if (isInternalToken(extensionsCode(error.extensions)))
|
|
29
|
+
return true;
|
|
30
|
+
return GRAPHQL_INTERNAL_MESSAGE.test(error.message);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
11
33
|
function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
|
|
12
34
|
// Retry signals take priority over everything else: GitHub's secondary rate limit
|
|
13
35
|
// returns 403 with a Retry-After header, which is a transient throttle — not the
|
|
14
36
|
// permission-denied 403 a bad/missing token produces. Treat any retry signal as
|
|
15
37
|
// TEMPFAIL first so it isn't shadowed by the checks below.
|
|
16
38
|
const rateLimitExhausted = rateLimit !== undefined && rateLimit.remaining <= 0;
|
|
17
|
-
if (status === 429 ||
|
|
39
|
+
if (status === 429 ||
|
|
40
|
+
status >= 500 ||
|
|
41
|
+
retryAfterSeconds !== undefined ||
|
|
42
|
+
rateLimitExhausted ||
|
|
43
|
+
isRetryableGraphQlInternal(graphqlErrors)) {
|
|
18
44
|
return EXIT.TEMPFAIL;
|
|
19
45
|
}
|
|
20
46
|
if (status === 401 || status === 403 || hasPermissionError(graphqlErrors))
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { withGraphQlInternalRetry } from "./graphql-internal-retry.mjs";
|
|
4
5
|
import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
|
|
5
6
|
import { makeHeaders } from "./http-auth.mjs";
|
|
6
7
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
@@ -96,10 +97,10 @@ async function graphqlInner(query, vars, opts) {
|
|
|
96
97
|
return { data: payload.data, rateLimit, retryAfterSeconds, errors: payload.errors };
|
|
97
98
|
}
|
|
98
99
|
export async function graphql(query, vars = {}, opts = {}) {
|
|
99
|
-
const { data, errors } = await graphqlInner(query, vars, opts);
|
|
100
|
+
const { data, errors } = await withGraphQlInternalRetry(query, () => graphqlInner(query, vars, opts));
|
|
100
101
|
return { data, errors };
|
|
101
102
|
}
|
|
102
103
|
export async function graphqlWithRateLimit(query, vars = {}, opts = {}) {
|
|
103
|
-
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars, opts);
|
|
104
|
+
const { data, rateLimit, retryAfterSeconds, errors } = await withGraphQlInternalRetry(query, () => graphqlInner(query, vars, opts));
|
|
104
105
|
return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
|
|
105
106
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { sleep } from "../util/sleep.mjs";
|
|
2
|
+
import { GitHubRequestError, isRetryableGraphQlInternal } from "./errors.mjs";
|
|
3
|
+
const GRAPHQL_INTERNAL_RETRY_DELAYS = [500, 1500];
|
|
4
|
+
/** True when the root operation is a mutation. Anonymous `{...}` is a query. */
|
|
5
|
+
function isGraphQlMutationDocument(document) {
|
|
6
|
+
let rest = document;
|
|
7
|
+
for (;;) {
|
|
8
|
+
rest = rest.replace(/^\s+/, "");
|
|
9
|
+
if (rest.startsWith("#")) {
|
|
10
|
+
const nl = rest.indexOf("\n");
|
|
11
|
+
rest = nl === -1 ? "" : rest.slice(nl + 1);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (rest.startsWith('"""')) {
|
|
15
|
+
const end = rest.indexOf('"""', 3);
|
|
16
|
+
rest = end === -1 ? "" : rest.slice(end + 3);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
return /^mutation\b/i.test(rest);
|
|
22
|
+
}
|
|
23
|
+
function hasServerBackoff(err) {
|
|
24
|
+
if (err.retryAfterSeconds !== undefined)
|
|
25
|
+
return true;
|
|
26
|
+
return err.rateLimit !== undefined && err.rateLimit.remaining <= 0;
|
|
27
|
+
}
|
|
28
|
+
/** Retry GitHub GraphQL engine crashes (HTTP 200, data: null, INTERNAL) on reads. */
|
|
29
|
+
export async function withGraphQlInternalRetry(document, run) {
|
|
30
|
+
let lastErr;
|
|
31
|
+
for (let attempt = 1; attempt <= GRAPHQL_INTERNAL_RETRY_DELAYS.length + 1; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
return await run();
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (!(err instanceof GitHubRequestError) ||
|
|
37
|
+
!isRetryableGraphQlInternal(err.graphqlErrors) ||
|
|
38
|
+
isGraphQlMutationDocument(document) ||
|
|
39
|
+
hasServerBackoff(err)) {
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
lastErr = err;
|
|
43
|
+
const delay = GRAPHQL_INTERNAL_RETRY_DELAYS[attempt - 1];
|
|
44
|
+
if (delay === undefined)
|
|
45
|
+
break;
|
|
46
|
+
await sleep(delay);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw lastErr;
|
|
50
|
+
}
|
|
@@ -13,7 +13,7 @@ export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds
|
|
|
13
13
|
typeof error["message"] === "string")) {
|
|
14
14
|
throw malformedGraphQlResponse("errors field is not an array of GraphQL errors", status, rateLimit, retryAfterSeconds);
|
|
15
15
|
}
|
|
16
|
-
errors = record["errors"];
|
|
16
|
+
errors = record["errors"].map((error) => parseGraphQlError(error));
|
|
17
17
|
}
|
|
18
18
|
if (!("data" in record)) {
|
|
19
19
|
if (errors?.length)
|
|
@@ -26,6 +26,16 @@ export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds
|
|
|
26
26
|
}
|
|
27
27
|
return { data: record["data"] ?? null, errors };
|
|
28
28
|
}
|
|
29
|
+
function parseGraphQlError(error) {
|
|
30
|
+
const parsed = { message: error["message"] };
|
|
31
|
+
if ("path" in error)
|
|
32
|
+
parsed.path = error["path"];
|
|
33
|
+
if (typeof error["type"] === "string")
|
|
34
|
+
parsed.type = error["type"];
|
|
35
|
+
if (error["extensions"] !== undefined)
|
|
36
|
+
parsed.extensions = error["extensions"];
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
29
39
|
export function formatGraphQlErrors(errors) {
|
|
30
40
|
return (errors ?? [])
|
|
31
41
|
.map((error) => {
|
package/package.json
CHANGED