@trim21/personal-pi-extensions 0.1.557 → 0.1.558

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/src/gh/base.ts ADDED
@@ -0,0 +1,851 @@
1
+ /**
2
+ * Shared infrastructure of the GitHub read-only tools: the `gh` subprocess
3
+ * layer, result shaping, the octokit-backed client, and the checks-wait
4
+ * pipeline. Each tool lives in `tools/<tool-name>.ts`; anything used by more
5
+ * than one tool belongs here.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import { delimiter, join } from "node:path";
11
+
12
+ import { Type } from "typebox";
13
+ import { Value } from "typebox/value";
14
+
15
+ import {
16
+ type ActionJob,
17
+ type CheckRun,
18
+ type CommitStatus,
19
+ createGithubChecks,
20
+ createGithubSearch,
21
+ type GithubChecksClient,
22
+ type GithubSearch,
23
+ renderHits,
24
+ } from "../lib/github.js";
25
+ import { type ToolPendant } from "../lib/pendant.js";
26
+ import { createHttpProxy } from "../lib/proxy.js";
27
+
28
+ /**
29
+ * 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
30
+ * `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
31
+ */
32
+ export const httpProxy = createHttpProxy();
33
+
34
+ /** A tool result: what the model sees plus the structured details payload. */
35
+ export interface ToolResult {
36
+ content: { type: "text"; text: string }[];
37
+ details: Record<string, unknown>;
38
+ }
39
+
40
+ /** What a toolcall handler receives from the framework. */
41
+ export interface ToolCall<Params> {
42
+ params: Params;
43
+ ctx: { cwd?: string };
44
+ signal?: AbortSignal;
45
+ /** Streaming progress updates, passed through as-is. */
46
+ onUpdate?: (update: ToolResult) => void;
47
+ }
48
+
49
+ export interface GhResult {
50
+ stdout: string;
51
+ stderr: string;
52
+ code: number;
53
+ killed: boolean;
54
+ combined: string;
55
+ /** Why the process was killed, when `killed` is true. */
56
+ reason?: "timeout" | "abort";
57
+ /** When the process could not be started at all (e.g. `gh` not found in PATH). */
58
+ spawnError?: string;
59
+ }
60
+
61
+ /**
62
+ * Check whether the `gh` CLI is on the system, scanning PATH like
63
+ * `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
64
+ * the model never sees GitHub tools that would fail on every call.
65
+ */
66
+ export function isGhAvailable(): boolean {
67
+ const pathEnv = process.env.PATH ?? "";
68
+ for (const directory of pathEnv.split(delimiter)) {
69
+ if (existsSync(join(directory, "gh"))) return true;
70
+ }
71
+ for (const candidate of ["/usr/bin/gh", "/usr/local/bin/gh", "/run/current-system/sw/bin/gh"]) {
72
+ if (existsSync(candidate)) return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ export function runGh(
78
+ args: string[],
79
+ ctx: {
80
+ cwd?: string;
81
+ signal?: AbortSignal;
82
+ timeout?: number;
83
+ /** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
84
+ env?: NodeJS.ProcessEnv;
85
+ },
86
+ ): Promise<GhResult> {
87
+ return new Promise((resolve) => {
88
+ const proc = spawn("gh", args, {
89
+ cwd: ctx.cwd,
90
+ shell: false,
91
+ stdio: ["ignore", "pipe", "pipe"],
92
+ // gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
93
+ env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
94
+ });
95
+
96
+ let stdout = "";
97
+ let stderr = "";
98
+ const combined: string[] = [];
99
+ let killed = false;
100
+ let killReason: "timeout" | "abort" | undefined;
101
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
102
+ let onAbort: (() => void) | undefined;
103
+
104
+ const killProcess = (reason: "timeout" | "abort") => {
105
+ if (killed) {
106
+ return;
107
+ }
108
+
109
+ killed = true;
110
+ killReason = reason;
111
+ proc.kill("SIGTERM");
112
+ setTimeout(() => {
113
+ if (!proc.killed) proc.kill("SIGKILL");
114
+ }, 5000);
115
+ };
116
+
117
+ if (ctx.signal) {
118
+ onAbort = () => killProcess("abort");
119
+ if (ctx.signal.aborted) {
120
+ killProcess("abort");
121
+ } else {
122
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
123
+ }
124
+ }
125
+
126
+ // Default timeout: 10 minutes. Long operations like downloading a CI job's
127
+ // full log routinely take well over 30s, so a short default would kill them
128
+ // mid-transfer; combined with `code ?? 0` that would silently cache a
129
+ // truncated log as success. A killed process must never look successful.
130
+ const timeout = ctx.timeout ?? 600_000;
131
+ if (timeout > 0) {
132
+ timeoutId = setTimeout(() => killProcess("timeout"), timeout);
133
+ }
134
+
135
+ proc.stdout.on("data", (data: Buffer) => {
136
+ const text = data.toString();
137
+ stdout += text;
138
+ combined.push(text);
139
+ });
140
+ proc.stderr.on("data", (data: Buffer) => {
141
+ const text = data.toString();
142
+ stderr += text;
143
+ combined.push(text);
144
+ });
145
+
146
+ proc.on("close", (code) => {
147
+ if (timeoutId) clearTimeout(timeoutId);
148
+ if (onAbort && ctx.signal) {
149
+ ctx.signal.removeEventListener("abort", onAbort);
150
+ }
151
+ resolve({
152
+ stdout,
153
+ stderr,
154
+ // When killed by a signal the close event's code is null; report the
155
+ // process as failed instead of pretending it succeeded. -1 is a
156
+ // sentinel for "did not exit normally" — distinct from a real gh
157
+ // failure exit code (1), which is always in 0-255.
158
+ code: code ?? (killed ? -1 : 0),
159
+ killed,
160
+ combined: combined.join(""),
161
+ reason: killReason,
162
+ });
163
+ });
164
+
165
+ proc.on("error", (err: Error) => {
166
+ if (timeoutId) clearTimeout(timeoutId);
167
+ if (onAbort && ctx.signal) {
168
+ ctx.signal.removeEventListener("abort", onAbort);
169
+ }
170
+ // spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
171
+ // 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
172
+ resolve({
173
+ stdout,
174
+ stderr,
175
+ code: 1,
176
+ killed,
177
+ combined: combined.join(""),
178
+ reason: killReason,
179
+ spawnError: err.message,
180
+ });
181
+ });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Error thrown by `ghExec` when the `gh` invocation exits non-zero.
187
+ * The message carries the toolcall input (JSON) wrapped in `<input>` markers,
188
+ * and the command output wrapped in `<output>` markers.
189
+ */
190
+ export class GhError extends Error {
191
+ readonly args: string[];
192
+ readonly code: number;
193
+ readonly stdout: string;
194
+ readonly stderr: string;
195
+ readonly input?: unknown;
196
+
197
+ constructor(args: string[], result: GhResult, input?: unknown) {
198
+ const inputText = input === undefined ? "" : `<input>${JSON.stringify(input)}<input>\n`;
199
+ const killedText = result.killed
200
+ ? result.reason === "timeout"
201
+ ? " (command timed out)"
202
+ : result.reason === "abort"
203
+ ? " (command aborted)"
204
+ : ""
205
+ : "";
206
+
207
+ // The process never started (e.g. `gh` not found): surface the spawn error.
208
+ // Otherwise show the command's own output; an empty output with a non-zero
209
+ // exit is explicitly marked, so a bare "exit code 1" can't be mistaken for
210
+ // a specific failure.
211
+ let outputText: string;
212
+ if (result.spawnError) {
213
+ outputText = `spawn failed: ${result.spawnError}`;
214
+ } else if (result.combined.trim()) {
215
+ outputText = result.combined.trim();
216
+ } else {
217
+ outputText = `exit code ${result.code} (no output)`;
218
+ }
219
+
220
+ super(`${inputText}<output>${outputText}${killedText}<output>`);
221
+ this.name = "GhError";
222
+ this.args = args;
223
+ this.code = result.code;
224
+ this.stdout = result.stdout;
225
+ this.stderr = result.stderr;
226
+ this.input = input;
227
+ }
228
+ }
229
+
230
+ /** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
231
+ export async function ghExec(
232
+ args: string[],
233
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown; timeout?: number },
234
+ ): Promise<string> {
235
+ const result = await runGh(args, ctx);
236
+ if (result.code !== 0) {
237
+ throw new GhError(args, result, ctx.input);
238
+ }
239
+ return result.stdout;
240
+ }
241
+
242
+ export function repoArgs(repo?: string): string[] {
243
+ return repo ? ["--repo", repo] : [];
244
+ }
245
+
246
+ /**
247
+ * `gh api` for a JSON-array endpoint, following pagination. The REST API pages
248
+ * these lists at 30 items by default, so a single page silently drops the rest;
249
+ * `--slurp` is required because `--paginate` alone prints the pages back to back
250
+ * (not valid JSON), and the page arrays are flattened back into one list.
251
+ */
252
+ export async function ghApiList(
253
+ path: string,
254
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
255
+ ): Promise<unknown[]> {
256
+ const out = await ghExec(["api", "--paginate", "--slurp", path], ctx);
257
+ return Value.Parse(Type.Array(Type.Array(Type.Unknown())), JSON.parse(out)).flat();
258
+ }
259
+
260
+ /** Split `OWNER/REPO`; throws when the name doesn't have exactly one slash. */
261
+ export function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
262
+ const slash = nameWithOwner.indexOf("/");
263
+ if (slash <= 0 || slash === nameWithOwner.length - 1 || nameWithOwner.includes("/", slash + 1)) {
264
+ throw new Error(`invalid repository: ${nameWithOwner} (expected OWNER/REPO)`);
265
+ }
266
+ return { owner: nameWithOwner.slice(0, slash), repo: nameWithOwner.slice(slash + 1) };
267
+ }
268
+
269
+ /** Parse a positive integer toolcall parameter (run/job ids are numbers or numeric strings). */
270
+ export function toPositiveId(value: number | string, name: string): number {
271
+ const id = typeof value === "number" ? value : Number(value);
272
+ if (!Number.isSafeInteger(id) || id <= 0) {
273
+ throw new Error(`invalid ${name}: ${String(value)} (expected a positive integer)`);
274
+ }
275
+ return id;
276
+ }
277
+
278
+ const repoViewSchema = Type.Object({ nameWithOwner: Type.String() });
279
+
280
+ /** Resolve the repo a toolcall should act on: the parameter, or `gh repo view`. */
281
+ export async function resolveRepo(
282
+ repo: string | undefined,
283
+ signal: AbortSignal | undefined,
284
+ cwd: string | undefined,
285
+ input?: unknown,
286
+ ): Promise<string> {
287
+ if (repo) return repo;
288
+ const stdout = await ghExec(["repo", "view", "--json", "nameWithOwner"], { cwd, signal, input });
289
+ const { nameWithOwner } = Value.Parse(repoViewSchema, JSON.parse(stdout));
290
+ return nameWithOwner;
291
+ }
292
+
293
+ export function truncate(
294
+ text: string,
295
+ maxLines = 2000,
296
+ maxBytes = 50 * 1024,
297
+ ): { text: string; truncated: boolean } {
298
+ const lines = text.split("\n");
299
+ if (lines.length <= maxLines && Buffer.byteLength(text, "utf8") <= maxBytes) {
300
+ return { text, truncated: false };
301
+ }
302
+
303
+ const out: string[] = [];
304
+ let bytes = 0;
305
+ for (const line of lines) {
306
+ if (out.length >= maxLines) break;
307
+ const lineBytes = Buffer.byteLength(line + "\n", "utf8");
308
+ if (bytes + lineBytes > maxBytes) break;
309
+ out.push(line);
310
+ bytes += lineBytes;
311
+ }
312
+ return { text: out.join("\n"), truncated: true };
313
+ }
314
+
315
+ /**
316
+ * Format a successful gh invocation's stdout into a tool result.
317
+ * Failures are thrown by `ghExec` as `GhError`, so only the success path lives here.
318
+ */
319
+ export function toToolResult(
320
+ stdout: string,
321
+ input?: unknown,
322
+ ): {
323
+ content: { type: "text"; text: string }[];
324
+ details: Record<string, unknown>;
325
+ } {
326
+ const { text, truncated } = truncate(stdout);
327
+ return {
328
+ content: [{ type: "text", text }],
329
+ details: { ...(input !== undefined && { input }), truncated },
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Pendant subtitle for a tool result: `repo=x/y` (when provided) plus the
335
+ * tool's id parameter, e.g. `repo=x/y number=123`. Returns undefined when
336
+ * neither is available, so the pendant is omitted rather than shown empty.
337
+ */
338
+ export function subtitlePendant<IdKey extends string = never>(
339
+ params: { repo?: string } & Partial<Record<IdKey, string | number>>,
340
+ idKey?: IdKey,
341
+ ): ToolPendant | undefined {
342
+ const parts: string[] = [];
343
+ if (params.repo) parts.push(`repo=${params.repo}`);
344
+ const id = idKey === undefined ? undefined : params[idKey];
345
+ if (typeof id === "string" || typeof id === "number") parts.push(`${idKey}=${id}`);
346
+ if (parts.length === 0) return undefined;
347
+ return { subtitle: parts.join(" ") };
348
+ }
349
+
350
+ export interface ListFilters {
351
+ repo?: string;
352
+ keywords?: string;
353
+ state?: string;
354
+ label?: string;
355
+ author?: string;
356
+ assignee?: string;
357
+ milestone?: string;
358
+ limit?: number;
359
+ /** Comma-separated field names for the keyword-search result rows. */
360
+ fields?: string;
361
+ }
362
+
363
+ /**
364
+ * Build the `gh` argv for browsing issues/PRs (no keyword search).
365
+ *
366
+ * Keyword searches no longer go through the `gh` CLI — the octokit-based client
367
+ * in `../lib/github.ts` handles them with state values (`all`, and `merged` for
368
+ * PRs) that `gh search` cannot express. Browse calls keep `gh issue list` /
369
+ * `gh pr list` semantics: `state` is passed through verbatim, since `gh issue
370
+ * list` accepts open/closed/all and `gh pr list` additionally accepts merged.
371
+ */
372
+ export function listGithubArgs(kind: "issue" | "pr", params: ListFilters): string[] {
373
+ const { repo, state, label, author, assignee, milestone, limit } = params;
374
+
375
+ const args = [kind, "list", ...repoArgs(repo)];
376
+ if (state) args.push("--state", state);
377
+ if (label) args.push("--label", label);
378
+ if (author) args.push("--author", author);
379
+ if (assignee) args.push("--assignee", assignee);
380
+ if (milestone) args.push("--milestone", milestone);
381
+ if (limit) args.push("--limit", String(limit));
382
+ return args;
383
+ }
384
+
385
+ export async function listGithub(
386
+ kind: "issue" | "pr",
387
+ params: ListFilters,
388
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
389
+ ): Promise<string> {
390
+ return ghExec(listGithubArgs(kind, params), ctx);
391
+ }
392
+
393
+ /** Run a keyword search through the octokit client and render the rows. */
394
+ export async function searchList(
395
+ kind: "issue" | "pr",
396
+ params: ListFilters,
397
+ githubSearch: GithubSearch,
398
+ ): Promise<string> {
399
+ const hits = await githubSearch.search(kind, params);
400
+ if (hits.length === 0) {
401
+ return `(no matching ${kind === "issue" ? "issues" : "pull requests"})`;
402
+ }
403
+ return renderHits(hits, { repo: params.repo, fields: params.fields });
404
+ }
405
+
406
+ /**
407
+ * The GitHub reads that go through the REST API, with the HTTP layer injected:
408
+ * production hands in the proxy-aware fetch, tests hand in a stub and never
409
+ * touch the network. The per-tool handlers live next to their tool
410
+ * registration in `tools/` and take the client as their state.
411
+ *
412
+ * `fetch` is a property (not module state) so a caller that needs different HTTP
413
+ * behavior — a test, another host — constructs its own instance.
414
+ */
415
+ export class GhClient {
416
+ readonly fetch: typeof globalThis.fetch;
417
+ readonly search: GithubSearch;
418
+ readonly checks: GithubChecksClient;
419
+
420
+ constructor(fetchImpl: typeof globalThis.fetch = httpProxy.fetch) {
421
+ this.fetch = fetchImpl;
422
+ this.search = createGithubSearch({ fetch: fetchImpl });
423
+ this.checks = createGithubChecks({ fetch: fetchImpl });
424
+ }
425
+ }
426
+
427
+ // ── checks watch (pure rendering + poll loop) ────────────────────────────────
428
+
429
+ const CHECKS_POLL_INTERVAL_MS = 30_000;
430
+ const CHECKS_WATCH_DEADLINE_MS = 600_000;
431
+
432
+ export type CheckBucket = "pass" | "skipped" | "fail" | "pending";
433
+
434
+ /**
435
+ * One judged CI check of a commit: a single commit status or check run, kept
436
+ * distinct — same-named checks from different sources (push vs pull_request
437
+ * events, status vs check run channels) stay separate entries, like the
438
+ * GitHub checks UI.
439
+ */
440
+ export interface MergedCheck {
441
+ readonly name: string;
442
+ readonly bucket: CheckBucket;
443
+ readonly startedAt: string | null;
444
+ readonly link: string | null;
445
+ /** Triggering workflow event (push, pull_request, ...); null when unknown. */
446
+ readonly event: string | null;
447
+ /** Actions run id behind this check, for `get-github-workflow-jobs`; null when unknown. */
448
+ readonly runId: number | null;
449
+ /** Actions job id behind this check, for `read-github-ci-logs`; null when unknown. */
450
+ readonly jobId: number | null;
451
+ }
452
+
453
+ function statusBucket(state: string): CheckBucket {
454
+ if (state === "success") return "pass";
455
+ if (state === "failure" || state === "error") return "fail";
456
+ // pending, expected, and anything unknown must not end the wait
457
+ return "pending";
458
+ }
459
+
460
+ function checkRunBucket(run: CheckRun): CheckBucket {
461
+ if (run.status !== "completed" || run.conclusion === null) return "pending";
462
+ switch (run.conclusion) {
463
+ case "success": {
464
+ return "pass";
465
+ }
466
+ case "skipped":
467
+ case "neutral":
468
+ case "stale":
469
+ case "action_required": {
470
+ // awaiting maintainer approval: it will never run, so waiting for it is
471
+ // meaningless — treat like skipped
472
+ return "skipped";
473
+ }
474
+ case "failure":
475
+ case "timed_out":
476
+ case "cancelled":
477
+ case "startup_failure": {
478
+ return "fail";
479
+ }
480
+ default: {
481
+ return "pending";
482
+ }
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Judge the commit's statuses and check runs into individual checks, keeping
488
+ * same-named entries distinct so the wait verdict (any fail / all
489
+ * pass-or-skipped across every entry) can never lose a failure. Pure — no
490
+ * network.
491
+ */
492
+ export function mergeChecks(
493
+ statuses: readonly CommitStatus[],
494
+ checkRuns: readonly CheckRun[],
495
+ ): MergedCheck[] {
496
+ return [
497
+ ...statuses.map((status) => ({
498
+ name: status.context,
499
+ bucket: statusBucket(status.state),
500
+ startedAt: null,
501
+ link: status.targetUrl,
502
+ event: null,
503
+ runId: null,
504
+ jobId: null,
505
+ })),
506
+ ...checkRuns.map((run) => ({
507
+ name: run.name,
508
+ bucket: checkRunBucket(run),
509
+ startedAt: run.startedAt,
510
+ link: run.url,
511
+ event: run.event,
512
+ runId: run.runId,
513
+ jobId: run.jobId,
514
+ })),
515
+ ];
516
+ }
517
+
518
+ /** Display name of a check; the trigger event is labelled like the GitHub UI (`build (pull_request)`). */
519
+ export function checkDisplayName(check: MergedCheck): string {
520
+ return check.event ? `${check.name} (${check.event})` : check.name;
521
+ }
522
+
523
+ /**
524
+ * Render one polling round as a compact bullet list of the checks still in
525
+ * flight: running ones first (`- [>]`), queued ones after (`- [ ]`). Completed
526
+ * checks are hidden — the header already reports the completion count.
527
+ * Pure — no network.
528
+ */
529
+ export function renderPrChecksList(options: {
530
+ /** Report subject, e.g. `PR #7` or `commit 5a7c407`. */
531
+ subject: string;
532
+ round: number;
533
+ checks: readonly MergedCheck[];
534
+ }): string {
535
+ const { subject, round, checks } = options;
536
+ const completed = checks.filter((c) => c.bucket !== "pending").length;
537
+
538
+ const pending = checks.filter((c) => c.bucket === "pending");
539
+ const ordered = [...pending.filter((c) => c.startedAt), ...pending.filter((c) => !c.startedAt)];
540
+ const lines = ordered.map((check) => {
541
+ const name = check.link
542
+ ? `[${checkDisplayName(check)}](${check.link})`
543
+ : checkDisplayName(check);
544
+ return `- [${check.startedAt ? ">" : " "}] ${name}`;
545
+ });
546
+ const body =
547
+ checks.length === 0 ? "- _no checks reported_" : lines.length > 0 ? lines.join("\n") : "";
548
+
549
+ return `${subject} checks — round ${round}: ${completed}/${checks.length} complete${body ? `\n\n${body}` : ""}`;
550
+ }
551
+
552
+ function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promise<void> {
553
+ return new Promise((resolve, reject) => {
554
+ const onAbort = () => {
555
+ clearTimeout(timer);
556
+ reject(new Error("aborted while waiting for the next checks poll"));
557
+ };
558
+ const timer = setTimeout(() => {
559
+ signal?.removeEventListener("abort", onAbort);
560
+ resolve();
561
+ }, ms);
562
+ if (signal?.aborted) {
563
+ onAbort();
564
+ return;
565
+ }
566
+ signal?.addEventListener("abort", onAbort, { once: true });
567
+ });
568
+ }
569
+
570
+ export type ChecksPollOutcome = "completed" | "fail_fast" | "timeout";
571
+
572
+ export interface ChecksPollResult {
573
+ readonly outcome: ChecksPollOutcome;
574
+ readonly checks: readonly MergedCheck[];
575
+ readonly elapsedMs: number;
576
+ }
577
+
578
+ export interface PollPrChecksOptions {
579
+ /** Report subject for progress lines, e.g. `PR #7` or `commit 5a7c407`. */
580
+ subject: string;
581
+ owner: string;
582
+ repo: string;
583
+ headSha: string;
584
+ failFast: boolean;
585
+ checks: GithubChecksClient;
586
+ /**
587
+ * When set, only check runs triggered by this workflow event (e.g. push)
588
+ * are judged; commit statuses have an unknown trigger event and are
589
+ * excluded. Unset means all checks of the commit.
590
+ */
591
+ event?: string;
592
+ /** Owned by the caller; the poll loop observes it but never aborts it. */
593
+ signal: AbortSignal;
594
+ /** Test overrides. */
595
+ intervalMs?: number;
596
+ deadlineMs?: number;
597
+ onUpdate?: (msg: ToolResult) => void;
598
+ }
599
+
600
+ /**
601
+ * Poll the commit's combined-status and check-runs APIs until the wait
602
+ * semantics are met: return on any failure (immediately under fail-fast) or
603
+ * when every check is complete (pass/skipped). Emits a compact list of
604
+ * in-flight checks via `onUpdate` each round.
605
+ *
606
+ * A failed round (network, auth) does not end the wait — the error is kept
607
+ * and polling continues, so a transient blip or a CI system that has not
608
+ * reported anything yet cannot be mistaken for a completed check set. Only
609
+ * when no round ever succeeded by the deadline is the last error thrown.
610
+ */
611
+ export async function pollPrChecks(options: PollPrChecksOptions): Promise<ChecksPollResult> {
612
+ const { subject, owner, repo, headSha, failFast, checks, signal, onUpdate } = options;
613
+ const intervalMs = options.intervalMs ?? CHECKS_POLL_INTERVAL_MS;
614
+ const deadlineMs = options.deadlineMs ?? CHECKS_WATCH_DEADLINE_MS;
615
+
616
+ const watchStart = Date.now();
617
+ let lastChecks: readonly MergedCheck[] = [];
618
+ let lastError: unknown;
619
+ let everSucceeded = false;
620
+
621
+ for (let round = 1; ; round++) {
622
+ if (signal.aborted) throw new Error("PR checks polling was aborted");
623
+ try {
624
+ const [statuses, runs] = await Promise.all([
625
+ checks.statuses(owner, repo, headSha, signal),
626
+ checks.checkRuns(owner, repo, headSha, signal),
627
+ ]);
628
+ everSucceeded = true;
629
+ lastChecks = mergeChecks(statuses, runs);
630
+ if (options.event) {
631
+ lastChecks = lastChecks.filter((c) => c.event === options.event);
632
+ }
633
+ onUpdate?.({
634
+ content: [
635
+ { type: "text", text: renderPrChecksList({ subject, round, checks: lastChecks }) },
636
+ ],
637
+ details: {},
638
+ });
639
+ if (lastChecks.every((c) => c.bucket !== "pending")) {
640
+ return { outcome: "completed", checks: lastChecks, elapsedMs: Date.now() - watchStart };
641
+ }
642
+ if (failFast && lastChecks.some((c) => c.bucket === "fail")) {
643
+ return { outcome: "fail_fast", checks: lastChecks, elapsedMs: Date.now() - watchStart };
644
+ }
645
+ } catch (error) {
646
+ // 不用 if (signal.aborted):循环顶部的同名字段检查把它收窄成 false,
647
+ // TS 会在 catch 里维持这个收窄。
648
+ signal.throwIfAborted();
649
+ lastError = error;
650
+ }
651
+ if (Date.now() - watchStart >= deadlineMs) {
652
+ if (!everSucceeded) {
653
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
654
+ throw new Error(`PR checks polling failed before any round succeeded: ${message}`);
655
+ }
656
+ return { outcome: "timeout", checks: lastChecks, elapsedMs: Date.now() - watchStart };
657
+ }
658
+ await sleepInterruptibly(intervalMs, signal);
659
+ }
660
+ }
661
+
662
+ /** Job conclusions that count as "did not succeed" for CI result reporting. */
663
+ const FAILED_JOB_CONCLUSIONS = new Set([
664
+ "failure",
665
+ "timed_out",
666
+ "action_required",
667
+ "startup_failure",
668
+ "cancelled",
669
+ ]);
670
+
671
+ export function statusIcon(conclusion: string | null): string {
672
+ switch (conclusion) {
673
+ case "success": {
674
+ return "✅";
675
+ }
676
+ case "failure": {
677
+ return "❌";
678
+ }
679
+ case "cancelled": {
680
+ return "🚫";
681
+ }
682
+ case "skipped": {
683
+ return "⏭️";
684
+ }
685
+ case "timed_out": {
686
+ return "⏰";
687
+ }
688
+ case "action_required": {
689
+ return "⚠️";
690
+ }
691
+ default: {
692
+ return "🔄";
693
+ }
694
+ }
695
+ }
696
+
697
+ /** One Actions job that did not succeed, for the FAILED report details. */
698
+ export interface FailedActionJob {
699
+ readonly runId: number;
700
+ readonly runName: string;
701
+ readonly runUrl: string;
702
+ readonly jobId: number;
703
+ readonly jobName: string;
704
+ readonly conclusion: string;
705
+ readonly jobUrl?: string;
706
+ }
707
+
708
+ export interface ChecksVerdict {
709
+ readonly status: "success" | "failure" | "pending";
710
+ readonly text: string;
711
+ readonly failedJobs: readonly FailedActionJob[];
712
+ }
713
+
714
+ /**
715
+ * Turn a poll result into the final report. Verdict comes from the checks
716
+ * buckets alone (so external CI such as Azure counts); Actions jobs are
717
+ * display-only enrichment. Pure — no network.
718
+ */
719
+ export function renderChecksVerdict(options: {
720
+ /** Report subject, e.g. `PR #123` or `commit 5a7c407`. */
721
+ subject: string;
722
+ poll: ChecksPollResult;
723
+ /** All Actions jobs of the head commit; failed/incomplete ones are listed. */
724
+ actionJobs?: readonly ActionJob[];
725
+ /** Set when the Actions job fetch failed; the verdict stays untouched. */
726
+ enrichmentError?: string;
727
+ }): ChecksVerdict {
728
+ const { subject, poll, actionJobs, enrichmentError } = options;
729
+ const totalChecks = poll.checks.length;
730
+ const failed = poll.checks.filter((c) => c.bucket === "fail");
731
+ const pending = poll.checks.filter((c) => c.bucket === "pending");
732
+
733
+ if (failed.length === 0 && poll.outcome === "completed") {
734
+ return {
735
+ status: "success",
736
+ text: `## ${subject} CI Checks - PASSED\n\nAll ${totalChecks} check(s) passed.`,
737
+ failedJobs: [],
738
+ };
739
+ }
740
+
741
+ if (failed.length > 0) {
742
+ const failedJobs: FailedActionJob[] = (actionJobs ?? [])
743
+ .filter((j) => !j.conclusion || FAILED_JOB_CONCLUSIONS.has(j.conclusion))
744
+ .map((j) => ({
745
+ runId: j.runId,
746
+ runName: j.runName,
747
+ runUrl: j.runUrl,
748
+ jobId: j.jobId,
749
+ jobName: j.jobName,
750
+ conclusion: j.conclusion ?? "in_progress",
751
+ ...(j.jobUrl && { jobUrl: j.jobUrl }),
752
+ }));
753
+
754
+ const lines = failed.map(
755
+ (c) =>
756
+ `- ${statusIcon("failure")} **${checkDisplayName(c)}**${c.link ? ` — [view check](${c.link})` : ""}`,
757
+ );
758
+ for (const j of failedJobs) {
759
+ lines.push(
760
+ ` - ${statusIcon(j.conclusion)} job **${j.jobName}** (${j.conclusion}) — [job #${j.jobId}](${j.jobUrl ?? j.runUrl})`,
761
+ ` - workflow: [${j.runName} (#${j.runId})](${j.runUrl})`,
762
+ );
763
+ }
764
+ if (enrichmentError) lines.push(` - _Actions job details unavailable: ${enrichmentError}_`);
765
+ if (pending.length > 0) lines.push(`\n_${pending.length} other check(s) still in flight._`);
766
+
767
+ return {
768
+ status: "failure",
769
+ text:
770
+ `## ${subject} CI Checks - FAILED\n\n` +
771
+ `${failed.length} of ${totalChecks} check(s) failed:\n\n${lines.join("\n")}`,
772
+ failedJobs,
773
+ };
774
+ }
775
+
776
+ const waitedMinutes = Math.max(1, Math.round(poll.elapsedMs / 60_000));
777
+ const pendingLines = pending.map(
778
+ (c) => `- [${c.startedAt ? ">" : " "}] ${c.link ? `[${c.name}](${c.link})` : c.name}`,
779
+ );
780
+ return {
781
+ status: "pending",
782
+ text:
783
+ `## ${subject} CI Checks - STILL IN FLIGHT\n\n` +
784
+ `${pending.length} of ${totalChecks} check(s) still incomplete after ~${waitedMinutes}m:\n\n` +
785
+ (pendingLines.length > 0 ? pendingLines.join("\n") : "- _no checks reported_"),
786
+ failedJobs: [],
787
+ };
788
+ }
789
+
790
+ /**
791
+ * Shared wait core of `wait-github-pr-checks` and `wait-github-commit-checks`:
792
+ * poll the commit's checks, enrich FAILED reports with Actions job details
793
+ * (display-only), render the verdict. The caller resolves repo/headSha;
794
+ * `subject` formats the report header.
795
+ */
796
+ export async function waitChecksReport(options: {
797
+ checks: GithubChecksClient;
798
+ subject: string;
799
+ owner: string;
800
+ repo: string;
801
+ headSha: string;
802
+ failFast: boolean;
803
+ event?: string;
804
+ signal: AbortSignal | undefined;
805
+ onUpdate: ((msg: ToolResult) => void) | undefined;
806
+ params: unknown;
807
+ pendant?: ToolPendant;
808
+ }): Promise<ToolResult> {
809
+ const { subject, owner, repo, headSha, failFast, event, signal, onUpdate, params, pendant } =
810
+ options;
811
+
812
+ // 轮询层要求非空 signal;框架可能不给时构造一个占位的(从不取消)。
813
+ const pollSignal = signal ?? new AbortController().signal;
814
+
815
+ const poll = await pollPrChecks({
816
+ subject,
817
+ owner,
818
+ repo,
819
+ headSha,
820
+ failFast,
821
+ event,
822
+ checks: options.checks,
823
+ signal: pollSignal,
824
+ onUpdate,
825
+ });
826
+
827
+ // Actions job 详情只做展示补充,不影响判定(判定来自 checks bucket,
828
+ // 覆盖 Azure 等外部 CI)。抓取失败时降级为提示,不推翻结论。
829
+ let actionJobs: readonly ActionJob[] | undefined;
830
+ let enrichmentError: string | undefined;
831
+ if (poll.checks.some((c) => c.bucket === "fail")) {
832
+ try {
833
+ actionJobs = await options.checks.actionJobs(owner, repo, headSha, pollSignal);
834
+ } catch (error) {
835
+ enrichmentError = error instanceof Error ? error.message : "Actions job details unavailable";
836
+ }
837
+ }
838
+
839
+ const verdict = renderChecksVerdict({ subject, poll, actionJobs, enrichmentError });
840
+ return {
841
+ content: [{ type: "text", text: verdict.text }],
842
+ details: {
843
+ status: verdict.status,
844
+ totalChecks: poll.checks.length,
845
+ checks: poll.checks,
846
+ failedJobs: verdict.failedJobs,
847
+ input: params,
848
+ ...(pendant && { pendant }),
849
+ },
850
+ };
851
+ }