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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -4
- package/bin/checks/conclusions.d.mts +24 -1
- package/bin/checks/conclusions.mjs +39 -0
- package/bin/checks/triage.d.mts +3 -2
- package/bin/checks/triage.mjs +44 -15
- package/bin/cli/api-usage-formatter.d.mts +3 -0
- package/bin/cli/api-usage-formatter.mjs +36 -0
- package/bin/cli/error-format.d.mts +13 -0
- package/bin/cli/error-format.mjs +41 -0
- package/bin/cli/fix-formatter.mjs +12 -1
- package/bin/cli/iterate-activity-formatter.d.mts +2 -0
- package/bin/cli/iterate-activity-formatter.mjs +26 -0
- package/bin/cli/iterate-formatter.mjs +11 -28
- package/bin/cli/iterate-instructions.mjs +14 -1
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/mutate-formatter.mjs +2 -0
- package/bin/commands/check-annotations.d.mts +2 -1
- package/bin/commands/check-annotations.mjs +7 -7
- package/bin/commands/check.mjs +6 -4
- package/bin/commands/iterate/api-usage.d.mts +2 -0
- package/bin/commands/iterate/api-usage.mjs +42 -0
- package/bin/commands/iterate/check-instructions.d.mts +2 -2
- package/bin/commands/iterate/check-instructions.mjs +27 -8
- package/bin/commands/iterate/fix-code.mjs +42 -8
- package/bin/commands/iterate/index.mjs +6 -3
- package/bin/commands/iterate/merge-state.mjs +5 -2
- package/bin/commands/iterate/render.d.mts +1 -1
- package/bin/commands/iterate/render.mjs +9 -5
- package/bin/commands/iterate/run.d.mts +2 -0
- package/bin/commands/iterate/run.mjs +8 -0
- package/bin/commands/mark-files-as-viewed.mjs +8 -0
- package/bin/commands/poll-run.d.mts +2 -0
- package/bin/commands/poll-run.mjs +8 -0
- package/bin/commands/poll.d.mts +1 -2
- package/bin/commands/poll.mjs +29 -10
- package/bin/comments/rate-limit.d.mts +4 -0
- package/bin/comments/rate-limit.mjs +6 -0
- package/bin/config/load.d.mts +6 -1
- package/bin/config/load.mjs +33 -1
- package/bin/config.json +6 -1
- package/bin/github/api-telemetry-aggregate.d.mts +32 -0
- package/bin/github/api-telemetry-aggregate.mjs +84 -0
- package/bin/github/api-telemetry.d.mts +13 -0
- package/bin/github/api-telemetry.mjs +128 -0
- package/bin/github/check-annotations.d.mts +14 -1
- package/bin/github/check-annotations.mjs +29 -2
- package/bin/github/errors.d.mts +2 -0
- package/bin/github/errors.mjs +2 -0
- package/bin/github/gql/batch-pr-page.gql +8 -0
- package/bin/github/gql/batch-pr.gql +8 -0
- package/bin/github/gql/check-run-annotations.gql +8 -0
- package/bin/github/gql/commit-check-contexts.gql +8 -0
- package/bin/github/gql/get-pr-body.gql +8 -0
- package/bin/github/gql/get-pr-head-sha.gql +8 -0
- package/bin/github/gql/pr-number-by-branch.gql +8 -0
- package/bin/github/gql/review-thread-comments.gql +8 -0
- package/bin/github/gql/suggestion-threads.gql +8 -0
- package/bin/github/graphql-http.mjs +63 -8
- package/bin/github/http-auth.d.mts +9 -1
- package/bin/github/http-auth.mjs +35 -14
- package/bin/github/http-intermediate.d.mts +9 -0
- package/bin/github/http-intermediate.mjs +20 -0
- package/bin/github/http-request.d.mts +1 -1
- package/bin/github/http-request.mjs +1 -1
- package/bin/github/http-utils.d.mts +6 -0
- package/bin/github/http-utils.mjs +10 -1
- package/bin/github/rest-http.d.mts +16 -1
- package/bin/github/rest-http.mjs +71 -8
- package/bin/github/rest-text.mjs +41 -5
- package/bin/index.mjs +2 -1
- package/bin/log/session.d.mts +11 -0
- package/bin/log/session.mjs +22 -0
- package/bin/mcp/server.mjs +16 -2
- package/bin/quota-warning.d.mts +2 -0
- package/bin/quota-warning.mjs +6 -0
- package/bin/state/graphql-quota-claims.d.mts +2 -0
- package/bin/state/graphql-quota-claims.mjs +61 -0
- package/bin/state/graphql-quota-policy.d.mts +17 -0
- package/bin/state/graphql-quota-policy.mjs +43 -0
- package/bin/state/graphql-quota-warnings.d.mts +6 -0
- package/bin/state/graphql-quota-warnings.mjs +105 -0
- package/bin/state/rest-cache.d.mts +48 -0
- package/bin/state/rest-cache.mjs +91 -0
- package/bin/types/api-usage.d.mts +31 -0
- package/bin/types/api-usage.mjs +1 -0
- package/bin/types/iterate.d.mts +4 -1
- package/bin/types/report.d.mts +2 -0
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- 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/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
package/bin/github/http-auth.mjs
CHANGED
|
@@ -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
|
|
19
|
-
if (
|
|
20
|
-
_token =
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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,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 = (
|
|
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?.(
|
|
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
|
-
|
|
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>>;
|
package/bin/github/rest-http.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
}
|
package/bin/github/rest-text.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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, {
|
|
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
|
|
34
|
-
retryAfterSeconds
|
|
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 =
|
|
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));
|
package/bin/log/session.d.mts
CHANGED
|
@@ -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;
|
package/bin/log/session.mjs
CHANGED
|
@@ -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`;
|
package/bin/mcp/server.mjs
CHANGED
|
@@ -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
|
|
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: {
|
|
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,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,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>;
|