pr-shepherd 0.2.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 +14 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/marketplace.json +8 -0
- package/package.json +62 -0
- package/skills/check/SKILL.md +70 -0
- package/skills/monitor/SKILL.md +108 -0
- package/skills/resolve/SKILL.md +85 -0
- package/src/cache/file-cache.mts +101 -0
- package/src/cache/file-cache.test.mts +91 -0
- package/src/cache/fix-attempts.mts +86 -0
- package/src/checks/classify.mts +80 -0
- package/src/checks/classify.test.mts +164 -0
- package/src/checks/triage.mock.test.mts +202 -0
- package/src/checks/triage.mts +88 -0
- package/src/cli.mts +423 -0
- package/src/commands/check.mts +188 -0
- package/src/commands/iterate.mock.test.mts +1111 -0
- package/src/commands/iterate.mts +371 -0
- package/src/commands/ready-delay.mts +117 -0
- package/src/commands/ready-delay.test.mts +116 -0
- package/src/commands/resolve.mts +92 -0
- package/src/commands/status.mts +173 -0
- package/src/comments/outdated.mts +18 -0
- package/src/comments/resolve.mts +179 -0
- package/src/config/load.mts +240 -0
- package/src/config.json +52 -0
- package/src/github/batch.mts +351 -0
- package/src/github/client.mts +207 -0
- package/src/github/client.test.mts +19 -0
- package/src/github/gql/batch-pr.gql +130 -0
- package/src/github/gql/dismiss-review.gql +7 -0
- package/src/github/gql/minimize-comment.gql +7 -0
- package/src/github/gql/multi-pr-status-paged.gql +31 -0
- package/src/github/gql/multi-pr-status.gql +32 -0
- package/src/github/gql/resolve-thread.gql +7 -0
- package/src/github/pagination.mts +86 -0
- package/src/github/pagination.test.mts +140 -0
- package/src/github/queries.mts +30 -0
- package/src/index.mts +17 -0
- package/src/merge-status/derive.mts +74 -0
- package/src/merge-status/derive.test.mts +130 -0
- package/src/reporters/json.mts +12 -0
- package/src/reporters/text.mts +140 -0
- package/src/types.mts +309 -0
- package/src/util/path-segment.mts +2 -0
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
|
|
10
|
+
import { graphql, graphqlWithRateLimit, type RateLimitInfo, type RepoInfo } from "./client.mts";
|
|
11
|
+
import { paginateForward, paginateBackward } from "./pagination.mts";
|
|
12
|
+
import { BATCH_PR_QUERY } from "./queries.mts";
|
|
13
|
+
import type {
|
|
14
|
+
BatchPrData,
|
|
15
|
+
CheckConclusion,
|
|
16
|
+
CheckRun,
|
|
17
|
+
CheckStatus,
|
|
18
|
+
PrComment,
|
|
19
|
+
Review,
|
|
20
|
+
ReviewThread,
|
|
21
|
+
} from "../types.mts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Public API
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface BatchResult {
|
|
28
|
+
data: BatchPrData;
|
|
29
|
+
rateLimit?: RateLimitInfo;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
|
34
|
+
*/
|
|
35
|
+
export async function fetchPrBatch(pr: number, repo: RepoInfo): Promise<BatchResult> {
|
|
36
|
+
// First page: no cursor variables.
|
|
37
|
+
const result = await graphqlWithRateLimit<RawBatchResponse>(BATCH_PR_QUERY, {
|
|
38
|
+
owner: repo.owner,
|
|
39
|
+
repo: repo.name,
|
|
40
|
+
pr,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const raw = result.data.repository.pullRequest;
|
|
44
|
+
if (!raw) {
|
|
45
|
+
throw new Error(`PR #${pr} not found`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Paginate reviewThreads backward if the first page is incomplete.
|
|
49
|
+
let rawThreadPages = raw.reviewThreads.nodes;
|
|
50
|
+
if (raw.reviewThreads.pageInfo.hasPreviousPage && raw.reviewThreads.pageInfo.startCursor) {
|
|
51
|
+
// Pass startCursor so paginateBackward fetches pages *before* the already-
|
|
52
|
+
// fetched first page instead of re-fetching it from the start.
|
|
53
|
+
const extra = await paginateBackward<RawThread>(async (cursor) => {
|
|
54
|
+
const res = await graphql<RawBatchResponse>(BATCH_PR_QUERY, {
|
|
55
|
+
owner: repo.owner,
|
|
56
|
+
repo: repo.name,
|
|
57
|
+
pr,
|
|
58
|
+
...(cursor ? { threadsCursor: cursor } : {}),
|
|
59
|
+
});
|
|
60
|
+
const pr2 = res.data.repository.pullRequest;
|
|
61
|
+
if (!pr2) throw new Error(`PR #${pr} not found`);
|
|
62
|
+
return pr2.reviewThreads;
|
|
63
|
+
}, raw.reviewThreads.pageInfo.startCursor);
|
|
64
|
+
// extra contains pages before the first page.
|
|
65
|
+
rawThreadPages = [...extra, ...rawThreadPages];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Paginate comments backward if the first page is incomplete.
|
|
69
|
+
let rawCommentNodes = raw.comments.nodes;
|
|
70
|
+
if (raw.comments.pageInfo.hasPreviousPage && raw.comments.pageInfo.startCursor) {
|
|
71
|
+
const extra = await paginateBackward<RawComment>(async (cursor) => {
|
|
72
|
+
const res = await graphql<RawBatchResponse>(BATCH_PR_QUERY, {
|
|
73
|
+
owner: repo.owner,
|
|
74
|
+
repo: repo.name,
|
|
75
|
+
pr,
|
|
76
|
+
...(cursor ? { commentsCursor: cursor } : {}),
|
|
77
|
+
});
|
|
78
|
+
const pr2 = res.data.repository.pullRequest;
|
|
79
|
+
if (!pr2) throw new Error(`PR #${pr} not found`);
|
|
80
|
+
return pr2.comments;
|
|
81
|
+
}, raw.comments.pageInfo.startCursor);
|
|
82
|
+
rawCommentNodes = [...extra, ...rawCommentNodes];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Paginate reviews backward if the first page is incomplete.
|
|
86
|
+
let rawReviewNodes = raw.reviews.nodes;
|
|
87
|
+
if (raw.reviews.pageInfo.hasPreviousPage && raw.reviews.pageInfo.startCursor) {
|
|
88
|
+
const extra = await paginateBackward<RawReview>(async (cursor) => {
|
|
89
|
+
const res = await graphql<RawBatchResponse>(BATCH_PR_QUERY, {
|
|
90
|
+
owner: repo.owner,
|
|
91
|
+
repo: repo.name,
|
|
92
|
+
pr,
|
|
93
|
+
...(cursor ? { reviewsCursor: cursor } : {}),
|
|
94
|
+
});
|
|
95
|
+
const pr2 = res.data.repository.pullRequest;
|
|
96
|
+
if (!pr2) throw new Error(`PR #${pr} not found`);
|
|
97
|
+
return pr2.reviews;
|
|
98
|
+
}, raw.reviews.pageInfo.startCursor);
|
|
99
|
+
rawReviewNodes = [...extra, ...rawReviewNodes];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Paginate check contexts forward if the first page is incomplete.
|
|
103
|
+
let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
|
104
|
+
const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
|
|
105
|
+
if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
|
|
106
|
+
// Pass endCursor so paginateForward fetches pages *after* the already-
|
|
107
|
+
// fetched first page instead of re-fetching it from the start.
|
|
108
|
+
const extra = await paginateForward<RawContextNode>(async (cursor) => {
|
|
109
|
+
const res = await graphql<RawBatchResponse>(BATCH_PR_QUERY, {
|
|
110
|
+
owner: repo.owner,
|
|
111
|
+
repo: repo.name,
|
|
112
|
+
pr,
|
|
113
|
+
...(cursor ? { checksCursor: cursor } : {}),
|
|
114
|
+
});
|
|
115
|
+
const pr2 = res.data.repository.pullRequest;
|
|
116
|
+
const ctxs = pr2?.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
117
|
+
return ctxs ?? { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
118
|
+
}, checksPageInfo.endCursor);
|
|
119
|
+
rawCheckNodes = [...rawCheckNodes, ...extra];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes);
|
|
123
|
+
return { data, rateLimit: result.rateLimit };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Parsers
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function parseRawPr(
|
|
131
|
+
raw: RawPr,
|
|
132
|
+
rawThreadPages: RawThread[],
|
|
133
|
+
rawCommentNodes: RawComment[],
|
|
134
|
+
rawReviewNodes: RawReview[],
|
|
135
|
+
rawCheckNodes: RawContextNode[],
|
|
136
|
+
): BatchPrData {
|
|
137
|
+
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
138
|
+
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
139
|
+
return login ? [{ login }] : [];
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const latestReviews = (raw.latestReviews?.nodes ?? []).map((n) => ({
|
|
143
|
+
login: n.author?.login ?? "unknown",
|
|
144
|
+
state: n.state,
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const reviewThreads: ReviewThread[] = rawThreadPages.map((t) => {
|
|
148
|
+
const comment = t.comments.nodes[0];
|
|
149
|
+
return {
|
|
150
|
+
id: t.id,
|
|
151
|
+
isResolved: t.isResolved,
|
|
152
|
+
isOutdated: t.isOutdated,
|
|
153
|
+
path: comment?.path ?? null,
|
|
154
|
+
line: comment?.line ?? null,
|
|
155
|
+
author: comment?.author?.login ?? "unknown",
|
|
156
|
+
body: comment?.body ?? "",
|
|
157
|
+
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const comments: PrComment[] = rawCommentNodes.map((c) => ({
|
|
162
|
+
id: c.id,
|
|
163
|
+
isMinimized: c.isMinimized,
|
|
164
|
+
author: c.author?.login ?? "unknown",
|
|
165
|
+
body: c.body,
|
|
166
|
+
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
const changesRequestedReviews: Review[] = rawReviewNodes.map((r) => ({
|
|
170
|
+
id: r.id,
|
|
171
|
+
author: r.author?.login ?? "unknown",
|
|
172
|
+
body: r.body,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const checks: CheckRun[] = rawCheckNodes.flatMap((node) => {
|
|
176
|
+
if (node.__typename === "CheckRun") {
|
|
177
|
+
const event = node.checkSuite?.workflowRun?.event ?? null;
|
|
178
|
+
const runId = extractRunId(node.detailsUrl);
|
|
179
|
+
return [
|
|
180
|
+
{
|
|
181
|
+
name: node.name,
|
|
182
|
+
status: node.status as CheckRun["status"],
|
|
183
|
+
conclusion: node.conclusion as CheckRun["conclusion"],
|
|
184
|
+
detailsUrl: node.detailsUrl ?? "",
|
|
185
|
+
event,
|
|
186
|
+
runId,
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
if (node.__typename === "StatusContext") {
|
|
191
|
+
const { status, conclusion } = mapStatusContextState(node.state);
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
name: node.context,
|
|
195
|
+
status,
|
|
196
|
+
conclusion,
|
|
197
|
+
detailsUrl: node.targetUrl ?? "",
|
|
198
|
+
event: null,
|
|
199
|
+
runId: null,
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
return [];
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
number: raw.number,
|
|
208
|
+
state: raw.state as BatchPrData["state"],
|
|
209
|
+
isDraft: raw.isDraft,
|
|
210
|
+
mergeable: raw.mergeable as BatchPrData["mergeable"],
|
|
211
|
+
mergeStateStatus: raw.mergeStateStatus as BatchPrData["mergeStateStatus"],
|
|
212
|
+
reviewDecision: (raw.reviewDecision ?? null) as BatchPrData["reviewDecision"],
|
|
213
|
+
headRefOid: raw.headRefOid,
|
|
214
|
+
reviewRequests,
|
|
215
|
+
latestReviews,
|
|
216
|
+
reviewThreads,
|
|
217
|
+
comments,
|
|
218
|
+
changesRequestedReviews,
|
|
219
|
+
checks,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseCreatedAt(iso: string): number {
|
|
224
|
+
const ms = new Date(iso).getTime();
|
|
225
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function extractRunId(url: string | undefined | null): string | null {
|
|
229
|
+
if (!url) return null;
|
|
230
|
+
const m = /\/runs\/(\d+)/.exec(url);
|
|
231
|
+
return m ? (m[1] ?? null) : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Maps a GitHub commit status `state` to CheckRun-compatible status + conclusion. */
|
|
235
|
+
function mapStatusContextState(state: string): {
|
|
236
|
+
status: CheckStatus;
|
|
237
|
+
conclusion: CheckConclusion;
|
|
238
|
+
} {
|
|
239
|
+
switch (state) {
|
|
240
|
+
case "SUCCESS":
|
|
241
|
+
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
242
|
+
case "FAILURE":
|
|
243
|
+
case "ERROR":
|
|
244
|
+
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
245
|
+
case "PENDING":
|
|
246
|
+
case "EXPECTED":
|
|
247
|
+
default:
|
|
248
|
+
return { status: "IN_PROGRESS", conclusion: null };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Raw GraphQL response types (private to this module)
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
interface RawBatchResponse {
|
|
257
|
+
repository: {
|
|
258
|
+
pullRequest: RawPr | null;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface RawPr {
|
|
263
|
+
number: number;
|
|
264
|
+
state: string;
|
|
265
|
+
isDraft: boolean;
|
|
266
|
+
mergeable: string;
|
|
267
|
+
mergeStateStatus: string;
|
|
268
|
+
reviewDecision: string | null;
|
|
269
|
+
headRefOid: string;
|
|
270
|
+
reviewRequests: {
|
|
271
|
+
nodes: Array<{
|
|
272
|
+
requestedReviewer: { login?: string; name?: string } | null;
|
|
273
|
+
}>;
|
|
274
|
+
};
|
|
275
|
+
latestReviews: {
|
|
276
|
+
nodes: Array<{
|
|
277
|
+
author: { login: string } | null;
|
|
278
|
+
state: string;
|
|
279
|
+
}>;
|
|
280
|
+
};
|
|
281
|
+
reviewThreads: {
|
|
282
|
+
pageInfo: { hasPreviousPage: boolean; startCursor: string | null };
|
|
283
|
+
nodes: RawThread[];
|
|
284
|
+
};
|
|
285
|
+
comments: {
|
|
286
|
+
pageInfo: { hasPreviousPage: boolean; startCursor: string | null };
|
|
287
|
+
nodes: RawComment[];
|
|
288
|
+
};
|
|
289
|
+
reviews: {
|
|
290
|
+
pageInfo: { hasPreviousPage: boolean; startCursor: string | null };
|
|
291
|
+
nodes: RawReview[];
|
|
292
|
+
};
|
|
293
|
+
commits: {
|
|
294
|
+
nodes: Array<{
|
|
295
|
+
commit: {
|
|
296
|
+
statusCheckRollup: {
|
|
297
|
+
contexts: {
|
|
298
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
|
299
|
+
nodes: RawContextNode[];
|
|
300
|
+
};
|
|
301
|
+
} | null;
|
|
302
|
+
};
|
|
303
|
+
}>;
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface RawThread {
|
|
308
|
+
id: string;
|
|
309
|
+
isResolved: boolean;
|
|
310
|
+
isOutdated: boolean;
|
|
311
|
+
comments: {
|
|
312
|
+
nodes: Array<{
|
|
313
|
+
id: string;
|
|
314
|
+
author: { login: string } | null;
|
|
315
|
+
body: string;
|
|
316
|
+
path: string | null;
|
|
317
|
+
line: number | null;
|
|
318
|
+
createdAt: string;
|
|
319
|
+
}>;
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface RawComment {
|
|
324
|
+
id: string;
|
|
325
|
+
isMinimized: boolean;
|
|
326
|
+
author: { login: string } | null;
|
|
327
|
+
body: string;
|
|
328
|
+
createdAt: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface RawReview {
|
|
332
|
+
id: string;
|
|
333
|
+
author: { login: string } | null;
|
|
334
|
+
body: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
type RawContextNode =
|
|
338
|
+
| {
|
|
339
|
+
__typename: "CheckRun";
|
|
340
|
+
name: string;
|
|
341
|
+
status: string;
|
|
342
|
+
conclusion: string | null;
|
|
343
|
+
detailsUrl: string | null;
|
|
344
|
+
checkSuite: { workflowRun: { event: string } | null } | null;
|
|
345
|
+
}
|
|
346
|
+
| {
|
|
347
|
+
__typename: "StatusContext";
|
|
348
|
+
context: string;
|
|
349
|
+
state: string;
|
|
350
|
+
targetUrl: string | null;
|
|
351
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around the `gh` CLI for GraphQL and REST calls.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import type { MergeableState, MergeStateStatus } from "../types.mts";
|
|
8
|
+
import { loadConfig } from "../config/load.mts";
|
|
9
|
+
|
|
10
|
+
const execFile = promisify(execFileCb);
|
|
11
|
+
|
|
12
|
+
export interface RateLimitInfo {
|
|
13
|
+
remaining: number;
|
|
14
|
+
limit: number;
|
|
15
|
+
resetAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GraphQlResult<T = unknown> {
|
|
19
|
+
data: T;
|
|
20
|
+
rateLimit?: RateLimitInfo;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// GraphQL
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Execute a GraphQL query via `gh api graphql`.
|
|
29
|
+
*
|
|
30
|
+
* @param query The full GraphQL query/mutation string.
|
|
31
|
+
* @param vars Key-value pairs forwarded as `-f key=value` or `-F key=value`.
|
|
32
|
+
* Numeric values are passed with `-F`; everything else with `-f`.
|
|
33
|
+
*/
|
|
34
|
+
export async function graphql<T = unknown>(
|
|
35
|
+
query: string,
|
|
36
|
+
vars: Record<string, string | number | boolean> = {},
|
|
37
|
+
): Promise<GraphQlResult<T>> {
|
|
38
|
+
const args = buildGraphqlArgs(query, vars);
|
|
39
|
+
const raw = await runGh(args);
|
|
40
|
+
const parsed = JSON.parse(raw) as { data: T; errors?: Array<{ message: string }> };
|
|
41
|
+
|
|
42
|
+
if (parsed.errors?.length) {
|
|
43
|
+
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
44
|
+
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { data: parsed.data };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Like {@link graphql} but also returns the `x-ratelimit-remaining` header. */
|
|
51
|
+
export async function graphqlWithRateLimit<T = unknown>(
|
|
52
|
+
query: string,
|
|
53
|
+
vars: Record<string, string | number | boolean> = {},
|
|
54
|
+
): Promise<GraphQlResult<T>> {
|
|
55
|
+
// --include must come after 'api' (it's a flag for `gh api`, not for `gh`).
|
|
56
|
+
const [api, ...extraArgs] = buildGraphqlArgs(query, vars);
|
|
57
|
+
const args = [api!, "--include", ...extraArgs];
|
|
58
|
+
const raw = await runGh(args);
|
|
59
|
+
|
|
60
|
+
// `gh api -i` prepends HTTP headers before the JSON body.
|
|
61
|
+
// Handle both CRLF (\r\n\r\n) and LF-only (\n\n) header separators.
|
|
62
|
+
const crlfEnd = raw.indexOf("\r\n\r\n");
|
|
63
|
+
const lfEnd = raw.indexOf("\n\n");
|
|
64
|
+
const headerEnd = crlfEnd >= 0 ? crlfEnd : lfEnd;
|
|
65
|
+
const headerSection = headerEnd >= 0 ? raw.slice(0, headerEnd) : "";
|
|
66
|
+
const body = headerEnd >= 0 ? raw.slice(headerEnd + (crlfEnd >= 0 ? 4 : 2)) : raw;
|
|
67
|
+
|
|
68
|
+
const remaining = parseHeaderNumber(headerSection, "x-ratelimit-remaining");
|
|
69
|
+
const limit = parseHeaderNumber(headerSection, "x-ratelimit-limit");
|
|
70
|
+
const resetAt = parseHeaderNumber(headerSection, "x-ratelimit-reset");
|
|
71
|
+
|
|
72
|
+
const parsed = JSON.parse(body) as { data: T; errors?: Array<{ message: string }> };
|
|
73
|
+
|
|
74
|
+
if (parsed.errors?.length) {
|
|
75
|
+
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
76
|
+
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
data: parsed.data,
|
|
81
|
+
rateLimit:
|
|
82
|
+
remaining !== null && limit !== null && resetAt !== null
|
|
83
|
+
? { remaining, limit, resetAt }
|
|
84
|
+
: undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Repo / PR lookup helpers
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export interface RepoInfo {
|
|
93
|
+
owner: string;
|
|
94
|
+
name: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Returns the current repo's owner and name from `gh repo view`. */
|
|
98
|
+
export async function getRepoInfo(): Promise<RepoInfo> {
|
|
99
|
+
const raw = await runGh(["repo", "view", "--json", "owner,name"]);
|
|
100
|
+
const parsed = JSON.parse(raw) as { owner: { login: string }; name: string };
|
|
101
|
+
return { owner: parsed.owner.login, name: parsed.name };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Derives the PR number for the current HEAD branch.
|
|
106
|
+
* Returns null if no open PR is found.
|
|
107
|
+
*/
|
|
108
|
+
export async function getCurrentPrNumber(): Promise<number | null> {
|
|
109
|
+
try {
|
|
110
|
+
const branch = await getCurrentBranch();
|
|
111
|
+
// In detached HEAD state git returns "HEAD" — no branch name to look up.
|
|
112
|
+
if (branch === "HEAD") return null;
|
|
113
|
+
const raw = await runGh([
|
|
114
|
+
"pr",
|
|
115
|
+
"list",
|
|
116
|
+
"--head",
|
|
117
|
+
branch,
|
|
118
|
+
"--json",
|
|
119
|
+
"number",
|
|
120
|
+
"--jq",
|
|
121
|
+
".[0].number",
|
|
122
|
+
]);
|
|
123
|
+
const trimmed = raw.trim();
|
|
124
|
+
if (!trimmed || trimmed === "null") return null;
|
|
125
|
+
return parseInt(trimmed, 10);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function getCurrentBranch(): Promise<string> {
|
|
132
|
+
await runGh(["--version"]); // warm up gh CLI before git call
|
|
133
|
+
// Use git directly for branch name — gh doesn't expose it.
|
|
134
|
+
const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
135
|
+
return stdout.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
|
|
139
|
+
export async function getPrHeadSha(pr: number, owner: string, name: string): Promise<string> {
|
|
140
|
+
const raw = await runGh(["api", `repos/${owner}/${name}/pulls/${pr}`, "--jq", ".head.sha"]);
|
|
141
|
+
return raw.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fetches `mergeable` and `mergeStateStatus` via the REST API (`gh pr view`).
|
|
146
|
+
*
|
|
147
|
+
* Used as a fallback when the GraphQL API returns `UNKNOWN` for these fields —
|
|
148
|
+
* a known GitHub quirk where GraphQL lags behind the REST layer.
|
|
149
|
+
*/
|
|
150
|
+
export async function getMergeableState(
|
|
151
|
+
pr: number,
|
|
152
|
+
owner: string,
|
|
153
|
+
repo: string,
|
|
154
|
+
): Promise<{ mergeable: MergeableState; mergeStateStatus: MergeStateStatus }> {
|
|
155
|
+
const raw = await runGh([
|
|
156
|
+
"pr",
|
|
157
|
+
"view",
|
|
158
|
+
String(pr),
|
|
159
|
+
"--repo",
|
|
160
|
+
`${owner}/${repo}`,
|
|
161
|
+
"--json",
|
|
162
|
+
"mergeable,mergeStateStatus",
|
|
163
|
+
]);
|
|
164
|
+
const parsed = JSON.parse(raw) as {
|
|
165
|
+
mergeable: MergeableState;
|
|
166
|
+
mergeStateStatus: MergeStateStatus;
|
|
167
|
+
};
|
|
168
|
+
return parsed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Internal helpers
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
function buildGraphqlArgs(
|
|
176
|
+
query: string,
|
|
177
|
+
vars: Record<string, string | number | boolean>,
|
|
178
|
+
): string[] {
|
|
179
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
180
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
181
|
+
if (typeof v === "number" || typeof v === "boolean") {
|
|
182
|
+
args.push("-F", `${k}=${String(v)}`);
|
|
183
|
+
} else {
|
|
184
|
+
args.push("-f", `${k}=${v}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return args;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runGh(args: string[]): Promise<string> {
|
|
191
|
+
try {
|
|
192
|
+
const { stdout } = await execFile("gh", args, {
|
|
193
|
+
maxBuffer: loadConfig().execution.maxBufferMb * 1024 * 1024,
|
|
194
|
+
});
|
|
195
|
+
return stdout;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// Re-throw with a more useful message
|
|
198
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
199
|
+
throw new Error(`gh ${args[0] ?? ""} failed: ${msg}`, { cause: err });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseHeaderNumber(headers: string, name: string): number | null {
|
|
204
|
+
const re = new RegExp(`^${name}:\\s*(\\d+)`, "im");
|
|
205
|
+
const m = re.exec(headers);
|
|
206
|
+
return m ? parseInt(m[1]!, 10) : null;
|
|
207
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for shepherd/github/client.mts — pure helper logic.
|
|
3
|
+
* Uses vi.mock for execFile — lives in client.mock.test.mts.
|
|
4
|
+
* This file covers things that don't need mocking (e.g. argument builders).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from "vitest";
|
|
8
|
+
|
|
9
|
+
// Test that the module exports exist (smoke test — real calls require gh CLI).
|
|
10
|
+
describe("client module", () => {
|
|
11
|
+
it("exports the expected functions", async () => {
|
|
12
|
+
const module = await import("./client.mts");
|
|
13
|
+
expect(typeof module.graphql).toBe("function");
|
|
14
|
+
expect(typeof module.getRepoInfo).toBe("function");
|
|
15
|
+
expect(typeof module.getCurrentPrNumber).toBe("function");
|
|
16
|
+
expect(typeof module.getPrHeadSha).toBe("function");
|
|
17
|
+
expect(typeof module.graphqlWithRateLimit).toBe("function");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
query BatchPr(
|
|
2
|
+
$owner: String!
|
|
3
|
+
$repo: String!
|
|
4
|
+
$pr: Int!
|
|
5
|
+
$threadsCursor: String
|
|
6
|
+
$checksCursor: String
|
|
7
|
+
$commentsCursor: String
|
|
8
|
+
$reviewsCursor: String
|
|
9
|
+
) {
|
|
10
|
+
repository(owner: $owner, name: $repo) {
|
|
11
|
+
pullRequest(number: $pr) {
|
|
12
|
+
number
|
|
13
|
+
state
|
|
14
|
+
isDraft
|
|
15
|
+
mergeable
|
|
16
|
+
mergeStateStatus
|
|
17
|
+
reviewDecision
|
|
18
|
+
headRefOid
|
|
19
|
+
# Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
|
|
20
|
+
reviewRequests(last: 50) {
|
|
21
|
+
nodes {
|
|
22
|
+
requestedReviewer {
|
|
23
|
+
... on Bot {
|
|
24
|
+
login
|
|
25
|
+
}
|
|
26
|
+
... on User {
|
|
27
|
+
login
|
|
28
|
+
}
|
|
29
|
+
... on Team {
|
|
30
|
+
name
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
latestReviews(last: 100) {
|
|
36
|
+
nodes {
|
|
37
|
+
author {
|
|
38
|
+
login
|
|
39
|
+
}
|
|
40
|
+
state
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
reviewThreads(last: 100, before: $threadsCursor) {
|
|
44
|
+
pageInfo {
|
|
45
|
+
hasPreviousPage
|
|
46
|
+
startCursor
|
|
47
|
+
}
|
|
48
|
+
nodes {
|
|
49
|
+
id
|
|
50
|
+
isResolved
|
|
51
|
+
isOutdated
|
|
52
|
+
# first: 1 — we want the reviewer's original comment, not the latest reply.
|
|
53
|
+
comments(first: 1) {
|
|
54
|
+
nodes {
|
|
55
|
+
id
|
|
56
|
+
author {
|
|
57
|
+
login
|
|
58
|
+
}
|
|
59
|
+
body
|
|
60
|
+
path
|
|
61
|
+
line
|
|
62
|
+
createdAt
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
comments(last: 100, before: $commentsCursor) {
|
|
68
|
+
pageInfo {
|
|
69
|
+
hasPreviousPage
|
|
70
|
+
startCursor
|
|
71
|
+
}
|
|
72
|
+
nodes {
|
|
73
|
+
id
|
|
74
|
+
isMinimized
|
|
75
|
+
author {
|
|
76
|
+
login
|
|
77
|
+
}
|
|
78
|
+
body
|
|
79
|
+
createdAt
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
reviews(states: CHANGES_REQUESTED, last: 50, before: $reviewsCursor) {
|
|
83
|
+
pageInfo {
|
|
84
|
+
hasPreviousPage
|
|
85
|
+
startCursor
|
|
86
|
+
}
|
|
87
|
+
nodes {
|
|
88
|
+
id
|
|
89
|
+
author {
|
|
90
|
+
login
|
|
91
|
+
}
|
|
92
|
+
body
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
commits(last: 1) {
|
|
96
|
+
nodes {
|
|
97
|
+
commit {
|
|
98
|
+
statusCheckRollup {
|
|
99
|
+
contexts(first: 100, after: $checksCursor) {
|
|
100
|
+
pageInfo {
|
|
101
|
+
hasNextPage
|
|
102
|
+
endCursor
|
|
103
|
+
}
|
|
104
|
+
nodes {
|
|
105
|
+
__typename
|
|
106
|
+
... on CheckRun {
|
|
107
|
+
name
|
|
108
|
+
status
|
|
109
|
+
conclusion
|
|
110
|
+
detailsUrl
|
|
111
|
+
checkSuite {
|
|
112
|
+
workflowRun {
|
|
113
|
+
event
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
... on StatusContext {
|
|
118
|
+
context
|
|
119
|
+
state
|
|
120
|
+
targetUrl
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|