pr-shepherd 0.44.1 → 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 (95) 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/github/api-telemetry-aggregate.d.mts +32 -0
  43. package/bin/github/api-telemetry-aggregate.mjs +84 -0
  44. package/bin/github/api-telemetry.d.mts +13 -0
  45. package/bin/github/api-telemetry.mjs +128 -0
  46. package/bin/github/check-annotations.d.mts +14 -1
  47. package/bin/github/check-annotations.mjs +29 -2
  48. package/bin/github/errors.d.mts +2 -0
  49. package/bin/github/errors.mjs +2 -0
  50. package/bin/github/gql/batch-pr-page.gql +8 -0
  51. package/bin/github/gql/batch-pr.gql +8 -0
  52. package/bin/github/gql/check-run-annotations.gql +8 -0
  53. package/bin/github/gql/commit-check-contexts.gql +8 -0
  54. package/bin/github/gql/get-pr-body.gql +8 -0
  55. package/bin/github/gql/get-pr-head-sha.gql +8 -0
  56. package/bin/github/gql/pr-number-by-branch.gql +8 -0
  57. package/bin/github/gql/review-thread-comments.gql +8 -0
  58. package/bin/github/gql/suggestion-threads.gql +8 -0
  59. package/bin/github/graphql-http.mjs +63 -8
  60. package/bin/github/http-auth.d.mts +9 -1
  61. package/bin/github/http-auth.mjs +35 -14
  62. package/bin/github/http-intermediate.d.mts +9 -0
  63. package/bin/github/http-intermediate.mjs +20 -0
  64. package/bin/github/http-request.d.mts +1 -1
  65. package/bin/github/http-request.mjs +1 -1
  66. package/bin/github/http-utils.d.mts +6 -0
  67. package/bin/github/http-utils.mjs +10 -1
  68. package/bin/github/rest-http.d.mts +16 -1
  69. package/bin/github/rest-http.mjs +71 -8
  70. package/bin/github/rest-text.mjs +41 -5
  71. package/bin/index.mjs +2 -1
  72. package/bin/log/session.d.mts +11 -0
  73. package/bin/log/session.mjs +22 -0
  74. package/bin/mcp/server.mjs +16 -2
  75. package/bin/quota-warning.d.mts +2 -0
  76. package/bin/quota-warning.mjs +6 -0
  77. package/bin/state/graphql-quota-claims.d.mts +2 -0
  78. package/bin/state/graphql-quota-claims.mjs +61 -0
  79. package/bin/state/graphql-quota-policy.d.mts +17 -0
  80. package/bin/state/graphql-quota-policy.mjs +43 -0
  81. package/bin/state/graphql-quota-warnings.d.mts +6 -0
  82. package/bin/state/graphql-quota-warnings.mjs +105 -0
  83. package/bin/state/rest-cache.d.mts +48 -0
  84. package/bin/state/rest-cache.mjs +91 -0
  85. package/bin/types/api-usage.d.mts +31 -0
  86. package/bin/types/api-usage.mjs +1 -0
  87. package/bin/types/iterate.d.mts +4 -1
  88. package/bin/types/report.d.mts +2 -0
  89. package/bin/types.d.mts +1 -0
  90. package/bin/types.mjs +1 -0
  91. package/package.json +1 -1
  92. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  93. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  94. package/plugins/pr-shepherd/.mcp.json +1 -1
  95. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
@@ -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));
@@ -24,6 +24,17 @@ interface HttpResponseEntry {
24
24
  contentLength?: number;
25
25
  /** Set on 401-retry invocations. */
26
26
  attempt?: number;
27
+ authSource?: string;
28
+ rateLimit?: {
29
+ resource?: string;
30
+ limit: number;
31
+ used?: number;
32
+ remaining: number;
33
+ resetAt: number;
34
+ cost?: number;
35
+ nodeCount?: number;
36
+ };
37
+ retryAfterSeconds?: number;
27
38
  }
28
39
  export declare function formatRequestEntry(entry: HttpRequestEntry): string;
29
40
  export declare function formatResponseEntry(entry: HttpResponseEntry): string;
@@ -70,6 +70,28 @@ export function formatResponseEntry(entry) {
70
70
  ? `restText response — ${entry.status}${attempt} · ${entry.durationMs}ms`
71
71
  : `REST response — ${entry.status}${attempt} · ${entry.durationMs}ms`;
72
72
  let out = `### #${entry.n} ${label} · ${ts}\n\n`;
73
+ if (entry.authSource !== undefined)
74
+ out += `auth-source: \`${entry.authSource}\`\n`;
75
+ if (entry.rateLimit !== undefined) {
76
+ const used = entry.rateLimit.used !== undefined ? ` · used ${entry.rateLimit.used}` : "";
77
+ const resource = entry.rateLimit.resource ?? "unknown";
78
+ out += `rate-limit: \`${resource}\` · remaining ${entry.rateLimit.remaining}/${entry.rateLimit.limit}${used} · reset ${new Date(entry.rateLimit.resetAt * 1000).toISOString()}\n`;
79
+ if (entry.rateLimit.cost !== undefined || entry.rateLimit.nodeCount !== undefined) {
80
+ const parts = [
81
+ entry.rateLimit.cost !== undefined ? `cost ${entry.rateLimit.cost}` : null,
82
+ entry.rateLimit.nodeCount !== undefined ? `nodes ${entry.rateLimit.nodeCount}` : null,
83
+ ].filter(Boolean);
84
+ out += `graphql-query: ${parts.join(" · ")}\n`;
85
+ }
86
+ }
87
+ if (entry.retryAfterSeconds !== undefined) {
88
+ out += `retry-after: ${entry.retryAfterSeconds}s\n`;
89
+ }
90
+ if (entry.authSource !== undefined ||
91
+ entry.rateLimit !== undefined ||
92
+ entry.retryAfterSeconds !== undefined) {
93
+ out += "\n";
94
+ }
73
95
  if (entry.kind === "restText") {
74
96
  if (entry.contentLength !== undefined) {
75
97
  out += `content-length: ${entry.contentLength} bytes (body not logged)\n\n`;
@@ -6,6 +6,7 @@ import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from
6
6
  import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
7
7
  import { formatJournalResult } from "../cli/journal-formatter.mjs";
8
8
  import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
9
+ import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
9
10
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
10
11
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
11
12
  const pr = z
@@ -166,16 +167,29 @@ function toolError(error) {
166
167
  : error instanceof PartialApplyError
167
168
  ? errorToExitCode(error.cause)
168
169
  : errorToExitCode(error);
170
+ const cause = error instanceof PartialApplyError ? error.cause : error;
171
+ const githubDetails = serializeGitHubRequestErrorDetails(cause);
169
172
  const details = error instanceof PartialApplyError
170
173
  ? { failedIndex: error.failedIndex, completed: redactValue(error.completed) }
171
174
  : error instanceof PrShepherdValidationError
172
175
  ? { validation: true }
173
176
  : {};
174
- const message = redactErrorMessage(error instanceof Error ? error.message : String(error));
177
+ const formattedCause = formatCliError(cause);
178
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
179
+ const message = redactErrorMessage(error instanceof PartialApplyError && formattedCause.startsWith(causeMessage)
180
+ ? `${error.message}${formattedCause.slice(causeMessage.length)}`
181
+ : formattedCause);
175
182
  return {
176
183
  isError: true,
177
184
  content: [{ type: "text", text: `pr-shepherd error (${code}): ${message}` }],
178
- structuredContent: { code, message, details },
185
+ structuredContent: {
186
+ code,
187
+ message,
188
+ details: {
189
+ ...details,
190
+ ...(githubDetails !== undefined && { github: redactValue(githubDetails) }),
191
+ },
192
+ },
179
193
  };
180
194
  }
181
195
  function redactErrorMessage(message) {
@@ -0,0 +1,2 @@
1
+ import type { GraphqlQuotaWarning } from "./types.mts";
2
+ export declare function buildQuotaAwareContinuation(warning: GraphqlQuotaWarning, prefix: string): string;
@@ -0,0 +1,6 @@
1
+ export function buildQuotaAwareContinuation(warning, prefix) {
2
+ const interval = `${warning.pollIntervalMinutes}m`;
3
+ const timeout = `${warning.pollTimeoutMinutes}m`;
4
+ const resetTime = new Date(warning.resetAt * 1000).toISOString();
5
+ return `${prefix} GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). For the time being, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr checks\`, \`gh pr review\`, \`gh api\` REST endpoints) for PR operations — they draw on the separate REST budget, not the depleted GraphQL pool. Resume pr-shepherd after the GraphQL quota resets at ${resetTime}. If you must keep polling before then, poll no more often than every ${warning.pollIntervalMinutes} minutes. With a polling CLI command, preserve the other options, replace any existing interval and timeout flags with \`--interval ${interval} --timeout ${timeout}\`, and omit \`--timeout\` when using \`--until-terminal\`. With a single-tick CLI, API, or MCP call, wait at least ${warning.pollIntervalMinutes} minutes before the next tick.`;
6
+ }
@@ -0,0 +1,2 @@
1
+ import type { GraphqlQuotaWarning } from "../types.mts";
2
+ export declare function claimWarning(path: string, warning: GraphqlQuotaWarning, now: number, rearmEpoch: number): Promise<boolean>;
@@ -0,0 +1,61 @@
1
+ import { mkdir, open, readdir, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function claimWarning(path, warning, now, rearmEpoch) {
4
+ const claimsDir = `${path}.claims`;
5
+ const claimPath = join(claimsDir, `${warning.resetAt}-${warning.limit}-${warning.thresholdPercent}-${rearmEpoch}.json`);
6
+ let handle;
7
+ try {
8
+ await mkdir(claimsDir, { recursive: true });
9
+ await sweepStaleClaims(claimsDir, now);
10
+ handle = await open(claimPath, "wx");
11
+ await handle.writeFile(JSON.stringify({
12
+ resource: warning.resource,
13
+ resetAt: warning.resetAt,
14
+ thresholdPercent: warning.thresholdPercent,
15
+ rearmEpoch,
16
+ }), "utf8");
17
+ return true;
18
+ }
19
+ catch (error) {
20
+ // When state storage is unavailable, surfacing the warning is safer than
21
+ // silently exhausting the credential. EEXIST alone means another process
22
+ // racing for this exact re-arm epoch already won the claim — a claim
23
+ // left over from an earlier epoch (e.g. before a credential switch) has
24
+ // a different filename and never collides here, so a re-armed warning
25
+ // is never suppressed by a stale epoch's claim.
26
+ return !isAlreadyExists(error);
27
+ }
28
+ finally {
29
+ try {
30
+ await handle?.close();
31
+ }
32
+ catch {
33
+ // Best-effort claim cleanup is unnecessary: existence is the claim.
34
+ }
35
+ }
36
+ }
37
+ async function sweepStaleClaims(claimsDir, now) {
38
+ try {
39
+ const entries = await readdir(claimsDir);
40
+ await Promise.all(entries.map(async (entry) => {
41
+ const resetAt = Number(entry.split("-")[0]);
42
+ if (!Number.isFinite(resetAt) || now < resetAt)
43
+ return;
44
+ try {
45
+ await unlink(join(claimsDir, entry));
46
+ }
47
+ catch {
48
+ // Best-effort sweep.
49
+ }
50
+ }));
51
+ }
52
+ catch {
53
+ // Best-effort sweep; a missing or unreadable directory is fine.
54
+ }
55
+ }
56
+ function isAlreadyExists(error) {
57
+ return (error !== null &&
58
+ typeof error === "object" &&
59
+ "code" in error &&
60
+ error.code === "EEXIST");
61
+ }
@@ -0,0 +1,17 @@
1
+ import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import type { GraphqlApiUsage, GraphqlQuotaWarning } from "../types.mts";
3
+ export interface GraphqlQuotaWarningState {
4
+ resource: string;
5
+ limit: number;
6
+ lastUsed?: number;
7
+ lastRemaining: number;
8
+ resetAt: number;
9
+ warnedThresholds: number[];
10
+ rearmEpoch?: number;
11
+ }
12
+ export declare function evaluateGraphqlQuotaWarning(bands: GraphqlQuotaWarningBand[], sample: Pick<GraphqlApiUsage, "resource" | "limit" | "used" | "remaining" | "resetAt">, previous: GraphqlQuotaWarningState | null, observedAt?: number): {
13
+ warning?: GraphqlQuotaWarning;
14
+ state: GraphqlQuotaWarningState & {
15
+ rearmEpoch: number;
16
+ };
17
+ };
@@ -0,0 +1,43 @@
1
+ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt = Date.now() / 1000) {
2
+ const windowRolled = previous !== null && sample.resetAt > previous.resetAt && observedAt >= previous.resetAt;
3
+ const rearm = previous === null ||
4
+ previous.resource !== sample.resource ||
5
+ previous.limit !== sample.limit ||
6
+ windowRolled ||
7
+ (sample.used !== undefined && previous.lastUsed !== undefined
8
+ ? sample.used < previous.lastUsed
9
+ : sample.remaining > previous.lastRemaining);
10
+ const warned = new Set(rearm ? [] : previous.warnedThresholds);
11
+ const rearmEpoch = rearm ? (previous?.rearmEpoch ?? 0) + 1 : (previous?.rearmEpoch ?? 0);
12
+ const crossed = bands.filter((band) => sample.remaining * 100 <= sample.limit * band.remainingPercent);
13
+ const newCrossed = crossed.filter((band) => !warned.has(band.remainingPercent));
14
+ for (const band of crossed)
15
+ warned.add(band.remainingPercent);
16
+ const active = newCrossed.at(-1);
17
+ const state = {
18
+ resource: sample.resource,
19
+ limit: sample.limit,
20
+ ...(sample.used !== undefined && { lastUsed: sample.used }),
21
+ lastRemaining: sample.remaining,
22
+ resetAt: sample.resetAt,
23
+ warnedThresholds: bands
24
+ .map((band) => band.remainingPercent)
25
+ .filter((threshold) => warned.has(threshold)),
26
+ rearmEpoch,
27
+ };
28
+ if (active === undefined)
29
+ return { state };
30
+ return {
31
+ warning: {
32
+ resource: "graphql",
33
+ thresholdPercent: active.remainingPercent,
34
+ remaining: sample.remaining,
35
+ limit: sample.limit,
36
+ ...(sample.used !== undefined && { used: sample.used }),
37
+ resetAt: sample.resetAt,
38
+ pollIntervalMinutes: active.pollIntervalMinutes,
39
+ pollTimeoutMinutes: active.pollIntervalMinutes * 2,
40
+ },
41
+ state,
42
+ };
43
+ }
@@ -0,0 +1,6 @@
1
+ import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import type { GraphqlQuotaWarning, GraphqlApiUsage } from "../types.mts";
3
+ export declare function evaluateWorktreeGraphqlQuotaWarning(key: {
4
+ owner: string;
5
+ repo: string;
6
+ }, bands: GraphqlQuotaWarningBand[], sample: GraphqlApiUsage, persist: boolean, now?: number): Promise<GraphqlQuotaWarning | undefined>;