pr-shepherd 0.16.3 → 0.17.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 +16 -2
- package/bin/checks/triage.test-support.mjs +61 -0
- package/bin/cli/clean-formatter.mjs +20 -0
- package/bin/cli/fix-formatter.mjs +3 -10
- package/bin/cli/formatters.mjs +6 -10
- package/bin/cli/handlers.mjs +57 -1
- package/bin/cli/iterate-instructions.mjs +4 -2
- package/bin/cli/iterate-lean.test-support.mjs +5 -0
- package/bin/cli/list-formatters.mjs +21 -1
- package/bin/cli/runner.mjs +13 -3
- package/bin/cli-parser.clean.test-support.mjs +45 -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-fixtures.mjs +1 -1
- package/bin/cli-parser.iterate.test-support.mjs +49 -0
- package/bin/cli-parser.mjs +6 -2
- package/bin/cli-parser.test-support.mjs +43 -0
- package/bin/commands/check.test-support.mjs +140 -0
- package/bin/commands/clean.mjs +156 -0
- package/bin/commands/clean.test-support.mjs +48 -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/classify.mjs +37 -9
- package/bin/commands/iterate/escalate.mjs +10 -8
- package/bin/commands/iterate/fix-code.mjs +17 -6
- package/bin/commands/iterate/index.mjs +19 -16
- package/bin/commands/iterate/render.mjs +12 -6
- 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/resolve.test-support.mjs +114 -0
- package/bin/commands/shepherd-journal.test-support.mjs +9 -0
- package/bin/comments/resolve.mjs +70 -11
- 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/client.mjs +10 -1
- 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/state/base.mjs +2 -1
- package/bin/suggestions/patch.test-support.mjs +4 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +4 -4
package/bin/comments/resolve.mjs
CHANGED
|
@@ -1,9 +1,48 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { graphqlWithRateLimit } from "../github/client.mjs";
|
|
2
3
|
import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "./rate-limit.mjs";
|
|
3
4
|
import { setPendingOps } from "./pending-ops.mjs";
|
|
4
5
|
import { waitForSha } from "./sha-poll.mjs";
|
|
6
|
+
const COMMENTED_DISMISS_ERROR_PATTERNS = [
|
|
7
|
+
/can\s*not\s+dismiss[\s\S]*?commented pull request review/i,
|
|
8
|
+
];
|
|
9
|
+
function dedupeIds(ids) {
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const id of ids) {
|
|
13
|
+
if (seen.has(id))
|
|
14
|
+
continue;
|
|
15
|
+
seen.add(id);
|
|
16
|
+
out.push(id);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
function isCommentedDismissError(message) {
|
|
21
|
+
return COMMENTED_DISMISS_ERROR_PATTERNS.some((pattern) => pattern.test(message));
|
|
22
|
+
}
|
|
23
|
+
function dismissReviewNonDismissibleMessage(id) {
|
|
24
|
+
return `Not dismissed: ${id} is a COMMENTED review. Use --minimize-comment-ids instead; --dismiss-review-ids is only for CHANGES_REQUESTED reviews.`;
|
|
25
|
+
}
|
|
5
26
|
export async function applyResolveOptions(pr, repo, opts) {
|
|
6
|
-
|
|
27
|
+
const resolveThreadIds = dedupeIds(opts.resolveThreadIds ?? []);
|
|
28
|
+
const minimizeCommentIds = opts.minimizeCommentIds ?? [];
|
|
29
|
+
const dismissReviewIds = dedupeIds(opts.dismissReviewIds ?? []);
|
|
30
|
+
const minimizeCommentIdSet = new Set(minimizeCommentIds);
|
|
31
|
+
const filteredDismissReviewIds = dismissReviewIds.filter((id) => !minimizeCommentIdSet.has(id));
|
|
32
|
+
const overlappingDismissIds = dismissReviewIds.filter((id) => minimizeCommentIdSet.has(id));
|
|
33
|
+
const result = {
|
|
34
|
+
resolvedThreads: [],
|
|
35
|
+
minimizedComments: [],
|
|
36
|
+
dismissedReviews: [],
|
|
37
|
+
errors: [],
|
|
38
|
+
};
|
|
39
|
+
if (overlappingDismissIds.length > 0) {
|
|
40
|
+
result.skippedDismissals = [];
|
|
41
|
+
for (const id of overlappingDismissIds) {
|
|
42
|
+
result.skippedDismissals.push(id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (filteredDismissReviewIds.length > 0 && !opts.dismissMessage) {
|
|
7
46
|
throw new Error("--message is required when dismissing reviews");
|
|
8
47
|
}
|
|
9
48
|
if (opts.requireSha) {
|
|
@@ -11,13 +50,7 @@ export async function applyResolveOptions(pr, repo, opts) {
|
|
|
11
50
|
// before reviewers see the fix.
|
|
12
51
|
await waitForSha(pr, repo, opts.requireSha);
|
|
13
52
|
}
|
|
14
|
-
|
|
15
|
-
resolvedThreads: [],
|
|
16
|
-
minimizedComments: [],
|
|
17
|
-
dismissedReviews: [],
|
|
18
|
-
errors: [],
|
|
19
|
-
};
|
|
20
|
-
await bulkApply(opts.resolveThreadIds ?? [], opts.minimizeCommentIds ?? [], opts.dismissReviewIds ?? [], opts.dismissMessage ?? "", result);
|
|
53
|
+
await bulkApply(resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
|
|
21
54
|
return result;
|
|
22
55
|
}
|
|
23
56
|
export async function autoResolveOutdated(threadIds) {
|
|
@@ -65,13 +98,15 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
|
|
|
65
98
|
if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
|
|
66
99
|
return false;
|
|
67
100
|
const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
|
|
68
|
-
let data;
|
|
101
|
+
let data = {};
|
|
102
|
+
let graphQlErrors = [];
|
|
69
103
|
let rateLimitStop;
|
|
70
104
|
let suppressCurrentChunkErrors = false;
|
|
71
105
|
try {
|
|
72
106
|
const resp = await graphqlWithRateLimit(doc, {});
|
|
73
107
|
data = resp.data;
|
|
74
|
-
|
|
108
|
+
graphQlErrors = (resp.errors ?? []);
|
|
109
|
+
const graphQlErrorMessages = graphQlErrors.map((e) => e.message);
|
|
75
110
|
suppressCurrentChunkErrors = graphQlErrorMessages.some(isRateLimitMessage);
|
|
76
111
|
rateLimitStop = rateLimitFromGraphQlResult(graphQlErrorMessages, {
|
|
77
112
|
rateLimit: resp.rateLimit,
|
|
@@ -109,12 +144,27 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
|
|
|
109
144
|
else if (!suppressCurrentChunkErrors)
|
|
110
145
|
result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
|
|
111
146
|
}
|
|
147
|
+
const singleDismiss = dismissIds.length === 1;
|
|
148
|
+
const commentedDismissErrorIndexes = new Set();
|
|
149
|
+
let hasUnmappedCommentedDismissError = false;
|
|
150
|
+
for (const error of graphQlErrors) {
|
|
151
|
+
if (!isCommentedDismissError(error.message))
|
|
152
|
+
continue;
|
|
153
|
+
const alias = dismissErrorAliasIndex(error);
|
|
154
|
+
if (alias === undefined) {
|
|
155
|
+
hasUnmappedCommentedDismissError = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
commentedDismissErrorIndexes.add(alias);
|
|
159
|
+
}
|
|
112
160
|
for (let i = 0; i < dismissIds.length; i++) {
|
|
113
161
|
const d = data[`d${i}`];
|
|
114
162
|
if (d?.pullRequestReview != null)
|
|
115
163
|
result.dismissedReviews.push(dismissIds[i]);
|
|
116
164
|
else if (!suppressCurrentChunkErrors)
|
|
117
|
-
result.errors.push(
|
|
165
|
+
result.errors.push(commentedDismissErrorIndexes.has(i) || (singleDismiss && hasUnmappedCommentedDismissError)
|
|
166
|
+
? dismissReviewNonDismissibleMessage(dismissIds[i])
|
|
167
|
+
: `${dismissIds[i]}: dismiss returned null`);
|
|
118
168
|
}
|
|
119
169
|
if (rateLimitStop) {
|
|
120
170
|
result.errors.push(`rate limit: ${rateLimitStop.message}`);
|
|
@@ -123,3 +173,12 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
|
|
|
123
173
|
}
|
|
124
174
|
return false;
|
|
125
175
|
}
|
|
176
|
+
function dismissErrorAliasIndex(error) {
|
|
177
|
+
if (!Array.isArray(error.path))
|
|
178
|
+
return undefined;
|
|
179
|
+
const alias = error.path.find((part) => typeof part === "string" && /^d\d+$/.test(part));
|
|
180
|
+
if (typeof alias !== "string")
|
|
181
|
+
return undefined;
|
|
182
|
+
const parsed = Number.parseInt(alias.slice(1), 10);
|
|
183
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
184
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mock github/client.mts before any imports.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
vi.mock("../github/client.mts", () => ({
|
|
7
|
+
graphqlWithRateLimit: vi.fn(),
|
|
8
|
+
getPrHeadSha: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
import { applyResolveOptions, autoResolveOutdated } from "./resolve.mjs";
|
|
11
|
+
import { graphqlWithRateLimit, getPrHeadSha } from "../github/client.mjs";
|
|
12
|
+
const mockGraphql = vi.mocked(graphqlWithRateLimit);
|
|
13
|
+
const mockGetPrHeadSha = vi.mocked(getPrHeadSha);
|
|
14
|
+
const REPO = { owner: "owner", name: "repo" };
|
|
15
|
+
/** Build a mock response with the correct nested shape for each alias type (r/m/d). */
|
|
16
|
+
function makeBulkResponse(doc) {
|
|
17
|
+
const str = typeof doc === "string" ? doc : "";
|
|
18
|
+
const data = {};
|
|
19
|
+
for (const [, alias] of str.matchAll(/^\s+([a-z]\d+):/gm)) {
|
|
20
|
+
if (alias.startsWith("r"))
|
|
21
|
+
data[alias] = { thread: { isResolved: true } };
|
|
22
|
+
else if (alias.startsWith("m"))
|
|
23
|
+
data[alias] = { minimizedComment: { isMinimized: true } };
|
|
24
|
+
else if (alias.startsWith("d"))
|
|
25
|
+
data[alias] = { pullRequestReview: { state: "DISMISSED" } };
|
|
26
|
+
else
|
|
27
|
+
data[alias] = {};
|
|
28
|
+
}
|
|
29
|
+
return { data };
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// applyResolveOptions
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
export function registerHooks() {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
mockGraphql.mockImplementation(async (doc) => makeBulkResponse(doc));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export { REPO, applyResolveOptions, autoResolveOutdated, getPrHeadSha, graphqlWithRateLimit, makeBulkResponse, mockGetPrHeadSha, mockGraphql, };
|
|
@@ -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, };
|
package/bin/github/client.mjs
CHANGED
|
@@ -34,7 +34,16 @@ export async function getCurrentPrNumber() {
|
|
|
34
34
|
if (branch === "HEAD")
|
|
35
35
|
return null;
|
|
36
36
|
const repo = await getRepoInfo();
|
|
37
|
-
|
|
37
|
+
return getPrNumberForBranch(branch, repo.owner, repo.name);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Returns the PR number for a given branch, or null if no open PR is found. */
|
|
44
|
+
export async function getPrNumberForBranch(branch, owner, repo) {
|
|
45
|
+
try {
|
|
46
|
+
const result = await httpGraphql(PR_NUMBER_BY_BRANCH_QUERY, { owner, repo, branch });
|
|
38
47
|
return result.data.repository?.pullRequests.nodes[0]?.number ?? null;
|
|
39
48
|
}
|
|
40
49
|
catch {
|
|
@@ -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
|
+
}
|