pr-shepherd 0.16.2 → 0.16.4
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/bin/checks/triage.test-support.mjs +61 -0
- package/bin/cli/iterate-lean.test-support.mjs +5 -0
- package/bin/cli-parser.commit-suggestion.test-support.mjs +66 -0
- package/bin/cli-parser.iterate-fix.test-support.mjs +48 -0
- package/bin/cli-parser.iterate.test-support.mjs +49 -0
- package/bin/cli-parser.test-support.mjs +43 -0
- package/bin/commands/check.mjs +14 -17
- package/bin/commands/check.test-support.mjs +140 -0
- package/bin/commands/commit-suggestion.apply.test-support.mjs +87 -0
- package/bin/commands/commit-suggestion.test-support.mjs +112 -0
- package/bin/commands/iterate/index.mjs +20 -16
- package/bin/commands/iterate-stall.test-support.mjs +25 -0
- package/bin/commands/iterate-test-support.mjs +149 -0
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +118 -0
- package/bin/commands/ready-mergeability.mjs +30 -0
- package/bin/commands/resolve.test-support.mjs +114 -0
- package/bin/commands/shepherd-journal.test-support.mjs +9 -0
- package/bin/comments/resolve.test-support.mjs +40 -0
- package/bin/github/batch-parsers.test-support.mjs +67 -0
- package/bin/github/batch.test-support.mjs +67 -0
- package/bin/github/graphql-http.mjs +73 -0
- package/bin/github/http-auth.mjs +48 -0
- package/bin/github/http-request.mjs +15 -0
- package/bin/github/http-utils.mjs +34 -0
- package/bin/github/http.mjs +4 -319
- package/bin/github/http.test-support.mjs +52 -0
- package/bin/github/rest-http.mjs +131 -0
- package/bin/suggestions/patch.test-support.mjs +4 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
vi.mock("./client.mts", () => ({
|
|
4
|
+
graphql: vi.fn(),
|
|
5
|
+
graphqlWithRateLimit: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { fetchPrBatch } from "./batch.mjs";
|
|
8
|
+
import { graphql, graphqlWithRateLimit } from "./client.mjs";
|
|
9
|
+
const mockGraphql = vi.mocked(graphql);
|
|
10
|
+
const mockGraphqlWithRateLimit = vi.mocked(graphqlWithRateLimit);
|
|
11
|
+
const REPO = { owner: "owner", name: "repo" };
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function makeRawPr(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
id: "PR_kgDOAAA",
|
|
18
|
+
number: 42,
|
|
19
|
+
state: "OPEN",
|
|
20
|
+
isDraft: false,
|
|
21
|
+
mergeable: "MERGEABLE",
|
|
22
|
+
mergeStateStatus: "CLEAN",
|
|
23
|
+
reviewDecision: "APPROVED",
|
|
24
|
+
headRefOid: "abc123",
|
|
25
|
+
headRefName: "feature",
|
|
26
|
+
headRepository: { nameWithOwner: "owner/repo" },
|
|
27
|
+
baseRefName: "main",
|
|
28
|
+
reviewRequests: { nodes: [] },
|
|
29
|
+
latestReviews: { nodes: [] },
|
|
30
|
+
reviewThreads: {
|
|
31
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
32
|
+
nodes: [],
|
|
33
|
+
},
|
|
34
|
+
comments: {
|
|
35
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
36
|
+
nodes: [],
|
|
37
|
+
},
|
|
38
|
+
changesRequestedReviews: {
|
|
39
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
40
|
+
nodes: [],
|
|
41
|
+
},
|
|
42
|
+
reviewSummaries: {
|
|
43
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
44
|
+
nodes: [],
|
|
45
|
+
},
|
|
46
|
+
approvedReviews: {
|
|
47
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
48
|
+
nodes: [],
|
|
49
|
+
},
|
|
50
|
+
commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function makeResponse(pr = makeRawPr()) {
|
|
55
|
+
return { data: { repository: { pullRequest: pr } } };
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// PR not found
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export function registerHooks() {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
mockGraphql.mockResolvedValue(makeResponse());
|
|
64
|
+
mockGraphqlWithRateLimit.mockResolvedValue(makeResponse());
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export { REPO, fetchPrBatch, graphql, graphqlWithRateLimit, makeRawPr, makeResponse, mockGraphql, mockGraphqlWithRateLimit, };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
vi.mock("./client.mts", () => ({
|
|
4
|
+
graphql: vi.fn(),
|
|
5
|
+
graphqlWithRateLimit: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { fetchPrBatch } from "./batch.mjs";
|
|
8
|
+
import { graphql, graphqlWithRateLimit } from "./client.mjs";
|
|
9
|
+
const mockGraphql = vi.mocked(graphql);
|
|
10
|
+
const mockGraphqlWithRateLimit = vi.mocked(graphqlWithRateLimit);
|
|
11
|
+
const REPO = { owner: "owner", name: "repo" };
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function makeRawPr(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
id: "PR_kgDOAAA",
|
|
18
|
+
number: 42,
|
|
19
|
+
state: "OPEN",
|
|
20
|
+
isDraft: false,
|
|
21
|
+
mergeable: "MERGEABLE",
|
|
22
|
+
mergeStateStatus: "CLEAN",
|
|
23
|
+
reviewDecision: "APPROVED",
|
|
24
|
+
headRefOid: "abc123",
|
|
25
|
+
headRefName: "feature",
|
|
26
|
+
headRepository: { nameWithOwner: "owner/repo" },
|
|
27
|
+
baseRefName: "main",
|
|
28
|
+
reviewRequests: { nodes: [] },
|
|
29
|
+
latestReviews: { nodes: [] },
|
|
30
|
+
reviewThreads: {
|
|
31
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
32
|
+
nodes: [],
|
|
33
|
+
},
|
|
34
|
+
comments: {
|
|
35
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
36
|
+
nodes: [],
|
|
37
|
+
},
|
|
38
|
+
changesRequestedReviews: {
|
|
39
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
40
|
+
nodes: [],
|
|
41
|
+
},
|
|
42
|
+
reviewSummaries: {
|
|
43
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
44
|
+
nodes: [],
|
|
45
|
+
},
|
|
46
|
+
approvedReviews: {
|
|
47
|
+
pageInfo: { hasPreviousPage: false, startCursor: null },
|
|
48
|
+
nodes: [],
|
|
49
|
+
},
|
|
50
|
+
commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function makeResponse(pr = makeRawPr()) {
|
|
55
|
+
return { data: { repository: { pullRequest: pr } } };
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// reviewSummaries — COMMENTED reviews surfaced for agent-driven minimize
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export function registerHooks() {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
mockGraphql.mockResolvedValue(makeResponse());
|
|
64
|
+
mockGraphqlWithRateLimit.mockResolvedValue(makeResponse());
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export { REPO, fetchPrBatch, graphql, graphqlWithRateLimit, makeRawPr, makeResponse, mockGraphql, mockGraphqlWithRateLimit, };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { appendEntry, nextEntry } from "../log/log-file.mjs";
|
|
2
|
+
import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
3
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
4
|
+
import { makeHeaders } from "./http-auth.mjs";
|
|
5
|
+
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
6
|
+
import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
|
|
7
|
+
const BASE_URL = "https://api.github.com";
|
|
8
|
+
async function graphqlInner(query, vars) {
|
|
9
|
+
const url = `${BASE_URL}/graphql`;
|
|
10
|
+
const n = nextEntry();
|
|
11
|
+
appendEntry(formatRequestEntry({
|
|
12
|
+
n,
|
|
13
|
+
kind: "GraphQL",
|
|
14
|
+
method: "POST",
|
|
15
|
+
url,
|
|
16
|
+
body: { query, variables: vars },
|
|
17
|
+
}));
|
|
18
|
+
const t0 = performance.now();
|
|
19
|
+
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: await makeHeaders(),
|
|
22
|
+
body: JSON.stringify({ query, variables: vars }),
|
|
23
|
+
}), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "GraphQL", method: "POST", url, status, durationMs })));
|
|
24
|
+
const durationMs = Math.round(performance.now() - retryT0);
|
|
25
|
+
const rateLimit = parseRateLimit(res.headers);
|
|
26
|
+
const retryAfterSeconds = parseRetryAfter(res.headers);
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const body = await res.text();
|
|
29
|
+
appendEntry(formatResponseEntry({
|
|
30
|
+
n,
|
|
31
|
+
kind: "GraphQL",
|
|
32
|
+
method: "POST",
|
|
33
|
+
url,
|
|
34
|
+
status: res.status,
|
|
35
|
+
durationMs,
|
|
36
|
+
textBody: redactToken(body),
|
|
37
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
38
|
+
}));
|
|
39
|
+
throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
|
|
40
|
+
}
|
|
41
|
+
const parsed = (await res.json());
|
|
42
|
+
appendEntry(formatResponseEntry({
|
|
43
|
+
n,
|
|
44
|
+
kind: "GraphQL",
|
|
45
|
+
method: "POST",
|
|
46
|
+
url,
|
|
47
|
+
status: res.status,
|
|
48
|
+
durationMs,
|
|
49
|
+
body: parsed,
|
|
50
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
51
|
+
}));
|
|
52
|
+
if (parsed.data == null) {
|
|
53
|
+
const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
|
|
54
|
+
throw new GitHubRequestError(`GitHub GraphQL error (no data): ${messages}`, {
|
|
55
|
+
status: res.status,
|
|
56
|
+
rateLimit: rateLimit ?? undefined,
|
|
57
|
+
retryAfterSeconds,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (parsed.errors?.length) {
|
|
61
|
+
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
62
|
+
process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
|
|
63
|
+
}
|
|
64
|
+
return { data: parsed.data, rateLimit, retryAfterSeconds, errors: parsed.errors };
|
|
65
|
+
}
|
|
66
|
+
export async function graphql(query, vars = {}) {
|
|
67
|
+
const { data } = await graphqlInner(query, vars);
|
|
68
|
+
return { data };
|
|
69
|
+
}
|
|
70
|
+
export async function graphqlWithRateLimit(query, vars = {}) {
|
|
71
|
+
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
|
|
72
|
+
return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
|
|
73
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFile = promisify(execFileCb);
|
|
4
|
+
let _token;
|
|
5
|
+
export function _resetTokenCache() {
|
|
6
|
+
_token = undefined;
|
|
7
|
+
}
|
|
8
|
+
export function hasCachedToken() {
|
|
9
|
+
return _token !== undefined;
|
|
10
|
+
}
|
|
11
|
+
export function clearTokenCache() {
|
|
12
|
+
_token = undefined;
|
|
13
|
+
}
|
|
14
|
+
async function resolveToken() {
|
|
15
|
+
if (_token)
|
|
16
|
+
return _token;
|
|
17
|
+
const envToken = process.env["GH_TOKEN"] ?? process.env["GITHUB_TOKEN"];
|
|
18
|
+
if (envToken) {
|
|
19
|
+
_token = envToken;
|
|
20
|
+
return _token;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execFile("gh", ["auth", "token"]);
|
|
24
|
+
const token = stdout.trim();
|
|
25
|
+
if (token) {
|
|
26
|
+
_token = token;
|
|
27
|
+
return _token;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// fall through to error
|
|
32
|
+
}
|
|
33
|
+
const codexToken = process.env["GITHUB_PERSONAL_ACCESS_TOKEN"];
|
|
34
|
+
if (codexToken) {
|
|
35
|
+
_token = codexToken;
|
|
36
|
+
return _token;
|
|
37
|
+
}
|
|
38
|
+
throw new Error("No GitHub token found. Set GH_TOKEN, GITHUB_TOKEN, or GITHUB_PERSONAL_ACCESS_TOKEN, or run `gh auth login`.");
|
|
39
|
+
}
|
|
40
|
+
export async function makeHeaders() {
|
|
41
|
+
return {
|
|
42
|
+
Authorization: `Bearer ${await resolveToken()}`,
|
|
43
|
+
Accept: "application/vnd.github+json",
|
|
44
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
45
|
+
"User-Agent": "pr-shepherd",
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { clearTokenCache, hasCachedToken } from "./http-auth.mjs";
|
|
2
|
+
export async function requestWithTokenRetry(fn, t0, onIntermediate) {
|
|
3
|
+
const res = await fn();
|
|
4
|
+
if (res.status === 401 && hasCachedToken()) {
|
|
5
|
+
onIntermediate?.(401, Math.round(performance.now() - t0));
|
|
6
|
+
try {
|
|
7
|
+
await res.arrayBuffer();
|
|
8
|
+
}
|
|
9
|
+
catch { }
|
|
10
|
+
clearTokenCache();
|
|
11
|
+
const retryT0 = performance.now();
|
|
12
|
+
return { res: await fn(), attempt: 2, retryT0 };
|
|
13
|
+
}
|
|
14
|
+
return { res, attempt: 1, retryT0: t0 };
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function sanitizeBody(body) {
|
|
2
|
+
return body.replace(/Bearer\s+\S+/gi, "[REDACTED]").slice(0, 200);
|
|
3
|
+
}
|
|
4
|
+
export function redactToken(body) {
|
|
5
|
+
return body.replace(/Bearer\s+\S+/gi, "[REDACTED]");
|
|
6
|
+
}
|
|
7
|
+
export function redactUrl(url) {
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(url);
|
|
10
|
+
return `${u.origin}${u.pathname}`;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return url;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function parseRateLimit(headers) {
|
|
17
|
+
const rRaw = headers.get("x-ratelimit-remaining");
|
|
18
|
+
const lRaw = headers.get("x-ratelimit-limit");
|
|
19
|
+
const tRaw = headers.get("x-ratelimit-reset");
|
|
20
|
+
if (rRaw === null || lRaw === null || tRaw === null)
|
|
21
|
+
return null;
|
|
22
|
+
const remaining = Number(rRaw);
|
|
23
|
+
const limit = Number(lRaw);
|
|
24
|
+
const resetAt = Number(tRaw);
|
|
25
|
+
if (Number.isFinite(remaining) && Number.isFinite(limit) && Number.isFinite(resetAt)) {
|
|
26
|
+
return { remaining, limit, resetAt };
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function parseRetryAfter(headers) {
|
|
31
|
+
const raw = headers.get("retry-after");
|
|
32
|
+
const seconds = Number(raw);
|
|
33
|
+
return raw !== null && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
|
|
34
|
+
}
|
package/bin/github/http.mjs
CHANGED
|
@@ -1,319 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { GitHubRequestError } from "./errors.mjs";
|
|
6
|
-
export { GitHubRequestError };
|
|
7
|
-
const execFile = promisify(execFileCb);
|
|
8
|
-
const BASE_URL = "https://api.github.com";
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Auth
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
let _token;
|
|
13
|
-
export function _resetTokenCache() {
|
|
14
|
-
_token = undefined;
|
|
15
|
-
}
|
|
16
|
-
async function resolveToken() {
|
|
17
|
-
if (_token)
|
|
18
|
-
return _token;
|
|
19
|
-
const envToken = process.env["GH_TOKEN"] ?? process.env["GITHUB_TOKEN"];
|
|
20
|
-
if (envToken) {
|
|
21
|
-
_token = envToken;
|
|
22
|
-
return _token;
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const { stdout } = await execFile("gh", ["auth", "token"]);
|
|
26
|
-
const token = stdout.trim();
|
|
27
|
-
if (token) {
|
|
28
|
-
_token = token;
|
|
29
|
-
return _token;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
// fall through to error
|
|
34
|
-
}
|
|
35
|
-
const codexToken = process.env["GITHUB_PERSONAL_ACCESS_TOKEN"];
|
|
36
|
-
if (codexToken) {
|
|
37
|
-
_token = codexToken;
|
|
38
|
-
return _token;
|
|
39
|
-
}
|
|
40
|
-
throw new Error("No GitHub token found. Set GH_TOKEN, GITHUB_TOKEN, or GITHUB_PERSONAL_ACCESS_TOKEN, or run `gh auth login`.");
|
|
41
|
-
}
|
|
42
|
-
async function makeHeaders() {
|
|
43
|
-
return {
|
|
44
|
-
Authorization: `Bearer ${await resolveToken()}`,
|
|
45
|
-
Accept: "application/vnd.github+json",
|
|
46
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
47
|
-
"User-Agent": "pr-shepherd",
|
|
48
|
-
"Content-Type": "application/json",
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
function sanitizeBody(body) {
|
|
52
|
-
return body.replace(/Bearer\s+\S+/gi, "[REDACTED]").slice(0, 200);
|
|
53
|
-
}
|
|
54
|
-
function redactToken(body) {
|
|
55
|
-
return body.replace(/Bearer\s+\S+/gi, "[REDACTED]");
|
|
56
|
-
}
|
|
57
|
-
function redactUrl(url) {
|
|
58
|
-
try {
|
|
59
|
-
const u = new URL(url);
|
|
60
|
-
return `${u.origin}${u.pathname}`;
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return url;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
async function requestWithTokenRetry(fn, t0, onIntermediate) {
|
|
67
|
-
const res = await fn();
|
|
68
|
-
if (res.status === 401 && _token !== undefined) {
|
|
69
|
-
onIntermediate?.(401, Math.round(performance.now() - t0));
|
|
70
|
-
try {
|
|
71
|
-
await res.arrayBuffer();
|
|
72
|
-
}
|
|
73
|
-
catch { }
|
|
74
|
-
_token = undefined;
|
|
75
|
-
const retryT0 = performance.now();
|
|
76
|
-
return { res: await fn(), attempt: 2, retryT0 };
|
|
77
|
-
}
|
|
78
|
-
return { res, attempt: 1, retryT0: t0 };
|
|
79
|
-
}
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// GraphQL
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
async function graphqlInner(query, vars) {
|
|
84
|
-
const url = `${BASE_URL}/graphql`;
|
|
85
|
-
const n = nextEntry();
|
|
86
|
-
appendEntry(formatRequestEntry({
|
|
87
|
-
n,
|
|
88
|
-
kind: "GraphQL",
|
|
89
|
-
method: "POST",
|
|
90
|
-
url,
|
|
91
|
-
body: { query, variables: vars },
|
|
92
|
-
}));
|
|
93
|
-
const t0 = performance.now();
|
|
94
|
-
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
|
|
95
|
-
method: "POST",
|
|
96
|
-
headers: await makeHeaders(),
|
|
97
|
-
body: JSON.stringify({ query, variables: vars }),
|
|
98
|
-
}), t0, (status, firstDurationMs) => {
|
|
99
|
-
appendEntry(formatResponseEntry({
|
|
100
|
-
n,
|
|
101
|
-
kind: "GraphQL",
|
|
102
|
-
method: "POST",
|
|
103
|
-
url,
|
|
104
|
-
status,
|
|
105
|
-
durationMs: firstDurationMs,
|
|
106
|
-
}));
|
|
107
|
-
});
|
|
108
|
-
const durationMs = Math.round(performance.now() - retryT0);
|
|
109
|
-
const rateLimit = parseRateLimit(res.headers);
|
|
110
|
-
const retryAfterSeconds = parseRetryAfter(res.headers);
|
|
111
|
-
if (!res.ok) {
|
|
112
|
-
const body = await res.text();
|
|
113
|
-
appendEntry(formatResponseEntry({
|
|
114
|
-
n,
|
|
115
|
-
kind: "GraphQL",
|
|
116
|
-
method: "POST",
|
|
117
|
-
url,
|
|
118
|
-
status: res.status,
|
|
119
|
-
durationMs,
|
|
120
|
-
textBody: redactToken(body),
|
|
121
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
122
|
-
}));
|
|
123
|
-
throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
|
|
124
|
-
}
|
|
125
|
-
const parsed = (await res.json());
|
|
126
|
-
appendEntry(formatResponseEntry({
|
|
127
|
-
n,
|
|
128
|
-
kind: "GraphQL",
|
|
129
|
-
method: "POST",
|
|
130
|
-
url,
|
|
131
|
-
status: res.status,
|
|
132
|
-
durationMs,
|
|
133
|
-
body: parsed,
|
|
134
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
135
|
-
}));
|
|
136
|
-
if (parsed.data == null) {
|
|
137
|
-
const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
|
|
138
|
-
throw new GitHubRequestError(`GitHub GraphQL error (no data): ${messages}`, {
|
|
139
|
-
status: res.status,
|
|
140
|
-
rateLimit: rateLimit ?? undefined,
|
|
141
|
-
retryAfterSeconds,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
if (parsed.errors?.length) {
|
|
145
|
-
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
146
|
-
process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
|
|
147
|
-
}
|
|
148
|
-
return { data: parsed.data, rateLimit, retryAfterSeconds, errors: parsed.errors };
|
|
149
|
-
}
|
|
150
|
-
export async function graphql(query, vars = {}) {
|
|
151
|
-
const { data } = await graphqlInner(query, vars);
|
|
152
|
-
return { data };
|
|
153
|
-
}
|
|
154
|
-
export async function graphqlWithRateLimit(query, vars = {}) {
|
|
155
|
-
const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
|
|
156
|
-
return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
|
|
157
|
-
}
|
|
158
|
-
// ---------------------------------------------------------------------------
|
|
159
|
-
// REST
|
|
160
|
-
// ---------------------------------------------------------------------------
|
|
161
|
-
export async function rest(method, path, body) {
|
|
162
|
-
const url = `${BASE_URL}${path}`;
|
|
163
|
-
const n = nextEntry();
|
|
164
|
-
appendEntry(formatRequestEntry({ n, kind: "REST", method, url, body }));
|
|
165
|
-
const t0 = performance.now();
|
|
166
|
-
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
|
|
167
|
-
method,
|
|
168
|
-
headers: await makeHeaders(),
|
|
169
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
170
|
-
}), t0, (status, firstDurationMs) => {
|
|
171
|
-
appendEntry(formatResponseEntry({ n, kind: "REST", method, url, status, durationMs: firstDurationMs }));
|
|
172
|
-
});
|
|
173
|
-
const durationMs = Math.round(performance.now() - retryT0);
|
|
174
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
175
|
-
if (!res.ok) {
|
|
176
|
-
const text = await res.text();
|
|
177
|
-
appendEntry(formatResponseEntry({
|
|
178
|
-
n,
|
|
179
|
-
kind: "REST",
|
|
180
|
-
method,
|
|
181
|
-
url,
|
|
182
|
-
status: res.status,
|
|
183
|
-
durationMs,
|
|
184
|
-
textBody: redactToken(text),
|
|
185
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
186
|
-
}));
|
|
187
|
-
throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`);
|
|
188
|
-
}
|
|
189
|
-
if (ct.includes("application/json")) {
|
|
190
|
-
const json = (await res.json());
|
|
191
|
-
appendEntry(formatResponseEntry({
|
|
192
|
-
n,
|
|
193
|
-
kind: "REST",
|
|
194
|
-
method,
|
|
195
|
-
url,
|
|
196
|
-
status: res.status,
|
|
197
|
-
durationMs,
|
|
198
|
-
contentType: ct,
|
|
199
|
-
body: json,
|
|
200
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
201
|
-
}));
|
|
202
|
-
return json;
|
|
203
|
-
}
|
|
204
|
-
appendEntry(formatResponseEntry({
|
|
205
|
-
n,
|
|
206
|
-
kind: "REST",
|
|
207
|
-
method,
|
|
208
|
-
url,
|
|
209
|
-
status: res.status,
|
|
210
|
-
durationMs,
|
|
211
|
-
contentType: ct || undefined,
|
|
212
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
213
|
-
}));
|
|
214
|
-
return undefined;
|
|
215
|
-
}
|
|
216
|
-
export async function restText(path) {
|
|
217
|
-
const url = `${BASE_URL}${path}`;
|
|
218
|
-
const n = nextEntry();
|
|
219
|
-
appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
|
|
220
|
-
const t0 = performance.now();
|
|
221
|
-
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
|
|
222
|
-
method: "GET",
|
|
223
|
-
headers: await makeHeaders(),
|
|
224
|
-
redirect: "manual",
|
|
225
|
-
}), t0, (status, firstDurationMs) => {
|
|
226
|
-
appendEntry(formatResponseEntry({
|
|
227
|
-
n,
|
|
228
|
-
kind: "restText",
|
|
229
|
-
method: "GET",
|
|
230
|
-
url,
|
|
231
|
-
status,
|
|
232
|
-
durationMs: firstDurationMs,
|
|
233
|
-
}));
|
|
234
|
-
});
|
|
235
|
-
const durationMs = Math.round(performance.now() - retryT0);
|
|
236
|
-
if (res.status === 301 || res.status === 302 || res.status === 307 || res.status === 308) {
|
|
237
|
-
appendEntry(formatResponseEntry({
|
|
238
|
-
n,
|
|
239
|
-
kind: "restText",
|
|
240
|
-
method: "GET",
|
|
241
|
-
url,
|
|
242
|
-
status: res.status,
|
|
243
|
-
durationMs,
|
|
244
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
245
|
-
}));
|
|
246
|
-
const location = res.headers.get("location");
|
|
247
|
-
if (location) {
|
|
248
|
-
const n2 = nextEntry();
|
|
249
|
-
const logUrl = redactUrl(location);
|
|
250
|
-
appendEntry(formatRequestEntry({ n: n2, kind: "restText", method: "GET", url: logUrl }));
|
|
251
|
-
const t1 = performance.now();
|
|
252
|
-
const redirectRes = await fetch(location);
|
|
253
|
-
const duration2 = Math.round(performance.now() - t1);
|
|
254
|
-
const clRaw = redirectRes.headers.get("content-length");
|
|
255
|
-
const contentLength = clRaw !== null && Number.isFinite(Number(clRaw)) ? Number(clRaw) : undefined;
|
|
256
|
-
appendEntry(formatResponseEntry({
|
|
257
|
-
n: n2,
|
|
258
|
-
kind: "restText",
|
|
259
|
-
method: "GET",
|
|
260
|
-
url: logUrl,
|
|
261
|
-
status: redirectRes.status,
|
|
262
|
-
durationMs: duration2,
|
|
263
|
-
contentLength,
|
|
264
|
-
}));
|
|
265
|
-
if (!redirectRes.ok) {
|
|
266
|
-
throw new Error(`redirect target ${location} failed: ${redirectRes.status}`);
|
|
267
|
-
}
|
|
268
|
-
return redirectRes.text();
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
if (!res.ok) {
|
|
272
|
-
const text = await res.text();
|
|
273
|
-
appendEntry(formatResponseEntry({
|
|
274
|
-
n,
|
|
275
|
-
kind: "restText",
|
|
276
|
-
method: "GET",
|
|
277
|
-
url,
|
|
278
|
-
status: res.status,
|
|
279
|
-
durationMs,
|
|
280
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
281
|
-
}));
|
|
282
|
-
throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`);
|
|
283
|
-
}
|
|
284
|
-
const clRaw = res.headers.get("content-length");
|
|
285
|
-
const contentLength = clRaw !== null && Number.isFinite(Number(clRaw)) ? Number(clRaw) : undefined;
|
|
286
|
-
appendEntry(formatResponseEntry({
|
|
287
|
-
n,
|
|
288
|
-
kind: "restText",
|
|
289
|
-
method: "GET",
|
|
290
|
-
url,
|
|
291
|
-
status: res.status,
|
|
292
|
-
durationMs,
|
|
293
|
-
contentLength,
|
|
294
|
-
attempt: attempt > 1 ? attempt : undefined,
|
|
295
|
-
}));
|
|
296
|
-
return res.text();
|
|
297
|
-
}
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
// Helpers
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
function parseRateLimit(headers) {
|
|
302
|
-
const rRaw = headers.get("x-ratelimit-remaining");
|
|
303
|
-
const lRaw = headers.get("x-ratelimit-limit");
|
|
304
|
-
const tRaw = headers.get("x-ratelimit-reset");
|
|
305
|
-
if (rRaw === null || lRaw === null || tRaw === null)
|
|
306
|
-
return null;
|
|
307
|
-
const remaining = Number(rRaw);
|
|
308
|
-
const limit = Number(lRaw);
|
|
309
|
-
const resetAt = Number(tRaw);
|
|
310
|
-
if (Number.isFinite(remaining) && Number.isFinite(limit) && Number.isFinite(resetAt)) {
|
|
311
|
-
return { remaining, limit, resetAt };
|
|
312
|
-
}
|
|
313
|
-
return null;
|
|
314
|
-
}
|
|
315
|
-
function parseRetryAfter(headers) {
|
|
316
|
-
const raw = headers.get("retry-after");
|
|
317
|
-
const seconds = Number(raw);
|
|
318
|
-
return raw !== null && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
|
|
319
|
-
}
|
|
1
|
+
export { GitHubRequestError } from "./errors.mjs";
|
|
2
|
+
export { _resetTokenCache } from "./http-auth.mjs";
|
|
3
|
+
export { graphql, graphqlWithRateLimit } from "./graphql-http.mjs";
|
|
4
|
+
export { rest, restText } from "./rest-http.mjs";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Stub fetch and child_process globally before any imports.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
const mockFetch = vi.fn();
|
|
7
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
8
|
+
const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
|
|
9
|
+
vi.mock("node:child_process", () => ({
|
|
10
|
+
execFile: (cmd, args, optsOrCb, maybeCb) => {
|
|
11
|
+
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
12
|
+
mockExecFile(cmd, args)
|
|
13
|
+
.then((result) => cb(null, result))
|
|
14
|
+
.catch((err) => cb(err, { stdout: "", stderr: "" }));
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
import { GitHubRequestError, graphql, graphqlWithRateLimit, rest, restText, _resetTokenCache, } from "./http.mjs";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function jsonOk(data) {
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
26
|
+
json: () => Promise.resolve(data),
|
|
27
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function gqlOk(data) {
|
|
31
|
+
return {
|
|
32
|
+
ok: true,
|
|
33
|
+
status: 200,
|
|
34
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
35
|
+
json: () => Promise.resolve({ data }),
|
|
36
|
+
text: () => Promise.resolve(JSON.stringify({ data })),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Token resolution
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
export function registerHooks() {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mockFetch.mockReset();
|
|
45
|
+
mockExecFile.mockReset();
|
|
46
|
+
_resetTokenCache();
|
|
47
|
+
delete process.env["GH_TOKEN"];
|
|
48
|
+
delete process.env["GITHUB_TOKEN"];
|
|
49
|
+
delete process.env["GITHUB_PERSONAL_ACCESS_TOKEN"];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export { GitHubRequestError, _resetTokenCache, gqlOk, graphql, graphqlWithRateLimit, jsonOk, mockExecFile, mockFetch, rest, restText, };
|