pr-shepherd 0.44.0 → 0.45.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.
Files changed (100) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -4
  3. package/bin/checks/conclusions.d.mts +24 -1
  4. package/bin/checks/conclusions.mjs +39 -0
  5. package/bin/checks/triage.d.mts +3 -2
  6. package/bin/checks/triage.mjs +44 -15
  7. package/bin/cli/api-usage-formatter.d.mts +3 -0
  8. package/bin/cli/api-usage-formatter.mjs +36 -0
  9. package/bin/cli/error-format.d.mts +13 -0
  10. package/bin/cli/error-format.mjs +41 -0
  11. package/bin/cli/fix-formatter.mjs +12 -1
  12. package/bin/cli/iterate-activity-formatter.d.mts +2 -0
  13. package/bin/cli/iterate-activity-formatter.mjs +26 -0
  14. package/bin/cli/iterate-formatter.mjs +11 -28
  15. package/bin/cli/iterate-instructions.mjs +14 -1
  16. package/bin/cli/iterate-lean.mjs +1 -0
  17. package/bin/cli/mutate-formatter.mjs +2 -0
  18. package/bin/commands/check-annotations.d.mts +2 -1
  19. package/bin/commands/check-annotations.mjs +7 -7
  20. package/bin/commands/check.mjs +6 -4
  21. package/bin/commands/iterate/api-usage.d.mts +2 -0
  22. package/bin/commands/iterate/api-usage.mjs +42 -0
  23. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  24. package/bin/commands/iterate/check-instructions.mjs +27 -8
  25. package/bin/commands/iterate/fix-code.mjs +42 -8
  26. package/bin/commands/iterate/index.mjs +6 -3
  27. package/bin/commands/iterate/merge-state.mjs +5 -2
  28. package/bin/commands/iterate/render.d.mts +1 -1
  29. package/bin/commands/iterate/render.mjs +9 -5
  30. package/bin/commands/iterate/run.d.mts +2 -0
  31. package/bin/commands/iterate/run.mjs +8 -0
  32. package/bin/commands/mark-files-as-viewed.mjs +8 -0
  33. package/bin/commands/poll-run.d.mts +2 -0
  34. package/bin/commands/poll-run.mjs +8 -0
  35. package/bin/commands/poll.d.mts +1 -2
  36. package/bin/commands/poll.mjs +29 -10
  37. package/bin/comments/rate-limit.d.mts +4 -0
  38. package/bin/comments/rate-limit.mjs +6 -0
  39. package/bin/config/load.d.mts +6 -1
  40. package/bin/config/load.mjs +33 -1
  41. package/bin/config.json +6 -1
  42. package/bin/exit-codes.d.mts +1 -1
  43. package/bin/exit-codes.mjs +1 -1
  44. package/bin/github/api-telemetry-aggregate.d.mts +32 -0
  45. package/bin/github/api-telemetry-aggregate.mjs +84 -0
  46. package/bin/github/api-telemetry.d.mts +13 -0
  47. package/bin/github/api-telemetry.mjs +128 -0
  48. package/bin/github/check-annotations.d.mts +14 -1
  49. package/bin/github/check-annotations.mjs +29 -2
  50. package/bin/github/errors.d.mts +8 -0
  51. package/bin/github/errors.mjs +29 -1
  52. package/bin/github/gql/batch-pr-page.gql +8 -0
  53. package/bin/github/gql/batch-pr.gql +8 -0
  54. package/bin/github/gql/check-run-annotations.gql +8 -0
  55. package/bin/github/gql/commit-check-contexts.gql +8 -0
  56. package/bin/github/gql/get-pr-body.gql +8 -0
  57. package/bin/github/gql/get-pr-head-sha.gql +8 -0
  58. package/bin/github/gql/pr-number-by-branch.gql +8 -0
  59. package/bin/github/gql/review-thread-comments.gql +8 -0
  60. package/bin/github/gql/suggestion-threads.gql +8 -0
  61. package/bin/github/graphql-http.mjs +66 -10
  62. package/bin/github/graphql-internal-retry.d.mts +2 -0
  63. package/bin/github/graphql-internal-retry.mjs +50 -0
  64. package/bin/github/graphql-response.mjs +11 -1
  65. package/bin/github/http-auth.d.mts +9 -1
  66. package/bin/github/http-auth.mjs +35 -14
  67. package/bin/github/http-intermediate.d.mts +9 -0
  68. package/bin/github/http-intermediate.mjs +20 -0
  69. package/bin/github/http-request.d.mts +1 -1
  70. package/bin/github/http-request.mjs +1 -1
  71. package/bin/github/http-utils.d.mts +6 -0
  72. package/bin/github/http-utils.mjs +10 -1
  73. package/bin/github/rest-http.d.mts +16 -1
  74. package/bin/github/rest-http.mjs +71 -8
  75. package/bin/github/rest-text.mjs +41 -5
  76. package/bin/index.mjs +2 -1
  77. package/bin/log/session.d.mts +11 -0
  78. package/bin/log/session.mjs +22 -0
  79. package/bin/mcp/server.mjs +16 -2
  80. package/bin/quota-warning.d.mts +2 -0
  81. package/bin/quota-warning.mjs +6 -0
  82. package/bin/state/graphql-quota-claims.d.mts +2 -0
  83. package/bin/state/graphql-quota-claims.mjs +61 -0
  84. package/bin/state/graphql-quota-policy.d.mts +17 -0
  85. package/bin/state/graphql-quota-policy.mjs +43 -0
  86. package/bin/state/graphql-quota-warnings.d.mts +6 -0
  87. package/bin/state/graphql-quota-warnings.mjs +105 -0
  88. package/bin/state/rest-cache.d.mts +48 -0
  89. package/bin/state/rest-cache.mjs +91 -0
  90. package/bin/types/api-usage.d.mts +31 -0
  91. package/bin/types/api-usage.mjs +1 -0
  92. package/bin/types/iterate.d.mts +4 -1
  93. package/bin/types/report.d.mts +2 -0
  94. package/bin/types.d.mts +1 -0
  95. package/bin/types.mjs +1 -0
  96. package/package.json +1 -1
  97. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  98. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  99. package/plugins/pr-shepherd/.mcp.json +1 -1
  100. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
@@ -1,10 +1,14 @@
1
+ /* eslint-disable max-lines */
1
2
  import { appendEntry, nextEntry } from "../log/log-file.mjs";
2
3
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
4
  import { GitHubRequestError } from "./errors.mjs";
5
+ import { withGraphQlInternalRetry } from "./graphql-internal-retry.mjs";
4
6
  import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
5
- import { makeHeaders } from "./http-auth.mjs";
7
+ import { makeAuthHeaders } from "./http-auth.mjs";
6
8
  import { requestWithTokenRetry } from "./http-request.mjs";
7
9
  import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
10
+ import { mergeGraphqlRateLimit, recordApiTelemetry } from "./api-telemetry.mjs";
11
+ import { recordIntermediateResponse } from "./http-intermediate.mjs";
8
12
  const BASE_URL = "https://api.github.com";
9
13
  async function graphqlInner(query, vars, opts) {
10
14
  const url = `${BASE_URL}/graphql`;
@@ -17,13 +21,26 @@ async function graphqlInner(query, vars, opts) {
17
21
  body: { query, variables: vars },
18
22
  }));
19
23
  const t0 = performance.now();
20
- const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
24
+ let authSource = "unknown";
25
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
26
+ const auth = await makeAuthHeaders();
27
+ authSource = auth.source;
28
+ return fetch(url, {
29
+ method: "POST",
30
+ headers: auth.headers,
31
+ body: JSON.stringify({ query, variables: vars }),
32
+ });
33
+ }, t0, (response, durationMs) => recordIntermediateResponse({
34
+ n,
35
+ kind: "GraphQL",
21
36
  method: "POST",
22
- headers: await makeHeaders(),
23
- body: JSON.stringify({ query, variables: vars }),
24
- }), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "GraphQL", method: "POST", url, status, durationMs })));
37
+ url,
38
+ response,
39
+ durationMs,
40
+ authSource,
41
+ }));
25
42
  const durationMs = Math.round(performance.now() - retryT0);
26
- const rateLimit = parseRateLimit(res.headers);
43
+ const headerRateLimit = parseRateLimit(res.headers);
27
44
  const retryAfterSeconds = parseRetryAfter(res.headers);
28
45
  if (!res.ok) {
29
46
  const body = await res.text();
@@ -36,8 +53,22 @@ async function graphqlInner(query, vars, opts) {
36
53
  durationMs,
37
54
  textBody: redactToken(body),
38
55
  attempt: attempt > 1 ? attempt : undefined,
56
+ authSource,
57
+ rateLimit: headerRateLimit ?? undefined,
58
+ retryAfterSeconds,
39
59
  }));
40
- throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
60
+ recordApiTelemetry({
61
+ kind: "GraphQL",
62
+ method: "POST",
63
+ authSource,
64
+ rateLimit: headerRateLimit ?? undefined,
65
+ });
66
+ throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, {
67
+ status: res.status,
68
+ rateLimit: headerRateLimit ?? undefined,
69
+ retryAfterSeconds,
70
+ authSource,
71
+ });
41
72
  }
42
73
  let parsed;
43
74
  try {
@@ -54,13 +85,27 @@ async function graphqlInner(query, vars, opts) {
54
85
  durationMs,
55
86
  textBody: `Invalid JSON response${detail}`,
56
87
  attempt: attempt > 1 ? attempt : undefined,
88
+ authSource,
89
+ rateLimit: headerRateLimit ?? undefined,
90
+ retryAfterSeconds,
57
91
  }));
92
+ recordApiTelemetry({
93
+ kind: "GraphQL",
94
+ method: "POST",
95
+ authSource,
96
+ rateLimit: headerRateLimit ?? undefined,
97
+ });
58
98
  throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
59
99
  status: res.status,
60
- rateLimit: rateLimit ?? undefined,
100
+ rateLimit: headerRateLimit ?? undefined,
61
101
  retryAfterSeconds,
102
+ authSource,
62
103
  });
63
104
  }
105
+ const parsedData = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
106
+ ? parsed["data"]
107
+ : undefined;
108
+ const rateLimit = mergeGraphqlRateLimit(headerRateLimit, parsedData);
64
109
  appendEntry(formatResponseEntry({
65
110
  n,
66
111
  kind: "GraphQL",
@@ -70,7 +115,16 @@ async function graphqlInner(query, vars, opts) {
70
115
  durationMs,
71
116
  body: parsed,
72
117
  attempt: attempt > 1 ? attempt : undefined,
118
+ authSource,
119
+ rateLimit: rateLimit ?? undefined,
120
+ retryAfterSeconds,
73
121
  }));
122
+ recordApiTelemetry({
123
+ kind: "GraphQL",
124
+ method: "POST",
125
+ authSource,
126
+ rateLimit: rateLimit ?? undefined,
127
+ });
74
128
  const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
75
129
  if (payload.data == null) {
76
130
  const detail = formatGraphQlErrors(payload.errors);
@@ -79,6 +133,7 @@ async function graphqlInner(query, vars, opts) {
79
133
  rateLimit: rateLimit ?? undefined,
80
134
  retryAfterSeconds,
81
135
  graphqlErrors: payload.errors,
136
+ authSource,
82
137
  });
83
138
  }
84
139
  if (payload.errors?.length && !opts.allowPartialData) {
@@ -87,6 +142,7 @@ async function graphqlInner(query, vars, opts) {
87
142
  rateLimit: rateLimit ?? undefined,
88
143
  retryAfterSeconds,
89
144
  graphqlErrors: payload.errors,
145
+ authSource,
90
146
  });
91
147
  }
92
148
  if (payload.errors?.length) {
@@ -96,10 +152,10 @@ async function graphqlInner(query, vars, opts) {
96
152
  return { data: payload.data, rateLimit, retryAfterSeconds, errors: payload.errors };
97
153
  }
98
154
  export async function graphql(query, vars = {}, opts = {}) {
99
- const { data, errors } = await graphqlInner(query, vars, opts);
155
+ const { data, errors } = await withGraphQlInternalRetry(query, () => graphqlInner(query, vars, opts));
100
156
  return { data, errors };
101
157
  }
102
158
  export async function graphqlWithRateLimit(query, vars = {}, opts = {}) {
103
- const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars, opts);
159
+ const { data, rateLimit, retryAfterSeconds, errors } = await withGraphQlInternalRetry(query, () => graphqlInner(query, vars, opts));
104
160
  return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
105
161
  }
@@ -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) => {
@@ -1,4 +1,12 @@
1
+ export type AuthSource = "GH_TOKEN" | "GITHUB_TOKEN" | "gh auth token" | "GITHUB_PERSONAL_ACCESS_TOKEN";
1
2
  export declare function _resetTokenCache(): void;
2
3
  export declare function hasCachedToken(): boolean;
3
4
  export declare function clearTokenCache(): void;
4
- export declare function makeHeaders(): Promise<Record<string, string>>;
5
+ /**
6
+ * `extra` lets callers layer additional headers (e.g. `If-None-Match` for
7
+ * conditional REST requests) on top of the standard auth/version headers.
8
+ */
9
+ export declare function makeAuthHeaders(extra?: Record<string, string>): Promise<{
10
+ headers: Record<string, string>;
11
+ source: AuthSource;
12
+ }>;
@@ -3,29 +3,40 @@ import { promisify } from "node:util";
3
3
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
4
4
  const execFile = promisify(execFileCb);
5
5
  let _token;
6
+ let _tokenSource;
6
7
  export function _resetTokenCache() {
7
8
  _token = undefined;
9
+ _tokenSource = undefined;
8
10
  }
9
11
  export function hasCachedToken() {
10
12
  return _token !== undefined;
11
13
  }
12
14
  export function clearTokenCache() {
13
15
  _token = undefined;
16
+ _tokenSource = undefined;
14
17
  }
15
18
  async function resolveToken() {
16
- if (_token)
17
- return _token;
18
- const envToken = process.env["GH_TOKEN"] ?? process.env["GITHUB_TOKEN"];
19
- if (envToken) {
20
- _token = envToken;
21
- return _token;
19
+ if (_token && _tokenSource)
20
+ return { token: _token, source: _tokenSource };
21
+ const ghToken = process.env["GH_TOKEN"];
22
+ if (ghToken) {
23
+ _token = ghToken;
24
+ _tokenSource = "GH_TOKEN";
25
+ return { token: _token, source: _tokenSource };
26
+ }
27
+ const githubToken = process.env["GITHUB_TOKEN"];
28
+ if (githubToken) {
29
+ _token = githubToken;
30
+ _tokenSource = "GITHUB_TOKEN";
31
+ return { token: _token, source: _tokenSource };
22
32
  }
23
33
  try {
24
34
  const { stdout } = await execFile("gh", ["auth", "token"]);
25
35
  const token = stdout.trim();
26
36
  if (token) {
27
37
  _token = token;
28
- return _token;
38
+ _tokenSource = "gh auth token";
39
+ return { token: _token, source: _tokenSource };
29
40
  }
30
41
  }
31
42
  catch {
@@ -34,16 +45,26 @@ async function resolveToken() {
34
45
  const codexToken = process.env["GITHUB_PERSONAL_ACCESS_TOKEN"];
35
46
  if (codexToken) {
36
47
  _token = codexToken;
37
- return _token;
48
+ _tokenSource = "GITHUB_PERSONAL_ACCESS_TOKEN";
49
+ return { token: _token, source: _tokenSource };
38
50
  }
39
51
  throw new ShepherdError("No GitHub token found. Set GH_TOKEN, GITHUB_TOKEN, or GITHUB_PERSONAL_ACCESS_TOKEN, or run `gh auth login`.", EXIT.NOPERM);
40
52
  }
41
- export async function makeHeaders() {
53
+ /**
54
+ * `extra` lets callers layer additional headers (e.g. `If-None-Match` for
55
+ * conditional REST requests) on top of the standard auth/version headers.
56
+ */
57
+ export async function makeAuthHeaders(extra) {
58
+ const { token, source } = await resolveToken();
42
59
  return {
43
- Authorization: `Bearer ${await resolveToken()}`,
44
- Accept: "application/vnd.github+json",
45
- "X-GitHub-Api-Version": "2022-11-28",
46
- "User-Agent": "pr-shepherd",
47
- "Content-Type": "application/json",
60
+ source,
61
+ headers: {
62
+ Authorization: `Bearer ${token}`,
63
+ Accept: "application/vnd.github+json",
64
+ "X-GitHub-Api-Version": "2022-11-28",
65
+ "User-Agent": "pr-shepherd",
66
+ "Content-Type": "application/json",
67
+ ...extra,
68
+ },
48
69
  };
49
70
  }
@@ -0,0 +1,9 @@
1
+ export declare function recordIntermediateResponse(opts: {
2
+ n: number;
3
+ kind: "GraphQL" | "REST" | "restText";
4
+ method: string;
5
+ url: string;
6
+ response: Response;
7
+ durationMs: number;
8
+ authSource: string;
9
+ }): void;
@@ -0,0 +1,20 @@
1
+ import { appendEntry } from "../log/log-file.mjs";
2
+ import { formatResponseEntry } from "../log/session.mjs";
3
+ import { recordApiTelemetry } from "./api-telemetry.mjs";
4
+ import { parseRateLimit, parseRetryAfter } from "./http-utils.mjs";
5
+ export function recordIntermediateResponse(opts) {
6
+ const rateLimit = parseRateLimit(opts.response.headers) ?? undefined;
7
+ const retryAfterSeconds = parseRetryAfter(opts.response.headers);
8
+ appendEntry(formatResponseEntry({
9
+ ...opts,
10
+ status: opts.response.status,
11
+ rateLimit,
12
+ retryAfterSeconds,
13
+ }));
14
+ recordApiTelemetry({
15
+ kind: opts.kind === "GraphQL" ? "GraphQL" : "REST",
16
+ method: opts.method,
17
+ authSource: opts.authSource,
18
+ rateLimit,
19
+ });
20
+ }
@@ -1,4 +1,4 @@
1
- type RetryLogFn = (status: number, durationMs: number) => void;
1
+ type RetryLogFn = (response: Response, durationMs: number) => void;
2
2
  export declare function requestWithTokenRetry(fn: () => Promise<Response>, t0: number, onIntermediate?: RetryLogFn): Promise<{
3
3
  res: Response;
4
4
  attempt: number;
@@ -23,7 +23,7 @@ async function fetchWithTransportRetry(fn) {
23
23
  export async function requestWithTokenRetry(fn, t0, onIntermediate) {
24
24
  const res = await fetchWithTransportRetry(fn);
25
25
  if (res.status === 401 && hasCachedToken()) {
26
- onIntermediate?.(401, Math.round(performance.now() - t0));
26
+ onIntermediate?.(res, Math.round(performance.now() - t0));
27
27
  try {
28
28
  await res.arrayBuffer();
29
29
  }
@@ -3,6 +3,12 @@ export interface RateLimitInfo {
3
3
  remaining: number;
4
4
  limit: number;
5
5
  resetAt: number;
6
+ used?: number;
7
+ resource?: string;
8
+ /** Exact GraphQL query cost when the operation selected rateLimit.cost. */
9
+ cost?: number;
10
+ /** Exact GraphQL node count when the operation selected rateLimit.nodeCount. */
11
+ nodeCount?: number;
6
12
  }
7
13
  export declare function sanitizeBody(body: string): string;
8
14
  export declare function redactToken(body: string): string;
@@ -45,7 +45,16 @@ export function parseRateLimit(headers) {
45
45
  const limit = Number(lRaw);
46
46
  const resetAt = Number(tRaw);
47
47
  if (Number.isFinite(remaining) && Number.isFinite(limit) && Number.isFinite(resetAt)) {
48
- return { remaining, limit, resetAt };
48
+ const usedRaw = headers.get("x-ratelimit-used");
49
+ const used = usedRaw === null ? undefined : Number(usedRaw);
50
+ const resource = headers.get("x-ratelimit-resource") ?? undefined;
51
+ return {
52
+ remaining,
53
+ limit,
54
+ resetAt,
55
+ ...(Number.isFinite(used) && { used }),
56
+ ...(resource !== undefined && resource !== "" && { resource }),
57
+ };
49
58
  }
50
59
  return null;
51
60
  }
@@ -1,7 +1,22 @@
1
+ import { type StateKey } from "../state/rest-cache.mts";
1
2
  import { type RateLimitInfo } from "./http-utils.mts";
2
3
  export interface RestResult<T = unknown> {
3
4
  data: T;
4
5
  rateLimit?: RateLimitInfo;
5
6
  }
7
+ /**
8
+ * Enables `If-None-Match` conditional requests for a REST call, backed by a
9
+ * per-PR on-disk cache (see `src/state/rest-cache.mts`). A 304 response
10
+ * returns the cached body and — unlike a normal request — does not consume
11
+ * the primary REST rate-limit quota.
12
+ */
13
+ export interface RestRequestOptions {
14
+ conditional?: {
15
+ key: StateKey;
16
+ /** Logical cache name; must be unique per distinct resource + page. */
17
+ name: string;
18
+ headSha?: string;
19
+ };
20
+ }
6
21
  export declare function rest<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
7
- export declare function restWithRateLimit<T = unknown>(method: string, path: string, body?: unknown): Promise<RestResult<T>>;
22
+ export declare function restWithRateLimit<T = unknown>(method: string, path: string, body?: unknown, opts?: RestRequestOptions): Promise<RestResult<T>>;
@@ -1,9 +1,13 @@
1
+ /* eslint-disable max-lines */
1
2
  import { appendEntry, nextEntry } from "../log/log-file.mjs";
2
3
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
4
+ import { loadEtagEntry, storeEtagEntry } from "../state/rest-cache.mjs";
3
5
  import { GitHubRequestError } from "./errors.mjs";
4
- import { makeHeaders } from "./http-auth.mjs";
6
+ import { makeAuthHeaders } from "./http-auth.mjs";
5
7
  import { requestWithTokenRetry } from "./http-request.mjs";
6
8
  import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
9
+ import { recordApiTelemetry } from "./api-telemetry.mjs";
10
+ import { recordIntermediateResponse } from "./http-intermediate.mjs";
7
11
  const BASE_URL = "https://api.github.com";
8
12
  const SAFE_GITHUB_REST_PATH = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/?-]*$/;
9
13
  function githubApiUrl(path) {
@@ -24,19 +28,55 @@ export async function rest(method, path, body) {
24
28
  const { data } = await restWithRateLimit(method, path, body);
25
29
  return data;
26
30
  }
27
- export async function restWithRateLimit(method, path, body) {
31
+ export async function restWithRateLimit(method, path, body, opts) {
28
32
  const url = githubApiUrl(path);
29
33
  const n = nextEntry();
30
34
  appendEntry(formatRequestEntry({ n, kind: "REST", method, url, body }));
31
35
  const t0 = performance.now();
32
- const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
36
+ let authSource = "unknown";
37
+ const cached = opts?.conditional
38
+ ? await loadEtagEntry(opts.conditional.key, opts.conditional.name)
39
+ : null;
40
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
41
+ const auth = await makeAuthHeaders(cached ? { "If-None-Match": cached.etag } : undefined);
42
+ authSource = auth.source;
43
+ return fetch(url, {
44
+ method,
45
+ headers: auth.headers,
46
+ body: body !== undefined ? JSON.stringify(body) : undefined,
47
+ });
48
+ }, t0, (response, durationMs) => recordIntermediateResponse({
49
+ n,
50
+ kind: "REST",
33
51
  method,
34
- headers: await makeHeaders(),
35
- body: body !== undefined ? JSON.stringify(body) : undefined,
36
- }), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "REST", method, url, status, durationMs })));
52
+ url,
53
+ response,
54
+ durationMs,
55
+ authSource,
56
+ }));
37
57
  const durationMs = Math.round(performance.now() - retryT0);
38
- const ct = res.headers.get("content-type") ?? "";
39
58
  const rateLimit = parseRateLimit(res.headers) ?? undefined;
59
+ const retryAfterSeconds = parseRetryAfter(res.headers);
60
+ // A 304 is not `res.ok` but is not an error either — and critically, does
61
+ // not consume the primary REST request quota — so it must be handled
62
+ // before the !res.ok branch below.
63
+ if (res.status === 304 && cached) {
64
+ appendEntry(formatResponseEntry({
65
+ n,
66
+ kind: "REST",
67
+ method,
68
+ url,
69
+ status: res.status,
70
+ durationMs,
71
+ attempt: attempt > 1 ? attempt : undefined,
72
+ authSource,
73
+ rateLimit,
74
+ retryAfterSeconds,
75
+ }));
76
+ recordApiTelemetry({ kind: "REST", method, authSource, rateLimit });
77
+ return { data: cached.body, rateLimit };
78
+ }
79
+ const ct = res.headers.get("content-type") ?? "";
40
80
  if (!res.ok) {
41
81
  const text = await res.text();
42
82
  appendEntry(formatResponseEntry({
@@ -48,11 +88,16 @@ export async function restWithRateLimit(method, path, body) {
48
88
  durationMs,
49
89
  textBody: redactToken(text),
50
90
  attempt: attempt > 1 ? attempt : undefined,
91
+ authSource,
92
+ rateLimit,
93
+ retryAfterSeconds,
51
94
  }));
95
+ recordApiTelemetry({ kind: "REST", method, authSource, rateLimit });
52
96
  throw new GitHubRequestError(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
53
97
  status: res.status,
54
98
  rateLimit,
55
- retryAfterSeconds: parseRetryAfter(res.headers),
99
+ retryAfterSeconds,
100
+ authSource,
56
101
  });
57
102
  }
58
103
  if (ct.includes("application/json")) {
@@ -67,7 +112,21 @@ export async function restWithRateLimit(method, path, body) {
67
112
  contentType: ct,
68
113
  body: json,
69
114
  attempt: attempt > 1 ? attempt : undefined,
115
+ authSource,
116
+ rateLimit,
117
+ retryAfterSeconds,
70
118
  }));
119
+ recordApiTelemetry({ kind: "REST", method, authSource, rateLimit });
120
+ if (opts?.conditional) {
121
+ const etag = res.headers.get("etag");
122
+ if (etag) {
123
+ await storeEtagEntry(opts.conditional.key, opts.conditional.name, {
124
+ etag,
125
+ body: json,
126
+ ...(opts.conditional.headSha !== undefined && { headSha: opts.conditional.headSha }),
127
+ });
128
+ }
129
+ }
71
130
  return { data: json, rateLimit };
72
131
  }
73
132
  appendEntry(formatResponseEntry({
@@ -79,6 +138,10 @@ export async function restWithRateLimit(method, path, body) {
79
138
  durationMs,
80
139
  contentType: ct || undefined,
81
140
  attempt: attempt > 1 ? attempt : undefined,
141
+ authSource,
142
+ rateLimit,
143
+ retryAfterSeconds,
82
144
  }));
145
+ recordApiTelemetry({ kind: "REST", method, authSource, rateLimit });
83
146
  return { data: undefined, rateLimit };
84
147
  }
@@ -1,19 +1,45 @@
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 { makeHeaders } from "./http-auth.mjs";
4
+ import { makeAuthHeaders } from "./http-auth.mjs";
5
5
  import { requestWithTokenRetry } from "./http-request.mjs";
6
6
  import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody } from "./http-utils.mjs";
7
+ import { recordApiTelemetry } from "./api-telemetry.mjs";
8
+ import { recordIntermediateResponse } from "./http-intermediate.mjs";
7
9
  const BASE_URL = "https://api.github.com";
8
10
  export async function restText(path) {
9
11
  const url = `${BASE_URL}${path}`;
10
12
  const n = nextEntry();
11
13
  appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
12
14
  const t0 = performance.now();
13
- const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, { method: "GET", headers: await makeHeaders(), redirect: "manual" }), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "restText", method: "GET", url, status, durationMs })));
15
+ let authSource = "unknown";
16
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
17
+ const auth = await makeAuthHeaders();
18
+ authSource = auth.source;
19
+ return fetch(url, { method: "GET", headers: auth.headers, redirect: "manual" });
20
+ }, t0, (response, durationMs) => recordIntermediateResponse({
21
+ n,
22
+ kind: "restText",
23
+ method: "GET",
24
+ url,
25
+ response,
26
+ durationMs,
27
+ authSource,
28
+ }));
14
29
  const durationMs = Math.round(performance.now() - retryT0);
30
+ const rateLimit = parseRateLimit(res.headers) ?? undefined;
31
+ const retryAfterSeconds = parseRetryAfter(res.headers);
32
+ recordApiTelemetry({ kind: "REST", method: "GET", authSource, rateLimit });
15
33
  if ([301, 302, 307, 308].includes(res.status)) {
16
- const redirected = await followRestTextRedirect(res, { n, url, durationMs, attempt });
34
+ const redirected = await followRestTextRedirect(res, {
35
+ n,
36
+ url,
37
+ durationMs,
38
+ attempt,
39
+ authSource,
40
+ rateLimit,
41
+ retryAfterSeconds,
42
+ });
17
43
  if (redirected !== null)
18
44
  return redirected;
19
45
  }
@@ -27,11 +53,15 @@ export async function restText(path) {
27
53
  status: res.status,
28
54
  durationMs,
29
55
  attempt: attempt > 1 ? attempt : undefined,
56
+ authSource,
57
+ rateLimit,
58
+ retryAfterSeconds,
30
59
  }));
31
60
  throw new GitHubRequestError(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
32
61
  status: res.status,
33
- rateLimit: parseRateLimit(res.headers) ?? undefined,
34
- retryAfterSeconds: parseRetryAfter(res.headers),
62
+ rateLimit,
63
+ retryAfterSeconds,
64
+ authSource,
35
65
  });
36
66
  }
37
67
  appendEntry(formatResponseEntry({
@@ -43,6 +73,9 @@ export async function restText(path) {
43
73
  durationMs,
44
74
  contentLength: parseContentLength(res.headers),
45
75
  attempt: attempt > 1 ? attempt : undefined,
76
+ authSource,
77
+ rateLimit,
78
+ retryAfterSeconds,
46
79
  }));
47
80
  return res.text();
48
81
  }
@@ -55,6 +88,9 @@ async function followRestTextRedirect(res, entry) {
55
88
  status: res.status,
56
89
  durationMs: entry.durationMs,
57
90
  attempt: entry.attempt > 1 ? entry.attempt : undefined,
91
+ authSource: entry.authSource,
92
+ rateLimit: entry.rateLimit,
93
+ retryAfterSeconds: entry.retryAfterSeconds,
58
94
  }));
59
95
  const location = res.headers.get("location");
60
96
  if (!location)
package/bin/index.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { main } from "./cli-parser.mjs";
11
11
  import { errorToExitCode } from "./exit-codes.mjs";
12
+ import { formatCliError } from "./cli/error-format.mjs";
12
13
  function formatCause(cause, seen = new Set(), depth = 0) {
13
14
  if (depth > 5 || seen.has(cause))
14
15
  return "[circular or deep cause chain]";
@@ -21,7 +22,7 @@ function formatCause(cause, seen = new Set(), depth = 0) {
21
22
  return String(cause);
22
23
  }
23
24
  main(process.argv).catch((err) => {
24
- const msg = err instanceof Error ? err.message : String(err);
25
+ const msg = formatCliError(err);
25
26
  const causeStr = err instanceof Error && err.cause != null ? formatCause(err.cause) : null;
26
27
  process.stderr.write(`pr-shepherd error: ${msg}${causeStr !== null ? ` (cause: ${causeStr})` : ""}\n`);
27
28
  process.exit(errorToExitCode(err));