pr-shepherd 0.2.0 → 0.4.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/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +8 -2
- package/README.md +128 -83
- package/bin/cache/file-cache.mjs +79 -0
- package/bin/cache/fix-attempts.mjs +67 -0
- package/bin/checks/classify.mjs +53 -0
- package/bin/checks/triage.mjs +77 -0
- package/bin/cli/args.mjs +173 -0
- package/bin/cli.mjs +204 -0
- package/bin/commands/check.mjs +140 -0
- package/bin/commands/iterate.mjs +301 -0
- package/bin/commands/ready-delay.mjs +87 -0
- package/bin/commands/resolve.mjs +64 -0
- package/bin/commands/status.mjs +107 -0
- package/{src/comments/outdated.mts → bin/comments/outdated.mjs} +2 -5
- package/bin/comments/resolve.mjs +111 -0
- package/bin/config/load.mjs +158 -0
- package/bin/github/batch.mjs +208 -0
- package/bin/github/client.mjs +152 -0
- package/{src/github/pagination.mts → bin/github/pagination.mjs} +26 -52
- package/{src/github/queries.mts → bin/github/queries.mjs} +1 -10
- package/{src/index.mts → bin/index.mjs} +3 -5
- package/bin/merge-status/derive.mjs +72 -0
- package/bin/pr-shepherd +2 -0
- package/bin/reporters/agent.mjs +41 -0
- package/{src/reporters/json.mts → bin/reporters/json.mjs} +2 -5
- package/bin/reporters/text.mjs +111 -0
- package/bin/types.mjs +2 -0
- package/package.json +9 -9
- package/skills/check/SKILL.md +12 -14
- package/skills/monitor/SKILL.md +9 -5
- package/src/cache/file-cache.mts +0 -101
- package/src/cache/file-cache.test.mts +0 -91
- package/src/cache/fix-attempts.mts +0 -86
- package/src/checks/classify.mts +0 -80
- package/src/checks/classify.test.mts +0 -164
- package/src/checks/triage.mock.test.mts +0 -202
- package/src/checks/triage.mts +0 -88
- package/src/cli.mts +0 -423
- package/src/commands/check.mts +0 -188
- package/src/commands/iterate.mock.test.mts +0 -1111
- package/src/commands/iterate.mts +0 -371
- package/src/commands/ready-delay.mts +0 -117
- package/src/commands/ready-delay.test.mts +0 -116
- package/src/commands/resolve.mts +0 -92
- package/src/commands/status.mts +0 -173
- package/src/comments/resolve.mts +0 -179
- package/src/config/load.mts +0 -240
- package/src/github/batch.mts +0 -351
- package/src/github/client.mts +0 -207
- package/src/github/client.test.mts +0 -19
- package/src/github/pagination.test.mts +0 -140
- package/src/merge-status/derive.mts +0 -74
- package/src/merge-status/derive.test.mts +0 -130
- package/src/reporters/text.mts +0 -140
- package/src/types.mts +0 -309
- /package/{src → bin}/config.json +0 -0
- /package/{src → bin}/github/gql/batch-pr.gql +0 -0
- /package/{src → bin}/github/gql/dismiss-review.gql +0 -0
- /package/{src → bin}/github/gql/minimize-comment.gql +0 -0
- /package/{src → bin}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{src → bin}/github/gql/multi-pr-status.gql +0 -0
- /package/{src → bin}/github/gql/resolve-thread.gql +0 -0
- /package/{src/util/path-segment.mts → bin/util/path-segment.mjs} +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched mutations for resolving threads, minimizing comments, and dismissing reviews.
|
|
3
|
+
*
|
|
4
|
+
* The three mutation types (resolve / minimize / dismiss) are run sequentially so total
|
|
5
|
+
* in-flight mutations never exceed CONCURRENCY at once, keeping us well within GitHub's
|
|
6
|
+
* secondary rate-limit window.
|
|
7
|
+
*
|
|
8
|
+
* Push-before-resolve safety:
|
|
9
|
+
* When `requireSha` is set, shepherd verifies that GitHub has received that
|
|
10
|
+
* commit before issuing any resolve/dismiss mutations. It polls up to 20 seconds.
|
|
11
|
+
* If the push hasn't landed, shepherd throws rather than resolving prematurely
|
|
12
|
+
* (which could allow auto-merge before reviewers see the fix).
|
|
13
|
+
*/
|
|
14
|
+
import { graphql, getPrHeadSha } from "../github/client.mjs";
|
|
15
|
+
import { RESOLVE_THREAD_MUTATION, MINIMIZE_COMMENT_MUTATION, DISMISS_REVIEW_MUTATION, } from "../github/queries.mjs";
|
|
16
|
+
import { loadConfig } from "../config/load.mjs";
|
|
17
|
+
/**
|
|
18
|
+
* Execute all requested resolve/minimize/dismiss mutations.
|
|
19
|
+
*
|
|
20
|
+
* @throws Error if `requireSha` is set and GitHub hasn't received that commit
|
|
21
|
+
* within the polling window.
|
|
22
|
+
*/
|
|
23
|
+
export async function applyResolveOptions(pr, repo, opts) {
|
|
24
|
+
// Require --message when dismissing reviews.
|
|
25
|
+
if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
|
|
26
|
+
throw new Error("--message is required when dismissing reviews");
|
|
27
|
+
}
|
|
28
|
+
// Safety check: verify the push landed before resolving.
|
|
29
|
+
if (opts.requireSha) {
|
|
30
|
+
await waitForSha(pr, repo, opts.requireSha);
|
|
31
|
+
}
|
|
32
|
+
const result = {
|
|
33
|
+
resolvedThreads: [],
|
|
34
|
+
minimizedComments: [],
|
|
35
|
+
dismissedReviews: [],
|
|
36
|
+
errors: [],
|
|
37
|
+
};
|
|
38
|
+
await runBatched(opts.resolveThreadIds ?? [], (id) => resolveThread(id), result.resolvedThreads, result.errors);
|
|
39
|
+
await runBatched(opts.minimizeCommentIds ?? [], (id) => minimizeComment(id, "RESOLVED"), result.minimizedComments, result.errors);
|
|
40
|
+
await runBatched(opts.dismissReviewIds ?? [], (id) => dismissReview(id, opts.dismissMessage), result.dismissedReviews, result.errors);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Auto-resolve a batch of outdated threads via the resolveReviewThread mutation.
|
|
45
|
+
*/
|
|
46
|
+
export async function autoResolveOutdated(threadIds) {
|
|
47
|
+
const resolved = [];
|
|
48
|
+
const errors = [];
|
|
49
|
+
await runBatched(threadIds, (id) => resolveThread(id), resolved, errors);
|
|
50
|
+
return { resolved, errors };
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Mutation helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
async function resolveThread(threadId) {
|
|
56
|
+
await graphql(RESOLVE_THREAD_MUTATION, { threadId });
|
|
57
|
+
}
|
|
58
|
+
async function minimizeComment(commentId, classifier) {
|
|
59
|
+
await graphql(MINIMIZE_COMMENT_MUTATION, { commentId, classifier });
|
|
60
|
+
}
|
|
61
|
+
async function dismissReview(reviewId, message) {
|
|
62
|
+
await graphql(DISMISS_REVIEW_MUTATION, { reviewId, message });
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Concurrency helper
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
async function runBatched(ids, fn, successList, errorList) {
|
|
68
|
+
const CONCURRENCY = loadConfig().resolve.concurrency;
|
|
69
|
+
// Process in chunks of CONCURRENCY.
|
|
70
|
+
for (let i = 0; i < ids.length; i += CONCURRENCY) {
|
|
71
|
+
const chunk = ids.slice(i, i + CONCURRENCY);
|
|
72
|
+
// eslint-disable-next-line no-await-in-loop
|
|
73
|
+
await Promise.all(chunk.map(async (id) => {
|
|
74
|
+
try {
|
|
75
|
+
await fn(id);
|
|
76
|
+
successList.push(id);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
errorList.push(`${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
80
|
+
}
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// SHA polling
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
async function waitForSha(pr, repo, expectedSha) {
|
|
88
|
+
const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
|
|
89
|
+
for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
|
|
90
|
+
try {
|
|
91
|
+
// eslint-disable-next-line no-await-in-loop
|
|
92
|
+
const currentSha = await getPrHeadSha(pr, repo.owner, repo.name);
|
|
93
|
+
if (currentSha === expectedSha)
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// Transient network / 5xx error — keep polling unless this is the last attempt.
|
|
98
|
+
if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
if (attempt < SHA_POLL_MAX_ATTEMPTS - 1) {
|
|
102
|
+
// eslint-disable-next-line no-await-in-loop
|
|
103
|
+
await sleep(SHA_POLL_INTERVAL_MS);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Total actual wait = (SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS (no sleep after last poll).
|
|
107
|
+
throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
|
|
108
|
+
}
|
|
109
|
+
function sleep(ms) {
|
|
110
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
111
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import builtins from "../config.json" with { type: "json" };
|
|
6
|
+
const RC_FILENAME = ".pr-shepherdrc.yml";
|
|
7
|
+
function findRcFile(startDir) {
|
|
8
|
+
const home = homedir();
|
|
9
|
+
let current = startDir;
|
|
10
|
+
while (true) {
|
|
11
|
+
const candidate = join(current, RC_FILENAME);
|
|
12
|
+
if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
if (current === home || current === dirname(current))
|
|
16
|
+
return null;
|
|
17
|
+
current = dirname(current);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function deepMerge(base, override) {
|
|
21
|
+
const result = { ...base };
|
|
22
|
+
for (const key of Object.keys(override)) {
|
|
23
|
+
const overVal = override[key];
|
|
24
|
+
const baseVal = base[key];
|
|
25
|
+
if (overVal !== null &&
|
|
26
|
+
typeof overVal === "object" &&
|
|
27
|
+
!Array.isArray(overVal) &&
|
|
28
|
+
typeof baseVal === "object" &&
|
|
29
|
+
baseVal !== null &&
|
|
30
|
+
!Array.isArray(baseVal)) {
|
|
31
|
+
result[key] = deepMerge(baseVal, overVal);
|
|
32
|
+
}
|
|
33
|
+
else if (overVal !== undefined) {
|
|
34
|
+
result[key] = overVal;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Compatibility shim — maps old RC keys to new ones and emits deprecation warnings
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
function applyCompat(raw) {
|
|
43
|
+
const out = { ...raw };
|
|
44
|
+
// Removed keys — warn and strip.
|
|
45
|
+
for (const gone of ["baseBranch", "minimizeBots", "cancelCiOnFailure", "autoMinimize"]) {
|
|
46
|
+
if (gone in out) {
|
|
47
|
+
process.stderr.write(`pr-shepherd: config key "${gone}" has been removed and has no effect.\n`);
|
|
48
|
+
delete out[gone];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Renamed top-level section keys — iterate
|
|
52
|
+
const iterate = out["iterate"];
|
|
53
|
+
if (iterate && "maxFixAttempts" in iterate) {
|
|
54
|
+
process.stderr.write(`pr-shepherd: config key "iterate.maxFixAttempts" renamed to "iterate.fixAttemptsPerThread".\n`);
|
|
55
|
+
out["iterate"] = {
|
|
56
|
+
...iterate,
|
|
57
|
+
fixAttemptsPerThread: iterate["fixAttemptsPerThread"] ?? iterate["maxFixAttempts"],
|
|
58
|
+
};
|
|
59
|
+
delete out["iterate"]["maxFixAttempts"];
|
|
60
|
+
}
|
|
61
|
+
// Renamed watch keys
|
|
62
|
+
const watch = out["watch"];
|
|
63
|
+
if (watch) {
|
|
64
|
+
const watchOut = { ...watch };
|
|
65
|
+
if ("intervalDefault" in watch) {
|
|
66
|
+
process.stderr.write(`pr-shepherd: config key "watch.intervalDefault" renamed to "watch.interval".\n`);
|
|
67
|
+
watchOut["interval"] = watchOut["interval"] ?? watch["intervalDefault"];
|
|
68
|
+
delete watchOut["intervalDefault"];
|
|
69
|
+
}
|
|
70
|
+
if ("readyDelayMinutesDefault" in watch) {
|
|
71
|
+
process.stderr.write(`pr-shepherd: config key "watch.readyDelayMinutesDefault" renamed to "watch.readyDelayMinutes".\n`);
|
|
72
|
+
watchOut["readyDelayMinutes"] =
|
|
73
|
+
watchOut["readyDelayMinutes"] ?? watch["readyDelayMinutesDefault"];
|
|
74
|
+
delete watchOut["readyDelayMinutesDefault"];
|
|
75
|
+
}
|
|
76
|
+
if ("expiresHoursDefault" in watch) {
|
|
77
|
+
process.stderr.write(`pr-shepherd: config key "watch.expiresHoursDefault" renamed to "watch.expiresHours".\n`);
|
|
78
|
+
watchOut["expiresHours"] = watchOut["expiresHours"] ?? watch["expiresHoursDefault"];
|
|
79
|
+
delete watchOut["expiresHoursDefault"];
|
|
80
|
+
}
|
|
81
|
+
out["watch"] = watchOut;
|
|
82
|
+
}
|
|
83
|
+
// Renamed resolve keys (shaPollIntervalMs / shaPollMaxAttempts → shaPoll object)
|
|
84
|
+
const resolve = out["resolve"];
|
|
85
|
+
if (resolve) {
|
|
86
|
+
const resolveOut = { ...resolve };
|
|
87
|
+
const shaPollOut = {};
|
|
88
|
+
let shaPollChanged = false;
|
|
89
|
+
if ("shaPollIntervalMs" in resolve) {
|
|
90
|
+
process.stderr.write(`pr-shepherd: config key "resolve.shaPollIntervalMs" moved to "resolve.shaPoll.intervalMs".\n`);
|
|
91
|
+
shaPollOut["intervalMs"] =
|
|
92
|
+
resolve["shaPoll"]?.["intervalMs"] ??
|
|
93
|
+
resolve["shaPollIntervalMs"];
|
|
94
|
+
delete resolveOut["shaPollIntervalMs"];
|
|
95
|
+
shaPollChanged = true;
|
|
96
|
+
}
|
|
97
|
+
if ("shaPollMaxAttempts" in resolve) {
|
|
98
|
+
process.stderr.write(`pr-shepherd: config key "resolve.shaPollMaxAttempts" moved to "resolve.shaPoll.maxAttempts".\n`);
|
|
99
|
+
shaPollOut["maxAttempts"] =
|
|
100
|
+
resolve["shaPoll"]?.["maxAttempts"] ??
|
|
101
|
+
resolve["shaPollMaxAttempts"];
|
|
102
|
+
delete resolveOut["shaPollMaxAttempts"];
|
|
103
|
+
shaPollChanged = true;
|
|
104
|
+
}
|
|
105
|
+
if (shaPollChanged) {
|
|
106
|
+
resolveOut["shaPoll"] = {
|
|
107
|
+
...resolveOut["shaPoll"],
|
|
108
|
+
...shaPollOut,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
out["resolve"] = resolveOut;
|
|
112
|
+
}
|
|
113
|
+
// Renamed checks keys
|
|
114
|
+
const checks = out["checks"];
|
|
115
|
+
if (checks) {
|
|
116
|
+
const checksOut = { ...checks };
|
|
117
|
+
if ("relevantEvents" in checks) {
|
|
118
|
+
process.stderr.write(`pr-shepherd: config key "checks.relevantEvents" renamed to "checks.ciTriggerEvents".\n`);
|
|
119
|
+
checksOut["ciTriggerEvents"] = checksOut["ciTriggerEvents"] ?? checks["relevantEvents"];
|
|
120
|
+
delete checksOut["relevantEvents"];
|
|
121
|
+
}
|
|
122
|
+
if ("logLinesKept" in checks) {
|
|
123
|
+
process.stderr.write(`pr-shepherd: config key "checks.logLinesKept" renamed to "checks.logMaxLines".\n`);
|
|
124
|
+
checksOut["logMaxLines"] = checksOut["logMaxLines"] ?? checks["logLinesKept"];
|
|
125
|
+
delete checksOut["logLinesKept"];
|
|
126
|
+
}
|
|
127
|
+
if ("logExcerptMaxChars" in checks) {
|
|
128
|
+
process.stderr.write(`pr-shepherd: config key "checks.logExcerptMaxChars" renamed to "checks.logMaxChars".\n`);
|
|
129
|
+
checksOut["logMaxChars"] = checksOut["logMaxChars"] ?? checks["logExcerptMaxChars"];
|
|
130
|
+
delete checksOut["logExcerptMaxChars"];
|
|
131
|
+
}
|
|
132
|
+
out["checks"] = checksOut;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
const defaults = builtins;
|
|
137
|
+
let cached = null;
|
|
138
|
+
export function loadConfig() {
|
|
139
|
+
if (cached)
|
|
140
|
+
return cached;
|
|
141
|
+
const rcPath = findRcFile(process.cwd());
|
|
142
|
+
if (!rcPath) {
|
|
143
|
+
cached = defaults;
|
|
144
|
+
return cached;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const raw = readFileSync(rcPath, "utf8");
|
|
148
|
+
const parsed = (parse(raw) ?? {});
|
|
149
|
+
const compat = applyCompat(parsed);
|
|
150
|
+
cached = deepMerge(defaults, compat);
|
|
151
|
+
return cached;
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
process.stderr.write(`pr-shepherd: failed to parse ${rcPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
155
|
+
cached = { ...defaults };
|
|
156
|
+
return cached;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executes the primary batch GraphQL query and parses the raw GitHub response
|
|
3
|
+
* into shepherd's typed `BatchPrData` shape.
|
|
4
|
+
*
|
|
5
|
+
* The batch query fetches CI checks + review threads + PR comments + merge
|
|
6
|
+
* status in a single network round-trip, drastically reducing API call counts
|
|
7
|
+
* compared to the previous per-agent approach.
|
|
8
|
+
*/
|
|
9
|
+
import { graphql, graphqlWithRateLimit } from "./client.mjs";
|
|
10
|
+
import { paginateForward, paginateBackward } from "./pagination.mjs";
|
|
11
|
+
import { BATCH_PR_QUERY } from "./queries.mjs";
|
|
12
|
+
/**
|
|
13
|
+
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
|
14
|
+
*/
|
|
15
|
+
export async function fetchPrBatch(pr, repo) {
|
|
16
|
+
// First page: no cursor variables.
|
|
17
|
+
const result = await graphqlWithRateLimit(BATCH_PR_QUERY, {
|
|
18
|
+
owner: repo.owner,
|
|
19
|
+
repo: repo.name,
|
|
20
|
+
pr,
|
|
21
|
+
});
|
|
22
|
+
const raw = result.data.repository.pullRequest;
|
|
23
|
+
if (!raw) {
|
|
24
|
+
throw new Error(`PR #${pr} not found`);
|
|
25
|
+
}
|
|
26
|
+
// Paginate reviewThreads backward if the first page is incomplete.
|
|
27
|
+
let rawThreadPages = raw.reviewThreads.nodes;
|
|
28
|
+
if (raw.reviewThreads.pageInfo.hasPreviousPage && raw.reviewThreads.pageInfo.startCursor) {
|
|
29
|
+
// Pass startCursor so paginateBackward fetches pages *before* the already-
|
|
30
|
+
// fetched first page instead of re-fetching it from the start.
|
|
31
|
+
const extra = await paginateBackward(async (cursor) => {
|
|
32
|
+
const res = await graphql(BATCH_PR_QUERY, {
|
|
33
|
+
owner: repo.owner,
|
|
34
|
+
repo: repo.name,
|
|
35
|
+
pr,
|
|
36
|
+
...(cursor ? { threadsCursor: cursor } : {}),
|
|
37
|
+
});
|
|
38
|
+
const pr2 = res.data.repository.pullRequest;
|
|
39
|
+
if (!pr2)
|
|
40
|
+
throw new Error(`PR #${pr} not found`);
|
|
41
|
+
return pr2.reviewThreads;
|
|
42
|
+
}, raw.reviewThreads.pageInfo.startCursor);
|
|
43
|
+
// extra contains pages before the first page.
|
|
44
|
+
rawThreadPages = [...extra, ...rawThreadPages];
|
|
45
|
+
}
|
|
46
|
+
// Paginate comments backward if the first page is incomplete.
|
|
47
|
+
let rawCommentNodes = raw.comments.nodes;
|
|
48
|
+
if (raw.comments.pageInfo.hasPreviousPage && raw.comments.pageInfo.startCursor) {
|
|
49
|
+
const extra = await paginateBackward(async (cursor) => {
|
|
50
|
+
const res = await graphql(BATCH_PR_QUERY, {
|
|
51
|
+
owner: repo.owner,
|
|
52
|
+
repo: repo.name,
|
|
53
|
+
pr,
|
|
54
|
+
...(cursor ? { commentsCursor: cursor } : {}),
|
|
55
|
+
});
|
|
56
|
+
const pr2 = res.data.repository.pullRequest;
|
|
57
|
+
if (!pr2)
|
|
58
|
+
throw new Error(`PR #${pr} not found`);
|
|
59
|
+
return pr2.comments;
|
|
60
|
+
}, raw.comments.pageInfo.startCursor);
|
|
61
|
+
rawCommentNodes = [...extra, ...rawCommentNodes];
|
|
62
|
+
}
|
|
63
|
+
// Paginate reviews backward if the first page is incomplete.
|
|
64
|
+
let rawReviewNodes = raw.reviews.nodes;
|
|
65
|
+
if (raw.reviews.pageInfo.hasPreviousPage && raw.reviews.pageInfo.startCursor) {
|
|
66
|
+
const extra = await paginateBackward(async (cursor) => {
|
|
67
|
+
const res = await graphql(BATCH_PR_QUERY, {
|
|
68
|
+
owner: repo.owner,
|
|
69
|
+
repo: repo.name,
|
|
70
|
+
pr,
|
|
71
|
+
...(cursor ? { reviewsCursor: cursor } : {}),
|
|
72
|
+
});
|
|
73
|
+
const pr2 = res.data.repository.pullRequest;
|
|
74
|
+
if (!pr2)
|
|
75
|
+
throw new Error(`PR #${pr} not found`);
|
|
76
|
+
return pr2.reviews;
|
|
77
|
+
}, raw.reviews.pageInfo.startCursor);
|
|
78
|
+
rawReviewNodes = [...extra, ...rawReviewNodes];
|
|
79
|
+
}
|
|
80
|
+
// Paginate check contexts forward if the first page is incomplete.
|
|
81
|
+
let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
|
82
|
+
const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
|
|
83
|
+
if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
|
|
84
|
+
// Pass endCursor so paginateForward fetches pages *after* the already-
|
|
85
|
+
// fetched first page instead of re-fetching it from the start.
|
|
86
|
+
const extra = await paginateForward(async (cursor) => {
|
|
87
|
+
const res = await graphql(BATCH_PR_QUERY, {
|
|
88
|
+
owner: repo.owner,
|
|
89
|
+
repo: repo.name,
|
|
90
|
+
pr,
|
|
91
|
+
...(cursor ? { checksCursor: cursor } : {}),
|
|
92
|
+
});
|
|
93
|
+
const pr2 = res.data.repository.pullRequest;
|
|
94
|
+
const ctxs = pr2?.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
95
|
+
return ctxs ?? { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
96
|
+
}, checksPageInfo.endCursor);
|
|
97
|
+
rawCheckNodes = [...rawCheckNodes, ...extra];
|
|
98
|
+
}
|
|
99
|
+
const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes);
|
|
100
|
+
return { data, rateLimit: result.rateLimit };
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Parsers
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes) {
|
|
106
|
+
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
107
|
+
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
108
|
+
return login ? [{ login }] : [];
|
|
109
|
+
});
|
|
110
|
+
const latestReviews = (raw.latestReviews?.nodes ?? []).map((n) => ({
|
|
111
|
+
login: n.author?.login ?? "unknown",
|
|
112
|
+
state: n.state,
|
|
113
|
+
}));
|
|
114
|
+
const reviewThreads = rawThreadPages.map((t) => {
|
|
115
|
+
const comment = t.comments.nodes[0];
|
|
116
|
+
return {
|
|
117
|
+
id: t.id,
|
|
118
|
+
isResolved: t.isResolved,
|
|
119
|
+
isOutdated: t.isOutdated,
|
|
120
|
+
path: comment?.path ?? null,
|
|
121
|
+
line: comment?.line ?? null,
|
|
122
|
+
author: comment?.author?.login ?? "unknown",
|
|
123
|
+
body: comment?.body ?? "",
|
|
124
|
+
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
const comments = rawCommentNodes.map((c) => ({
|
|
128
|
+
id: c.id,
|
|
129
|
+
isMinimized: c.isMinimized,
|
|
130
|
+
author: c.author?.login ?? "unknown",
|
|
131
|
+
body: c.body,
|
|
132
|
+
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
133
|
+
}));
|
|
134
|
+
const changesRequestedReviews = rawReviewNodes.map((r) => ({
|
|
135
|
+
id: r.id,
|
|
136
|
+
author: r.author?.login ?? "unknown",
|
|
137
|
+
body: r.body,
|
|
138
|
+
}));
|
|
139
|
+
const checks = rawCheckNodes.flatMap((node) => {
|
|
140
|
+
if (node.__typename === "CheckRun") {
|
|
141
|
+
const event = node.checkSuite?.workflowRun?.event ?? null;
|
|
142
|
+
const runId = extractRunId(node.detailsUrl);
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
name: node.name,
|
|
146
|
+
status: node.status,
|
|
147
|
+
conclusion: node.conclusion,
|
|
148
|
+
detailsUrl: node.detailsUrl ?? "",
|
|
149
|
+
event,
|
|
150
|
+
runId,
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
if (node.__typename === "StatusContext") {
|
|
155
|
+
const { status, conclusion } = mapStatusContextState(node.state);
|
|
156
|
+
return [
|
|
157
|
+
{
|
|
158
|
+
name: node.context,
|
|
159
|
+
status,
|
|
160
|
+
conclusion,
|
|
161
|
+
detailsUrl: node.targetUrl ?? "",
|
|
162
|
+
event: null,
|
|
163
|
+
runId: null,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
number: raw.number,
|
|
171
|
+
state: raw.state,
|
|
172
|
+
isDraft: raw.isDraft,
|
|
173
|
+
mergeable: raw.mergeable,
|
|
174
|
+
mergeStateStatus: raw.mergeStateStatus,
|
|
175
|
+
reviewDecision: (raw.reviewDecision ?? null),
|
|
176
|
+
headRefOid: raw.headRefOid,
|
|
177
|
+
reviewRequests,
|
|
178
|
+
latestReviews,
|
|
179
|
+
reviewThreads,
|
|
180
|
+
comments,
|
|
181
|
+
changesRequestedReviews,
|
|
182
|
+
checks,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function parseCreatedAt(iso) {
|
|
186
|
+
const ms = new Date(iso).getTime();
|
|
187
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
188
|
+
}
|
|
189
|
+
function extractRunId(url) {
|
|
190
|
+
if (!url)
|
|
191
|
+
return null;
|
|
192
|
+
const m = /\/runs\/(\d+)/.exec(url);
|
|
193
|
+
return m ? (m[1] ?? null) : null;
|
|
194
|
+
}
|
|
195
|
+
/** Maps a GitHub commit status `state` to CheckRun-compatible status + conclusion. */
|
|
196
|
+
function mapStatusContextState(state) {
|
|
197
|
+
switch (state) {
|
|
198
|
+
case "SUCCESS":
|
|
199
|
+
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
200
|
+
case "FAILURE":
|
|
201
|
+
case "ERROR":
|
|
202
|
+
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
203
|
+
case "PENDING":
|
|
204
|
+
case "EXPECTED":
|
|
205
|
+
default:
|
|
206
|
+
return { status: "IN_PROGRESS", conclusion: null };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around the `gh` CLI for GraphQL and REST calls.
|
|
3
|
+
*/
|
|
4
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { loadConfig } from "../config/load.mjs";
|
|
7
|
+
const execFile = promisify(execFileCb);
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// GraphQL
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* Execute a GraphQL query via `gh api graphql`.
|
|
13
|
+
*
|
|
14
|
+
* @param query The full GraphQL query/mutation string.
|
|
15
|
+
* @param vars Key-value pairs forwarded as `-f key=value` or `-F key=value`.
|
|
16
|
+
* Numeric values are passed with `-F`; everything else with `-f`.
|
|
17
|
+
*/
|
|
18
|
+
export async function graphql(query, vars = {}) {
|
|
19
|
+
const args = buildGraphqlArgs(query, vars);
|
|
20
|
+
const raw = await runGh(args);
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (parsed.errors?.length) {
|
|
23
|
+
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
24
|
+
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
25
|
+
}
|
|
26
|
+
return { data: parsed.data };
|
|
27
|
+
}
|
|
28
|
+
/** Like {@link graphql} but also returns the `x-ratelimit-remaining` header. */
|
|
29
|
+
export async function graphqlWithRateLimit(query, vars = {}) {
|
|
30
|
+
// --include must come after 'api' (it's a flag for `gh api`, not for `gh`).
|
|
31
|
+
const [api, ...extraArgs] = buildGraphqlArgs(query, vars);
|
|
32
|
+
const args = [api, "--include", ...extraArgs];
|
|
33
|
+
const raw = await runGh(args);
|
|
34
|
+
// `gh api -i` prepends HTTP headers before the JSON body.
|
|
35
|
+
// Handle both CRLF (\r\n\r\n) and LF-only (\n\n) header separators.
|
|
36
|
+
const crlfEnd = raw.indexOf("\r\n\r\n");
|
|
37
|
+
const lfEnd = raw.indexOf("\n\n");
|
|
38
|
+
const headerEnd = crlfEnd >= 0 ? crlfEnd : lfEnd;
|
|
39
|
+
const headerSection = headerEnd >= 0 ? raw.slice(0, headerEnd) : "";
|
|
40
|
+
const body = headerEnd >= 0 ? raw.slice(headerEnd + (crlfEnd >= 0 ? 4 : 2)) : raw;
|
|
41
|
+
const remaining = parseHeaderNumber(headerSection, "x-ratelimit-remaining");
|
|
42
|
+
const limit = parseHeaderNumber(headerSection, "x-ratelimit-limit");
|
|
43
|
+
const resetAt = parseHeaderNumber(headerSection, "x-ratelimit-reset");
|
|
44
|
+
const parsed = JSON.parse(body);
|
|
45
|
+
if (parsed.errors?.length) {
|
|
46
|
+
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
47
|
+
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
data: parsed.data,
|
|
51
|
+
rateLimit: remaining !== null && limit !== null && resetAt !== null
|
|
52
|
+
? { remaining, limit, resetAt }
|
|
53
|
+
: undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Returns the current repo's owner and name from `gh repo view`. */
|
|
57
|
+
export async function getRepoInfo() {
|
|
58
|
+
const raw = await runGh(["repo", "view", "--json", "owner,name"]);
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
return { owner: parsed.owner.login, name: parsed.name };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Derives the PR number for the current HEAD branch.
|
|
64
|
+
* Returns null if no open PR is found.
|
|
65
|
+
*/
|
|
66
|
+
export async function getCurrentPrNumber() {
|
|
67
|
+
try {
|
|
68
|
+
const branch = await getCurrentBranch();
|
|
69
|
+
// In detached HEAD state git returns "HEAD" — no branch name to look up.
|
|
70
|
+
if (branch === "HEAD")
|
|
71
|
+
return null;
|
|
72
|
+
const raw = await runGh([
|
|
73
|
+
"pr",
|
|
74
|
+
"list",
|
|
75
|
+
"--head",
|
|
76
|
+
branch,
|
|
77
|
+
"--json",
|
|
78
|
+
"number",
|
|
79
|
+
"--jq",
|
|
80
|
+
".[0].number",
|
|
81
|
+
]);
|
|
82
|
+
const trimmed = raw.trim();
|
|
83
|
+
if (!trimmed || trimmed === "null")
|
|
84
|
+
return null;
|
|
85
|
+
return parseInt(trimmed, 10);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function getCurrentBranch() {
|
|
92
|
+
// Use git directly for branch name — gh doesn't expose it.
|
|
93
|
+
const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
94
|
+
return stdout.trim();
|
|
95
|
+
}
|
|
96
|
+
/** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
|
|
97
|
+
export async function getPrHeadSha(pr, owner, name) {
|
|
98
|
+
const raw = await runGh(["api", `repos/${owner}/${name}/pulls/${pr}`, "--jq", ".head.sha"]);
|
|
99
|
+
return raw.trim();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fetches `mergeable` and `mergeStateStatus` via the REST API (`gh pr view`).
|
|
103
|
+
*
|
|
104
|
+
* Used as a fallback when the GraphQL API returns `UNKNOWN` for these fields —
|
|
105
|
+
* a known GitHub quirk where GraphQL lags behind the REST layer.
|
|
106
|
+
*/
|
|
107
|
+
export async function getMergeableState(pr, owner, repo) {
|
|
108
|
+
const raw = await runGh([
|
|
109
|
+
"pr",
|
|
110
|
+
"view",
|
|
111
|
+
String(pr),
|
|
112
|
+
"--repo",
|
|
113
|
+
`${owner}/${repo}`,
|
|
114
|
+
"--json",
|
|
115
|
+
"mergeable,mergeStateStatus",
|
|
116
|
+
]);
|
|
117
|
+
const parsed = JSON.parse(raw);
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Internal helpers
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
function buildGraphqlArgs(query, vars) {
|
|
124
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
125
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
126
|
+
if (typeof v === "number" || typeof v === "boolean") {
|
|
127
|
+
args.push("-F", `${k}=${String(v)}`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
args.push("-f", `${k}=${v}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return args;
|
|
134
|
+
}
|
|
135
|
+
async function runGh(args) {
|
|
136
|
+
try {
|
|
137
|
+
const { stdout } = await execFile("gh", args, {
|
|
138
|
+
maxBuffer: loadConfig().execution.maxBufferMb * 1024 * 1024,
|
|
139
|
+
});
|
|
140
|
+
return stdout;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
// Re-throw with a more useful message
|
|
144
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
145
|
+
throw new Error(`gh ${args[0] ?? ""} failed: ${msg}`, { cause: err });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function parseHeaderNumber(headers, name) {
|
|
149
|
+
const re = new RegExp(`^${name}:\\s*(\\d+)`, "im");
|
|
150
|
+
const m = re.exec(headers);
|
|
151
|
+
return m ? parseInt(m[1], 10) : null;
|
|
152
|
+
}
|