pr-shepherd 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/exit-codes.d.mts +1 -1
- package/bin/exit-codes.mjs +1 -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 +8 -0
- package/bin/github/errors.mjs +29 -1
- 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 +66 -10
- package/bin/github/graphql-internal-retry.d.mts +2 -0
- package/bin/github/graphql-internal-retry.mjs +50 -0
- package/bin/github/graphql-response.mjs +11 -1
- 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
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { appendEntry, nextEntry } from "../log/log-file.mjs";
|
|
2
3
|
import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
3
4
|
import { GitHubRequestError } from "./errors.mjs";
|
|
5
|
+
import { withGraphQlInternalRetry } from "./graphql-internal-retry.mjs";
|
|
4
6
|
import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
|
|
5
|
-
import {
|
|
7
|
+
import { makeAuthHeaders } from "./http-auth.mjs";
|
|
6
8
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
7
9
|
import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
|
|
10
|
+
import { mergeGraphqlRateLimit, recordApiTelemetry } from "./api-telemetry.mjs";
|
|
11
|
+
import { recordIntermediateResponse } from "./http-intermediate.mjs";
|
|
8
12
|
const BASE_URL = "https://api.github.com";
|
|
9
13
|
async function graphqlInner(query, vars, opts) {
|
|
10
14
|
const url = `${BASE_URL}/graphql`;
|
|
@@ -17,13 +21,26 @@ async function graphqlInner(query, vars, opts) {
|
|
|
17
21
|
body: { query, variables: vars },
|
|
18
22
|
}));
|
|
19
23
|
const t0 = performance.now();
|
|
20
|
-
|
|
24
|
+
let authSource = "unknown";
|
|
25
|
+
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
|
|
26
|
+
const auth = await makeAuthHeaders();
|
|
27
|
+
authSource = auth.source;
|
|
28
|
+
return fetch(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: auth.headers,
|
|
31
|
+
body: JSON.stringify({ query, variables: vars }),
|
|
32
|
+
});
|
|
33
|
+
}, t0, (response, durationMs) => recordIntermediateResponse({
|
|
34
|
+
n,
|
|
35
|
+
kind: "GraphQL",
|
|
21
36
|
method: "POST",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
url,
|
|
38
|
+
response,
|
|
39
|
+
durationMs,
|
|
40
|
+
authSource,
|
|
41
|
+
}));
|
|
25
42
|
const durationMs = Math.round(performance.now() - retryT0);
|
|
26
|
-
const
|
|
43
|
+
const headerRateLimit = parseRateLimit(res.headers);
|
|
27
44
|
const retryAfterSeconds = parseRetryAfter(res.headers);
|
|
28
45
|
if (!res.ok) {
|
|
29
46
|
const body = await res.text();
|
|
@@ -36,8 +53,22 @@ async function graphqlInner(query, vars, opts) {
|
|
|
36
53
|
durationMs,
|
|
37
54
|
textBody: redactToken(body),
|
|
38
55
|
attempt: attempt > 1 ? attempt : undefined,
|
|
56
|
+
authSource,
|
|
57
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
58
|
+
retryAfterSeconds,
|
|
39
59
|
}));
|
|
40
|
-
|
|
60
|
+
recordApiTelemetry({
|
|
61
|
+
kind: "GraphQL",
|
|
62
|
+
method: "POST",
|
|
63
|
+
authSource,
|
|
64
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
65
|
+
});
|
|
66
|
+
throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, {
|
|
67
|
+
status: res.status,
|
|
68
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
69
|
+
retryAfterSeconds,
|
|
70
|
+
authSource,
|
|
71
|
+
});
|
|
41
72
|
}
|
|
42
73
|
let parsed;
|
|
43
74
|
try {
|
|
@@ -54,13 +85,27 @@ async function graphqlInner(query, vars, opts) {
|
|
|
54
85
|
durationMs,
|
|
55
86
|
textBody: `Invalid JSON response${detail}`,
|
|
56
87
|
attempt: attempt > 1 ? attempt : undefined,
|
|
88
|
+
authSource,
|
|
89
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
90
|
+
retryAfterSeconds,
|
|
57
91
|
}));
|
|
92
|
+
recordApiTelemetry({
|
|
93
|
+
kind: "GraphQL",
|
|
94
|
+
method: "POST",
|
|
95
|
+
authSource,
|
|
96
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
97
|
+
});
|
|
58
98
|
throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
|
|
59
99
|
status: res.status,
|
|
60
|
-
rateLimit:
|
|
100
|
+
rateLimit: headerRateLimit ?? undefined,
|
|
61
101
|
retryAfterSeconds,
|
|
102
|
+
authSource,
|
|
62
103
|
});
|
|
63
104
|
}
|
|
105
|
+
const parsedData = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
106
|
+
? parsed["data"]
|
|
107
|
+
: undefined;
|
|
108
|
+
const rateLimit = mergeGraphqlRateLimit(headerRateLimit, parsedData);
|
|
64
109
|
appendEntry(formatResponseEntry({
|
|
65
110
|
n,
|
|
66
111
|
kind: "GraphQL",
|
|
@@ -70,7 +115,16 @@ async function graphqlInner(query, vars, opts) {
|
|
|
70
115
|
durationMs,
|
|
71
116
|
body: parsed,
|
|
72
117
|
attempt: attempt > 1 ? attempt : undefined,
|
|
118
|
+
authSource,
|
|
119
|
+
rateLimit: rateLimit ?? undefined,
|
|
120
|
+
retryAfterSeconds,
|
|
73
121
|
}));
|
|
122
|
+
recordApiTelemetry({
|
|
123
|
+
kind: "GraphQL",
|
|
124
|
+
method: "POST",
|
|
125
|
+
authSource,
|
|
126
|
+
rateLimit: rateLimit ?? undefined,
|
|
127
|
+
});
|
|
74
128
|
const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
|
|
75
129
|
if (payload.data == null) {
|
|
76
130
|
const detail = formatGraphQlErrors(payload.errors);
|
|
@@ -79,6 +133,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
79
133
|
rateLimit: rateLimit ?? undefined,
|
|
80
134
|
retryAfterSeconds,
|
|
81
135
|
graphqlErrors: payload.errors,
|
|
136
|
+
authSource,
|
|
82
137
|
});
|
|
83
138
|
}
|
|
84
139
|
if (payload.errors?.length && !opts.allowPartialData) {
|
|
@@ -87,6 +142,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
87
142
|
rateLimit: rateLimit ?? undefined,
|
|
88
143
|
retryAfterSeconds,
|
|
89
144
|
graphqlErrors: payload.errors,
|
|
145
|
+
authSource,
|
|
90
146
|
});
|
|
91
147
|
}
|
|
92
148
|
if (payload.errors?.length) {
|
|
@@ -96,10 +152,10 @@ async function graphqlInner(query, vars, opts) {
|
|
|
96
152
|
return { data: payload.data, rateLimit, retryAfterSeconds, errors: payload.errors };
|
|
97
153
|
}
|
|
98
154
|
export async function graphql(query, vars = {}, opts = {}) {
|
|
99
|
-
const { data, errors } = await graphqlInner(query, vars, opts);
|
|
155
|
+
const { data, errors } = await withGraphQlInternalRetry(query, () => graphqlInner(query, vars, opts));
|
|
100
156
|
return { data, errors };
|
|
101
157
|
}
|
|
102
158
|
export async function graphqlWithRateLimit(query, vars = {}, opts = {}) {
|
|
103
|
-
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars, opts);
|
|
159
|
+
const { data, rateLimit, retryAfterSeconds, errors } = await withGraphQlInternalRetry(query, () => graphqlInner(query, vars, opts));
|
|
104
160
|
return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
|
|
105
161
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { sleep } from "../util/sleep.mjs";
|
|
2
|
+
import { GitHubRequestError, isRetryableGraphQlInternal } from "./errors.mjs";
|
|
3
|
+
const GRAPHQL_INTERNAL_RETRY_DELAYS = [500, 1500];
|
|
4
|
+
/** True when the root operation is a mutation. Anonymous `{...}` is a query. */
|
|
5
|
+
function isGraphQlMutationDocument(document) {
|
|
6
|
+
let rest = document;
|
|
7
|
+
for (;;) {
|
|
8
|
+
rest = rest.replace(/^\s+/, "");
|
|
9
|
+
if (rest.startsWith("#")) {
|
|
10
|
+
const nl = rest.indexOf("\n");
|
|
11
|
+
rest = nl === -1 ? "" : rest.slice(nl + 1);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (rest.startsWith('"""')) {
|
|
15
|
+
const end = rest.indexOf('"""', 3);
|
|
16
|
+
rest = end === -1 ? "" : rest.slice(end + 3);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
return /^mutation\b/i.test(rest);
|
|
22
|
+
}
|
|
23
|
+
function hasServerBackoff(err) {
|
|
24
|
+
if (err.retryAfterSeconds !== undefined)
|
|
25
|
+
return true;
|
|
26
|
+
return err.rateLimit !== undefined && err.rateLimit.remaining <= 0;
|
|
27
|
+
}
|
|
28
|
+
/** Retry GitHub GraphQL engine crashes (HTTP 200, data: null, INTERNAL) on reads. */
|
|
29
|
+
export async function withGraphQlInternalRetry(document, run) {
|
|
30
|
+
let lastErr;
|
|
31
|
+
for (let attempt = 1; attempt <= GRAPHQL_INTERNAL_RETRY_DELAYS.length + 1; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
return await run();
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (!(err instanceof GitHubRequestError) ||
|
|
37
|
+
!isRetryableGraphQlInternal(err.graphqlErrors) ||
|
|
38
|
+
isGraphQlMutationDocument(document) ||
|
|
39
|
+
hasServerBackoff(err)) {
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
lastErr = err;
|
|
43
|
+
const delay = GRAPHQL_INTERNAL_RETRY_DELAYS[attempt - 1];
|
|
44
|
+
if (delay === undefined)
|
|
45
|
+
break;
|
|
46
|
+
await sleep(delay);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw lastErr;
|
|
50
|
+
}
|
|
@@ -13,7 +13,7 @@ export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds
|
|
|
13
13
|
typeof error["message"] === "string")) {
|
|
14
14
|
throw malformedGraphQlResponse("errors field is not an array of GraphQL errors", status, rateLimit, retryAfterSeconds);
|
|
15
15
|
}
|
|
16
|
-
errors = record["errors"];
|
|
16
|
+
errors = record["errors"].map((error) => parseGraphQlError(error));
|
|
17
17
|
}
|
|
18
18
|
if (!("data" in record)) {
|
|
19
19
|
if (errors?.length)
|
|
@@ -26,6 +26,16 @@ export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds
|
|
|
26
26
|
}
|
|
27
27
|
return { data: record["data"] ?? null, errors };
|
|
28
28
|
}
|
|
29
|
+
function parseGraphQlError(error) {
|
|
30
|
+
const parsed = { message: error["message"] };
|
|
31
|
+
if ("path" in error)
|
|
32
|
+
parsed.path = error["path"];
|
|
33
|
+
if (typeof error["type"] === "string")
|
|
34
|
+
parsed.type = error["type"];
|
|
35
|
+
if (error["extensions"] !== undefined)
|
|
36
|
+
parsed.extensions = error["extensions"];
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
29
39
|
export function formatGraphQlErrors(errors) {
|
|
30
40
|
return (errors ?? [])
|
|
31
41
|
.map((error) => {
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
export type AuthSource = "GH_TOKEN" | "GITHUB_TOKEN" | "gh auth token" | "GITHUB_PERSONAL_ACCESS_TOKEN";
|
|
1
2
|
export declare function _resetTokenCache(): void;
|
|
2
3
|
export declare function hasCachedToken(): boolean;
|
|
3
4
|
export declare function clearTokenCache(): void;
|
|
4
|
-
|
|
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));
|