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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +8 -2
  3. package/README.md +128 -83
  4. package/bin/cache/file-cache.mjs +79 -0
  5. package/bin/cache/fix-attempts.mjs +67 -0
  6. package/bin/checks/classify.mjs +53 -0
  7. package/bin/checks/triage.mjs +77 -0
  8. package/bin/cli/args.mjs +173 -0
  9. package/bin/cli.mjs +204 -0
  10. package/bin/commands/check.mjs +140 -0
  11. package/bin/commands/iterate.mjs +301 -0
  12. package/bin/commands/ready-delay.mjs +87 -0
  13. package/bin/commands/resolve.mjs +64 -0
  14. package/bin/commands/status.mjs +107 -0
  15. package/{src/comments/outdated.mts → bin/comments/outdated.mjs} +2 -5
  16. package/bin/comments/resolve.mjs +111 -0
  17. package/bin/config/load.mjs +158 -0
  18. package/bin/github/batch.mjs +208 -0
  19. package/bin/github/client.mjs +152 -0
  20. package/{src/github/pagination.mts → bin/github/pagination.mjs} +26 -52
  21. package/{src/github/queries.mts → bin/github/queries.mjs} +1 -10
  22. package/{src/index.mts → bin/index.mjs} +3 -5
  23. package/bin/merge-status/derive.mjs +72 -0
  24. package/bin/pr-shepherd +2 -0
  25. package/bin/reporters/agent.mjs +41 -0
  26. package/{src/reporters/json.mts → bin/reporters/json.mjs} +2 -5
  27. package/bin/reporters/text.mjs +111 -0
  28. package/bin/types.mjs +2 -0
  29. package/package.json +9 -9
  30. package/skills/check/SKILL.md +12 -14
  31. package/skills/monitor/SKILL.md +9 -5
  32. package/src/cache/file-cache.mts +0 -101
  33. package/src/cache/file-cache.test.mts +0 -91
  34. package/src/cache/fix-attempts.mts +0 -86
  35. package/src/checks/classify.mts +0 -80
  36. package/src/checks/classify.test.mts +0 -164
  37. package/src/checks/triage.mock.test.mts +0 -202
  38. package/src/checks/triage.mts +0 -88
  39. package/src/cli.mts +0 -423
  40. package/src/commands/check.mts +0 -188
  41. package/src/commands/iterate.mock.test.mts +0 -1111
  42. package/src/commands/iterate.mts +0 -371
  43. package/src/commands/ready-delay.mts +0 -117
  44. package/src/commands/ready-delay.test.mts +0 -116
  45. package/src/commands/resolve.mts +0 -92
  46. package/src/commands/status.mts +0 -173
  47. package/src/comments/resolve.mts +0 -179
  48. package/src/config/load.mts +0 -240
  49. package/src/github/batch.mts +0 -351
  50. package/src/github/client.mts +0 -207
  51. package/src/github/client.test.mts +0 -19
  52. package/src/github/pagination.test.mts +0 -140
  53. package/src/merge-status/derive.mts +0 -74
  54. package/src/merge-status/derive.test.mts +0 -130
  55. package/src/reporters/text.mts +0 -140
  56. package/src/types.mts +0 -309
  57. /package/{src → bin}/config.json +0 -0
  58. /package/{src → bin}/github/gql/batch-pr.gql +0 -0
  59. /package/{src → bin}/github/gql/dismiss-review.gql +0 -0
  60. /package/{src → bin}/github/gql/minimize-comment.gql +0 -0
  61. /package/{src → bin}/github/gql/multi-pr-status-paged.gql +0 -0
  62. /package/{src → bin}/github/gql/multi-pr-status.gql +0 -0
  63. /package/{src → bin}/github/gql/resolve-thread.gql +0 -0
  64. /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
+ }