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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.44.0",
4
+ "version": "0.44.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -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, `Retry-After` present. */
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;
@@ -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, `Retry-After` present. */
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,
@@ -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;
@@ -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 || status >= 500 || retryAfterSeconds !== undefined || rateLimitExhausted) {
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,2 @@
1
+ /** Retry GitHub GraphQL engine crashes (HTTP 200, data: null, INTERNAL) on reads. */
2
+ export declare function withGraphQlInternalRetry<T>(document: string, run: () => Promise<T>): Promise<T>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.44.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.44.1", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.44.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.44.1", "pr-shepherd-mcp"]
5
5
  }
6
6
  }