@brainervirus/workit-core 2.3.0 → 2.5.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.
@@ -0,0 +1,529 @@
1
+ // GitHub adapter: `gh api graphql` for PR state, checks and review threads,
2
+ // `gh api` REST for required checks, job logs and reruns (design §2.0).
3
+ import { apiJson, apiText, apiWrite, cliFailure, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
4
+ import { logTail, redactText, shortBody } from "./redact";
5
+ import {
6
+ failure,
7
+ success,
8
+ type Forge,
9
+ type ForgeCheck,
10
+ type ForgePrStatus,
11
+ type ForgeResult,
12
+ type ForgeThread,
13
+ type PrRef,
14
+ type PrState,
15
+ } from "./types";
16
+
17
+ /** Page cap for review threads and check contexts (100 per page). */
18
+ export const GITHUB_MAX_PAGES = 10;
19
+
20
+ const PR_FIELDS = `number url state isDraft isInMergeQueue mergeable mergeStateStatus baseRefName baseRefOid baseRef { target { oid } } headRefName headRefOid reviewDecision`;
21
+
22
+ const THREADS = `reviewThreads(first: 100, after: $threads) { pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated path line comments(first: 1) { nodes { author { login __typename } body url } } } }`;
23
+
24
+ // isRequired: only checks branch protection requires gate the merge.
25
+ const CONTEXTS = `contexts(first: 100, after: $contexts) { pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { name status conclusion detailsUrl databaseId isRequired(pullRequestNumber: $number) checkSuite { workflowRun { databaseId workflow { name } } } } ... on StatusContext { context state targetUrl isRequired(pullRequestNumber: $number) } } }`;
26
+
27
+ export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threads: String, $contexts: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_FIELDS} ${THREADS} commits(last: 1) { nodes { commit { oid statusCheckRollup { state ${CONTEXTS} } } } } } } }`;
28
+
29
+ export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state headRefName headRefOid headRepositoryOwner { login } } } } }`;
30
+
31
+ type Page<T> = { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: T[] };
32
+
33
+ type GqlThread = {
34
+ id: string;
35
+ isResolved: boolean;
36
+ isOutdated: boolean;
37
+ path: string | null;
38
+ line: number | null;
39
+ comments: {
40
+ nodes: Array<{
41
+ author: { login: string; __typename: string } | null;
42
+ body: string;
43
+ url: string;
44
+ }>;
45
+ };
46
+ };
47
+
48
+ type GqlContext =
49
+ | {
50
+ __typename: "CheckRun";
51
+ name: string;
52
+ status: string;
53
+ conclusion: string | null;
54
+ detailsUrl: string | null;
55
+ databaseId: number | null;
56
+ isRequired?: boolean | null;
57
+ checkSuite: {
58
+ workflowRun: { databaseId: number; workflow: { name: string } | null } | null;
59
+ } | null;
60
+ }
61
+ | {
62
+ __typename: "StatusContext";
63
+ context: string;
64
+ state: string;
65
+ targetUrl: string | null;
66
+ isRequired?: boolean | null;
67
+ };
68
+
69
+ type GqlPr = {
70
+ number: number;
71
+ url: string;
72
+ state: "OPEN" | "CLOSED" | "MERGED";
73
+ isDraft: boolean;
74
+ isInMergeQueue?: boolean;
75
+ mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
76
+ mergeStateStatus: string;
77
+ baseRefName: string;
78
+ /** baseRefOid is the base at the PR's last sync; baseRef.target is the live tip. */
79
+ baseRefOid: string | null;
80
+ baseRef?: { target: { oid: string } | null } | null;
81
+ headRefName: string;
82
+ headRefOid: string;
83
+ reviewDecision: string | null;
84
+ reviewThreads: Page<GqlThread>;
85
+ commits: {
86
+ nodes: Array<{
87
+ commit: {
88
+ oid: string;
89
+ statusCheckRollup: { state: string; contexts: Page<GqlContext> } | null;
90
+ };
91
+ }>;
92
+ };
93
+ };
94
+
95
+ type GqlResponse<T> = { data?: T; errors?: Array<{ message?: string; type?: string }> };
96
+
97
+ const prState = (value: string): PrState =>
98
+ value === "MERGED" ? "merged" : value === "CLOSED" ? "closed" : "open";
99
+
100
+ // GitHub Actions conclusions that fail a PR. NEUTRAL/SKIPPED/SUCCESS pass;
101
+ // STALE is superseded by a newer run and is ignored.
102
+ const FAILING = new Set([
103
+ "FAILURE",
104
+ "TIMED_OUT",
105
+ "CANCELLED",
106
+ "ACTION_REQUIRED",
107
+ "STARTUP_FAILURE",
108
+ ]);
109
+
110
+ const safeUrl = (value: string | null): string | null => (value ? redactText(value) : null);
111
+
112
+ export function checkFromContext(node: GqlContext): ForgeCheck | null {
113
+ const required = node.isRequired ?? null;
114
+ if (node.__typename === "StatusContext") {
115
+ const state = node.state.toUpperCase();
116
+ return {
117
+ name: node.context,
118
+ context: node.context,
119
+ state:
120
+ state === "SUCCESS"
121
+ ? "passing"
122
+ : state === "FAILURE" || state === "ERROR"
123
+ ? "failing"
124
+ : "pending",
125
+ conclusion: state.toLowerCase(),
126
+ url: safeUrl(node.targetUrl),
127
+ runId: null,
128
+ jobId: null,
129
+ scope: null,
130
+ required,
131
+ };
132
+ }
133
+ if (node.conclusion === "STALE") return null;
134
+ const run = node.checkSuite?.workflowRun ?? null;
135
+ const workflow = run?.workflow?.name;
136
+ const completed = node.status === "COMPLETED";
137
+ const conclusion = node.conclusion?.toUpperCase() ?? null;
138
+ return {
139
+ name: workflow ? `${workflow} / ${node.name}` : node.name,
140
+ context: node.name,
141
+ state: !completed
142
+ ? "pending"
143
+ : conclusion && FAILING.has(conclusion)
144
+ ? "failing"
145
+ : conclusion === "SKIPPED" || conclusion === "NEUTRAL"
146
+ ? "skipped"
147
+ : "passing",
148
+ conclusion: completed ? (conclusion?.toLowerCase() ?? null) : node.status.toLowerCase(),
149
+ url: safeUrl(node.detailsUrl),
150
+ runId: run?.databaseId ?? null,
151
+ // Only Actions check runs have a job log behind their database id.
152
+ jobId: run ? (node.databaseId ?? null) : null,
153
+ scope: null,
154
+ required,
155
+ };
156
+ }
157
+
158
+ /** One check per name: a re-run check replaces its earlier attempt. */
159
+ export function latestPerName(checks: readonly ForgeCheck[]): ForgeCheck[] {
160
+ const byName = new Map<string, ForgeCheck>();
161
+ for (const check of checks) {
162
+ const seen = byName.get(check.name);
163
+ if (!seen || (check.jobId ?? 0) >= (seen.jobId ?? 0)) byName.set(check.name, check);
164
+ }
165
+ return [...byName.values()];
166
+ }
167
+
168
+ function threadFrom(node: GqlThread): ForgeThread {
169
+ const first = node.comments.nodes[0];
170
+ return {
171
+ id: node.id,
172
+ path: node.path,
173
+ line: node.line,
174
+ author: first?.author?.login ?? null,
175
+ isBot: first?.author?.__typename === "Bot",
176
+ outdated: node.isOutdated,
177
+ url: safeUrl(first?.url ?? null),
178
+ body: shortBody(first?.body ?? ""),
179
+ };
180
+ }
181
+
182
+ const encodeRef = (ref: string): string => ref.split("/").map(encodeURIComponent).join("/");
183
+
184
+ export function createGitHubForge(options: {
185
+ apiHost: string;
186
+ repo: string;
187
+ runner: ForgeRunner;
188
+ maxPages?: number;
189
+ }): Forge {
190
+ const { apiHost, repo, runner } = options;
191
+ const maxPages = options.maxPages ?? GITHUB_MAX_PAGES;
192
+ const [owner, name] = repo.split("/");
193
+
194
+ const rest = <T>(endpoint: string, what: string): ForgeResult<T> =>
195
+ apiJson<T>(runner, "gh", apiHost, ["api", endpoint], what);
196
+
197
+ const graphql = <T>(
198
+ query: string,
199
+ variables: Record<string, string | number | null>,
200
+ what: string,
201
+ ): ForgeResult<T> => {
202
+ const args = ["api", "graphql", "-f", `query=${query}`];
203
+ for (const [key, value] of Object.entries(variables)) {
204
+ if (value === null) continue;
205
+ args.push(typeof value === "number" ? "-F" : "-f", `${key}=${value}`);
206
+ }
207
+ const response = apiJson<GqlResponse<T>>(
208
+ runner,
209
+ "gh",
210
+ apiHost,
211
+ args,
212
+ what,
213
+ FORGE_TIMEOUTS.api,
214
+ { graphql: true },
215
+ );
216
+ if (!response.ok) return response;
217
+ if (response.data.errors?.length) {
218
+ const message = redactText(response.data.errors[0]?.message ?? "GraphQL error").slice(0, 300);
219
+ return /could not resolve to a (pullrequest|repository)/iu.test(message)
220
+ ? failure("not_found", `${what}: ${message}`)
221
+ : failure("failed", `${what}: ${message}`);
222
+ }
223
+ if (!response.data.data) return failure("failed", `${what}: empty GraphQL response`);
224
+ return success(response.data.data);
225
+ };
226
+
227
+ const fetchPr = (
228
+ number: number,
229
+ cursors: { threads: string | null; contexts: string | null },
230
+ ): ForgeResult<GqlPr> => {
231
+ const data = graphql<{ repository: { pullRequest: GqlPr | null } | null }>(
232
+ PR_STATUS_QUERY,
233
+ { owner, name, number, threads: cursors.threads, contexts: cursors.contexts },
234
+ `pull request #${number}`,
235
+ );
236
+ if (!data.ok) return data;
237
+ const pr = data.data.repository?.pullRequest;
238
+ return pr ? success(pr) : failure("not_found", `pull request #${number} was not found`);
239
+ };
240
+
241
+ /**
242
+ * Contexts the base branch requires: classic branch protection (readable
243
+ * through the branch endpoint without admin) plus branch rulesets. Null
244
+ * when either source could not be read.
245
+ */
246
+ const requiredContexts = (base: string): string[] | null => {
247
+ const branch = rest<{
248
+ protected?: boolean;
249
+ protection?: {
250
+ required_status_checks?: { contexts?: string[]; checks?: Array<{ context?: string }> };
251
+ };
252
+ }>(`repos/${repo}/branches/${encodeRef(base)}`, `branch ${base}`);
253
+ const rules = rest<Array<{ type?: string; parameters?: Record<string, unknown> }>>(
254
+ `repos/${repo}/rules/branches/${encodeRef(base)}`,
255
+ `rules for ${base}`,
256
+ );
257
+ if (!branch.ok || !rules.ok || !Array.isArray(rules.data)) return null;
258
+ const contexts = new Set<string>();
259
+ const classic = branch.data.protection?.required_status_checks;
260
+ for (const context of classic?.contexts ?? []) contexts.add(context);
261
+ for (const check of classic?.checks ?? []) if (check.context) contexts.add(check.context);
262
+ for (const rule of rules.data)
263
+ if (rule.type === "required_status_checks") {
264
+ const listed = rule.parameters?.required_status_checks;
265
+ if (Array.isArray(listed))
266
+ for (const item of listed as Array<{ context?: unknown }>)
267
+ if (typeof item.context === "string") contexts.add(item.context);
268
+ }
269
+ return [...contexts];
270
+ };
271
+
272
+ return {
273
+ kind: "github",
274
+ apiHost,
275
+ repo,
276
+
277
+ identity(expected) {
278
+ const run = runner("gh", ["api", "user"], { timeoutMs: FORGE_TIMEOUTS.api });
279
+ if (run.status !== 0) {
280
+ // App/Actions installation tokens cannot read /user; the token is
281
+ // still valid for reads, so the check is skipped, not failed.
282
+ if (/HTTP 403|resource not accessible by integration/iu.test(run.stderr))
283
+ return success({
284
+ login: null,
285
+ expected,
286
+ matches: null,
287
+ note: "the credential cannot read /user (app or Actions token); identity not checked",
288
+ });
289
+ return cliFailure("gh", apiHost, run, "user", FORGE_TIMEOUTS.api);
290
+ }
291
+ let login = "";
292
+ try {
293
+ const user = JSON.parse(run.stdout) as { login?: unknown };
294
+ login = typeof user.login === "string" ? user.login : "";
295
+ } catch {
296
+ // handled below
297
+ }
298
+ if (!login) return failure("failed", "gh api user returned no login");
299
+ return success({
300
+ login,
301
+ expected,
302
+ matches: expected ? login.toLowerCase() === expected.toLowerCase() : null,
303
+ });
304
+ },
305
+
306
+ repoInfo(target) {
307
+ const info = rest<{ id?: number; fork?: boolean; parent?: { full_name?: string } }>(
308
+ `repos/${target}`,
309
+ `repository ${target}`,
310
+ );
311
+ if (!info.ok) return info;
312
+ return success({
313
+ id: typeof info.data.id === "number" ? info.data.id : null,
314
+ parent: info.data.fork && info.data.parent?.full_name ? info.data.parent.full_name : null,
315
+ });
316
+ },
317
+
318
+ findPr(head, match) {
319
+ const data = graphql<{
320
+ repository: {
321
+ pullRequests: {
322
+ nodes: Array<{
323
+ number: number;
324
+ url: string;
325
+ state: string;
326
+ headRefName: string;
327
+ headRefOid: string | null;
328
+ headRepositoryOwner: { login: string } | null;
329
+ }>;
330
+ };
331
+ } | null;
332
+ }>(FIND_PR_QUERY, { owner, name, head }, `pull requests for ${head}`);
333
+ if (!data.ok) return data;
334
+ const nodes = data.data.repository?.pullRequests.nodes ?? [];
335
+ // A branch name alone proves nothing: anyone can open a PR from a
336
+ // same-named branch in their fork. The head owner or head sha must match.
337
+ const ours = nodes.filter(
338
+ (node) =>
339
+ node.headRefName === head &&
340
+ ((match.owner !== null &&
341
+ node.headRepositoryOwner?.login.toLowerCase() === match.owner.toLowerCase()) ||
342
+ (match.sha !== null && node.headRefOid === match.sha)),
343
+ );
344
+ const pick = ours.find((node) => node.state === "OPEN") ?? ours[0];
345
+ if (!pick) return success(null);
346
+ const ref: PrRef = {
347
+ number: pick.number,
348
+ url: pick.url,
349
+ state: prState(pick.state),
350
+ headBranch: pick.headRefName,
351
+ headSha: pick.headRefOid,
352
+ };
353
+ return success(ref);
354
+ },
355
+
356
+ prStatus(number) {
357
+ const first = fetchPr(number, { threads: null, contexts: null });
358
+ if (!first.ok) return first;
359
+ const pr = first.data;
360
+ const threads = [...pr.reviewThreads.nodes];
361
+ const rollup = pr.commits.nodes[0]?.commit.statusCheckRollup ?? null;
362
+ const contexts = [...(rollup?.contexts.nodes ?? [])];
363
+ let threadPage = pr.reviewThreads.pageInfo;
364
+ let contextPage = rollup?.contexts.pageInfo ?? { hasNextPage: false, endCursor: null };
365
+ let pages = 1;
366
+ // Each follow-up query advances whichever list still has pages.
367
+ while ((threadPage.hasNextPage || contextPage.hasNextPage) && pages < maxPages) {
368
+ pages += 1;
369
+ const next = fetchPr(number, {
370
+ threads: threadPage.hasNextPage ? threadPage.endCursor : null,
371
+ contexts: contextPage.hasNextPage ? contextPage.endCursor : null,
372
+ });
373
+ if (!next.ok) return next;
374
+ if (next.data.headRefOid !== pr.headRefOid)
375
+ return failure("failed", `pull request #${number} head moved while paging; retry`);
376
+ if (threadPage.hasNextPage) {
377
+ threads.push(...next.data.reviewThreads.nodes);
378
+ threadPage = next.data.reviewThreads.pageInfo;
379
+ }
380
+ const nextRollup = next.data.commits.nodes[0]?.commit.statusCheckRollup;
381
+ if (contextPage.hasNextPage && nextRollup) {
382
+ contexts.push(...nextRollup.contexts.nodes);
383
+ contextPage = nextRollup.contexts.pageInfo;
384
+ }
385
+ }
386
+ const checks = latestPerName(
387
+ contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
388
+ );
389
+ const open = pr.state === "OPEN";
390
+ const status: ForgePrStatus = {
391
+ number: pr.number,
392
+ url: pr.url,
393
+ state: prState(pr.state),
394
+ draft: pr.isDraft,
395
+ base: pr.baseRefName,
396
+ baseSha: pr.baseRef?.target?.oid ?? pr.baseRefOid,
397
+ head: { branch: pr.headRefName, sha: pr.headRefOid },
398
+ mergeable:
399
+ pr.mergeable === "MERGEABLE" ? "yes" : pr.mergeable === "CONFLICTING" ? "no" : "unknown",
400
+ conflicts: pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY",
401
+ // BEHIND: branch protection requires an up-to-date head and it is not.
402
+ rebaseRequired: pr.mergeStateStatus === "BEHIND",
403
+ reviewDecision: pr.reviewDecision?.toLowerCase() ?? null,
404
+ mergeState: pr.mergeStateStatus?.toLowerCase() ?? null,
405
+ inMergeQueue: pr.isInMergeQueue === true,
406
+ checks,
407
+ requiredContexts: open ? requiredContexts(pr.baseRefName) : null,
408
+ threads: threads.filter((node) => !node.isResolved).map(threadFrom),
409
+ truncated: threadPage.hasNextPage || contextPage.hasNextPage,
410
+ };
411
+ return success(status);
412
+ },
413
+
414
+ jobLogTail(job, lines) {
415
+ const text = apiText(
416
+ runner,
417
+ "gh",
418
+ apiHost,
419
+ ["api", `repos/${repo}/actions/jobs/${job.jobId}/logs`],
420
+ `job ${job.jobId} log`,
421
+ FORGE_TIMEOUTS.log,
422
+ );
423
+ return text.ok ? success(logTail(text.data, lines)) : text;
424
+ },
425
+
426
+ rerun(target) {
427
+ const endpoint =
428
+ target.kind === "failed_in_run"
429
+ ? `repos/${repo}/actions/runs/${target.runId}/rerun-failed-jobs`
430
+ : `repos/${repo}/actions/jobs/${target.jobId}/rerun`;
431
+ const what =
432
+ target.kind === "failed_in_run"
433
+ ? `rerun of run ${target.runId}`
434
+ : `rerun of job ${target.jobId}`;
435
+ const run = apiText(
436
+ runner,
437
+ "gh",
438
+ apiHost,
439
+ ["api", "-X", "POST", endpoint],
440
+ what,
441
+ FORGE_TIMEOUTS.api,
442
+ );
443
+ return run.ok ? success(undefined) : run;
444
+ },
445
+
446
+ createPr(input) {
447
+ // Cross-repository PRs name the head as owner:branch.
448
+ const headOwner = input.headRepo.split("/")[0];
449
+ const head = input.headRepo === repo ? input.head : `${headOwner}:${input.head}`;
450
+ const created = apiWrite<{
451
+ number?: number;
452
+ html_url?: string;
453
+ state?: string;
454
+ merged?: boolean;
455
+ head?: { ref?: string; sha?: string };
456
+ }>(
457
+ runner,
458
+ "gh",
459
+ apiHost,
460
+ [
461
+ "api",
462
+ "-X",
463
+ "POST",
464
+ `repos/${repo}/pulls`,
465
+ "-f",
466
+ `title=${input.title}`,
467
+ "-f",
468
+ `head=${head}`,
469
+ "-f",
470
+ `base=${input.base}`,
471
+ "-f",
472
+ `body=${input.body}`,
473
+ "-F",
474
+ `draft=${input.draft ? "true" : "false"}`,
475
+ ],
476
+ `pull request for ${input.head}`,
477
+ );
478
+ if (!created.ok) return created;
479
+ const value = created.data;
480
+ if (typeof value.number !== "number" || typeof value.html_url !== "string")
481
+ return failure("failed", "gh api pulls returned no pull request number");
482
+ return success({
483
+ number: value.number,
484
+ url: value.html_url,
485
+ state: value.merged ? "merged" : value.state === "closed" ? "closed" : "open",
486
+ headBranch: value.head?.ref ?? input.head,
487
+ headSha: value.head?.sha ?? null,
488
+ });
489
+ },
490
+
491
+ merge(number, mergeOptions) {
492
+ // sha= is GitHub's atomic head guard: 409 when the head moved.
493
+ const merged = apiWrite<{ sha?: string; merged?: boolean; message?: string }>(
494
+ runner,
495
+ "gh",
496
+ apiHost,
497
+ [
498
+ "api",
499
+ "-X",
500
+ "PUT",
501
+ `repos/${repo}/pulls/${number}/merge`,
502
+ "-f",
503
+ `sha=${mergeOptions.sha}`,
504
+ "-f",
505
+ `merge_method=${mergeOptions.method}`,
506
+ ],
507
+ `merge of pull request #${number}`,
508
+ );
509
+ if (!merged.ok) return merged;
510
+ if (merged.data.merged === false)
511
+ return failure(
512
+ "blocked",
513
+ `merge of pull request #${number} refused: ${redactText(merged.data.message ?? "not merged").slice(0, 200)}`,
514
+ );
515
+ return success({ mergeSha: typeof merged.data.sha === "string" ? merged.data.sha : null });
516
+ },
517
+
518
+ updateBase(number, base) {
519
+ const updated = apiWrite<unknown>(
520
+ runner,
521
+ "gh",
522
+ apiHost,
523
+ ["api", "-X", "PATCH", `repos/${repo}/pulls/${number}`, "-f", `base=${base}`],
524
+ `retarget of pull request #${number}`,
525
+ );
526
+ return updated.ok ? success(undefined) : updated;
527
+ },
528
+ };
529
+ }