pr-shepherd 0.44.1 → 0.46.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 +19 -8
- package/bin/checks/conclusions.d.mts +8 -1
- package/bin/checks/conclusions.mjs +12 -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 +22 -5
- package/bin/cli/help-command-pages.d.mts +13 -0
- package/bin/cli/help-command-pages.mjs +13 -0
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +2 -0
- package/bin/cli/help.d.mts +14 -1
- package/bin/cli/help.mjs +2 -0
- package/bin/cli/iterate-activity-formatter.d.mts +2 -0
- package/bin/cli/iterate-activity-formatter.mjs +26 -0
- package/bin/cli/iterate-checks-formatter.d.mts +2 -0
- package/bin/cli/iterate-checks-formatter.mjs +55 -0
- package/bin/cli/iterate-formatter.mjs +17 -29
- package/bin/cli/iterate-instructions.mjs +14 -1
- package/bin/cli/iterate-lean.mjs +5 -0
- package/bin/cli/journal-extract-handler.d.mts +2 -0
- package/bin/cli/journal-extract-handler.mjs +48 -0
- package/bin/cli/mutate-formatter.mjs +2 -0
- package/bin/cli/safe-body-file.d.mts +14 -0
- package/bin/cli/safe-body-file.mjs +42 -0
- package/bin/cli-parser.mjs +7 -0
- package/bin/commands/check-annotations.d.mts +2 -1
- package/bin/commands/check-annotations.mjs +7 -7
- package/bin/commands/check-status.mjs +4 -4
- package/bin/commands/check.mjs +31 -9
- package/bin/commands/iterate/api-usage.d.mts +2 -0
- package/bin/commands/iterate/api-usage.mjs +35 -0
- package/bin/commands/iterate/check-instructions.d.mts +1 -1
- package/bin/commands/iterate/check-instructions.mjs +13 -18
- package/bin/commands/iterate/classify.mjs +2 -1
- package/bin/commands/iterate/escalate.mjs +63 -9
- package/bin/commands/iterate/fix-code.mjs +110 -73
- package/bin/commands/iterate/index.mjs +8 -5
- package/bin/commands/iterate/merge-state.mjs +5 -2
- package/bin/commands/iterate/render.mjs +13 -6
- 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 +26 -10
- package/bin/comments/rate-limit.d.mts +4 -0
- package/bin/comments/rate-limit.mjs +6 -0
- package/bin/comments/review-visibility.d.mts +1 -1
- package/bin/comments/review-visibility.mjs +3 -2
- package/bin/comments/thread-visibility.d.mts +1 -1
- package/bin/comments/thread-visibility.mjs +9 -3
- 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/client.d.mts +1 -1
- package/bin/github/client.mjs +1 -1
- 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/pr-reference.d.mts +1 -1
- package/bin/pr-reference.mjs +1 -1
- 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/escalate.d.mts +5 -3
- package/bin/types/iterate.d.mts +4 -1
- package/bin/types/report.d.mts +3 -1
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- package/package.json +2 -2
- 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 +24 -13
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
query SuggestionThreads($owner: String!, $repo: String!, $pr: Int!, $threadIds: [ID!]!) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
2
10
|
repository(owner: $owner, name: $repo) {
|
|
3
11
|
pullRequest(number: $pr) {
|
|
4
12
|
headRefOid
|
|
@@ -1,11 +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";
|
|
4
5
|
import { withGraphQlInternalRetry } from "./graphql-internal-retry.mjs";
|
|
5
6
|
import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
|
|
6
|
-
import {
|
|
7
|
+
import { makeAuthHeaders } from "./http-auth.mjs";
|
|
7
8
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
8
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";
|
|
9
12
|
const BASE_URL = "https://api.github.com";
|
|
10
13
|
async function graphqlInner(query, vars, opts) {
|
|
11
14
|
const url = `${BASE_URL}/graphql`;
|
|
@@ -18,13 +21,26 @@ async function graphqlInner(query, vars, opts) {
|
|
|
18
21
|
body: { query, variables: vars },
|
|
19
22
|
}));
|
|
20
23
|
const t0 = performance.now();
|
|
21
|
-
|
|
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",
|
|
22
36
|
method: "POST",
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
url,
|
|
38
|
+
response,
|
|
39
|
+
durationMs,
|
|
40
|
+
authSource,
|
|
41
|
+
}));
|
|
26
42
|
const durationMs = Math.round(performance.now() - retryT0);
|
|
27
|
-
const
|
|
43
|
+
const headerRateLimit = parseRateLimit(res.headers);
|
|
28
44
|
const retryAfterSeconds = parseRetryAfter(res.headers);
|
|
29
45
|
if (!res.ok) {
|
|
30
46
|
const body = await res.text();
|
|
@@ -37,8 +53,22 @@ async function graphqlInner(query, vars, opts) {
|
|
|
37
53
|
durationMs,
|
|
38
54
|
textBody: redactToken(body),
|
|
39
55
|
attempt: attempt > 1 ? attempt : undefined,
|
|
56
|
+
authSource,
|
|
57
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
58
|
+
retryAfterSeconds,
|
|
40
59
|
}));
|
|
41
|
-
|
|
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
|
+
});
|
|
42
72
|
}
|
|
43
73
|
let parsed;
|
|
44
74
|
try {
|
|
@@ -55,13 +85,27 @@ async function graphqlInner(query, vars, opts) {
|
|
|
55
85
|
durationMs,
|
|
56
86
|
textBody: `Invalid JSON response${detail}`,
|
|
57
87
|
attempt: attempt > 1 ? attempt : undefined,
|
|
88
|
+
authSource,
|
|
89
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
90
|
+
retryAfterSeconds,
|
|
58
91
|
}));
|
|
92
|
+
recordApiTelemetry({
|
|
93
|
+
kind: "GraphQL",
|
|
94
|
+
method: "POST",
|
|
95
|
+
authSource,
|
|
96
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
97
|
+
});
|
|
59
98
|
throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
|
|
60
99
|
status: res.status,
|
|
61
|
-
rateLimit:
|
|
100
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
62
101
|
retryAfterSeconds,
|
|
102
|
+
authSource,
|
|
63
103
|
});
|
|
64
104
|
}
|
|
105
|
+
const parsedData = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
106
|
+
? parsed["data"]
|
|
107
|
+
: undefined;
|
|
108
|
+
const rateLimit = mergeGraphqlRateLimit(headerRateLimit, parsedData);
|
|
65
109
|
appendEntry(formatResponseEntry({
|
|
66
110
|
n,
|
|
67
111
|
kind: "GraphQL",
|
|
@@ -71,7 +115,16 @@ async function graphqlInner(query, vars, opts) {
|
|
|
71
115
|
durationMs,
|
|
72
116
|
body: parsed,
|
|
73
117
|
attempt: attempt > 1 ? attempt : undefined,
|
|
118
|
+
authSource,
|
|
119
|
+
rateLimit: rateLimit ?? undefined,
|
|
120
|
+
retryAfterSeconds,
|
|
74
121
|
}));
|
|
122
|
+
recordApiTelemetry({
|
|
123
|
+
kind: "GraphQL",
|
|
124
|
+
method: "POST",
|
|
125
|
+
authSource,
|
|
126
|
+
rateLimit: rateLimit ?? undefined,
|
|
127
|
+
});
|
|
75
128
|
const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
|
|
76
129
|
if (payload.data == null) {
|
|
77
130
|
const detail = formatGraphQlErrors(payload.errors);
|
|
@@ -80,6 +133,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
80
133
|
rateLimit: rateLimit ?? undefined,
|
|
81
134
|
retryAfterSeconds,
|
|
82
135
|
graphqlErrors: payload.errors,
|
|
136
|
+
authSource,
|
|
83
137
|
});
|
|
84
138
|
}
|
|
85
139
|
if (payload.errors?.length && !opts.allowPartialData) {
|
|
@@ -88,6 +142,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
88
142
|
rateLimit: rateLimit ?? undefined,
|
|
89
143
|
retryAfterSeconds,
|
|
90
144
|
graphqlErrors: payload.errors,
|
|
145
|
+
authSource,
|
|
91
146
|
});
|
|
92
147
|
}
|
|
93
148
|
if (payload.errors?.length) {
|
|
@@ -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
|
-
|
|
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
|
+
}>;
|
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`;
|