@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.
@@ -1,2161 +1,10 @@
1
1
  /**
2
- * GitHub Read-Only Tools Extension
2
+ * GitHub Read-Only Tools Extension — extension entry point.
3
3
  *
4
- * Provides individual read-only tools for GitHub operations using the system's `gh` CLI.
5
- *
6
- * Tools:
7
- * - read-github-issue: Get issue details
8
- * - list-github-issues: List or search issues
9
- * - read-github-issue-comments: Get issue comments
10
- * - read-github-pr: Get PR details
11
- * - list-github-prs: List or search PRs
12
- * - read-github-pr-diff: Get PR diff
13
- * - read-github-pr-status: Get PR status checks
14
- * - read-github-pr-comments: Get PR comments
15
- * - read-github-ci-logs: Get CI workflow run logs
16
- * - read-github-workflow-runs: List workflow runs
17
- * - get-github-workflow-jobs: Get workflow run jobs
18
- * - read-github-repo: Get repo info
19
- * - list-github-releases: List releases
20
- * - read-github-release: Get release details
21
- * - download-github-release-assets: Download a release's assets with gh credentials
22
- * - wait-github-pr-checks: Watch PR CI checks
23
- * - wait-github-commit-checks: Watch CI checks of a commit (no PR required)
24
- * - watch-github-run: Watch a workflow run
25
- *
26
- * Install:
27
- * cp gh-readonly.ts ~/.pi/agent/extensions/
28
- *
29
- * Or for project-local:
30
- * cp gh-readonly.ts .pi/extensions/
31
- *
32
- * Proxy (for the gh CLI and for the octokit-backed search/checks requests):
33
- * ~/.pi/agent/proxy.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost" }
34
- * HTTPS_PROXY / HTTP_PROXY / ALL_PROXY and NO_PROXY are used instead for the
35
- * fields the config file leaves out. The config is read once per process.
36
- */
37
-
38
- import { spawn } from "node:child_process";
39
- import { existsSync } from "node:fs";
40
- import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
41
- import { homedir } from "node:os";
42
- import { delimiter, dirname, join } from "node:path";
43
-
44
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
45
- import { Type } from "typebox";
46
- import { Value } from "typebox/value";
47
-
48
- import {
49
- type ActionJob,
50
- type CheckRun,
51
- type CommitStatus,
52
- createGithubChecks,
53
- createGithubSearch,
54
- type GithubChecksClient,
55
- type GithubSearch,
56
- renderHits,
57
- type RunJob,
58
- } from "./lib/github.js";
59
- import { type ToolPendant } from "./lib/pendant.js";
60
- import { createHttpProxy } from "./lib/proxy.js";
61
- import { createSeqState } from "./lib/seq-state.js";
62
-
63
- /**
64
- * 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
65
- * `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
66
- */
67
- const httpProxy = createHttpProxy();
68
-
69
- interface GhResult {
70
- stdout: string;
71
- stderr: string;
72
- code: number;
73
- killed: boolean;
74
- combined: string;
75
- /** Why the process was killed, when `killed` is true. */
76
- reason?: "timeout" | "abort";
77
- /** When the process could not be started at all (e.g. `gh` not found in PATH). */
78
- spawnError?: string;
79
- }
80
-
81
- // ── helpers ──────────────────────────────────────────────────────────────────
82
-
83
- /**
84
- * Check whether the `gh` CLI is on the system, scanning PATH like
85
- * `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
86
- * the model never sees GitHub tools that would fail on every call.
87
- */
88
- export function isGhAvailable(): boolean {
89
- const pathEnv = process.env.PATH ?? "";
90
- for (const directory of pathEnv.split(delimiter)) {
91
- if (existsSync(join(directory, "gh"))) return true;
92
- }
93
- for (const candidate of ["/usr/bin/gh", "/usr/local/bin/gh", "/run/current-system/sw/bin/gh"]) {
94
- if (existsSync(candidate)) return true;
95
- }
96
- return false;
97
- }
98
-
99
- export function runGh(
100
- args: string[],
101
- ctx: {
102
- cwd?: string;
103
- signal?: AbortSignal;
104
- timeout?: number;
105
- /** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
106
- env?: NodeJS.ProcessEnv;
107
- },
108
- ): Promise<GhResult> {
109
- return new Promise((resolve) => {
110
- const proc = spawn("gh", args, {
111
- cwd: ctx.cwd,
112
- shell: false,
113
- stdio: ["ignore", "pipe", "pipe"],
114
- // gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
115
- env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
116
- });
117
-
118
- let stdout = "";
119
- let stderr = "";
120
- const combined: string[] = [];
121
- let killed = false;
122
- let killReason: "timeout" | "abort" | undefined;
123
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
124
- let onAbort: (() => void) | undefined;
125
-
126
- const killProcess = (reason: "timeout" | "abort") => {
127
- if (killed) {
128
- return;
129
- }
130
-
131
- killed = true;
132
- killReason = reason;
133
- proc.kill("SIGTERM");
134
- setTimeout(() => {
135
- if (!proc.killed) proc.kill("SIGKILL");
136
- }, 5000);
137
- };
138
-
139
- if (ctx.signal) {
140
- onAbort = () => killProcess("abort");
141
- if (ctx.signal.aborted) {
142
- killProcess("abort");
143
- } else {
144
- ctx.signal.addEventListener("abort", onAbort, { once: true });
145
- }
146
- }
147
-
148
- // Default timeout: 10 minutes. Long operations like downloading a CI job's
149
- // full log routinely take well over 30s, so a short default would kill them
150
- // mid-transfer; combined with `code ?? 0` that would silently cache a
151
- // truncated log as success. A killed process must never look successful.
152
- const timeout = ctx.timeout ?? 600_000;
153
- if (timeout > 0) {
154
- timeoutId = setTimeout(() => killProcess("timeout"), timeout);
155
- }
156
-
157
- proc.stdout.on("data", (data: Buffer) => {
158
- const text = data.toString();
159
- stdout += text;
160
- combined.push(text);
161
- });
162
- proc.stderr.on("data", (data: Buffer) => {
163
- const text = data.toString();
164
- stderr += text;
165
- combined.push(text);
166
- });
167
-
168
- proc.on("close", (code) => {
169
- if (timeoutId) clearTimeout(timeoutId);
170
- if (onAbort && ctx.signal) {
171
- ctx.signal.removeEventListener("abort", onAbort);
172
- }
173
- resolve({
174
- stdout,
175
- stderr,
176
- // When killed by a signal the close event's code is null; report the
177
- // process as failed instead of pretending it succeeded. -1 is a
178
- // sentinel for "did not exit normally" — distinct from a real gh
179
- // failure exit code (1), which is always in 0-255.
180
- code: code ?? (killed ? -1 : 0),
181
- killed,
182
- combined: combined.join(""),
183
- reason: killReason,
184
- });
185
- });
186
-
187
- proc.on("error", (err: Error) => {
188
- if (timeoutId) clearTimeout(timeoutId);
189
- if (onAbort && ctx.signal) {
190
- ctx.signal.removeEventListener("abort", onAbort);
191
- }
192
- // spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
193
- // 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
194
- resolve({
195
- stdout,
196
- stderr,
197
- code: 1,
198
- killed,
199
- combined: combined.join(""),
200
- reason: killReason,
201
- spawnError: err.message,
202
- });
203
- });
204
- });
205
- }
206
-
207
- /**
208
- * Error thrown by `ghExec` when the `gh` invocation exits non-zero.
209
- * The message carries the toolcall input (JSON) wrapped in `<input>` markers,
210
- * and the command output wrapped in `<output>` markers.
211
- */
212
- export class GhError extends Error {
213
- readonly args: string[];
214
- readonly code: number;
215
- readonly stdout: string;
216
- readonly stderr: string;
217
- readonly input?: unknown;
218
-
219
- constructor(args: string[], result: GhResult, input?: unknown) {
220
- const inputText = input === undefined ? "" : `<input>${JSON.stringify(input)}<input>\n`;
221
- const killedText = result.killed
222
- ? result.reason === "timeout"
223
- ? " (command timed out)"
224
- : result.reason === "abort"
225
- ? " (command aborted)"
226
- : ""
227
- : "";
228
-
229
- // The process never started (e.g. `gh` not found): surface the spawn error.
230
- // Otherwise show the command's own output; an empty output with a non-zero
231
- // exit is explicitly marked, so a bare "exit code 1" can't be mistaken for
232
- // a specific failure.
233
- let outputText: string;
234
- if (result.spawnError) {
235
- outputText = `spawn failed: ${result.spawnError}`;
236
- } else if (result.combined.trim()) {
237
- outputText = result.combined.trim();
238
- } else {
239
- outputText = `exit code ${result.code} (no output)`;
240
- }
241
-
242
- super(`${inputText}<output>${outputText}${killedText}<output>`);
243
- this.name = "GhError";
244
- this.args = args;
245
- this.code = result.code;
246
- this.stdout = result.stdout;
247
- this.stderr = result.stderr;
248
- this.input = input;
249
- }
250
- }
251
-
252
- /** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
253
- export async function ghExec(
254
- args: string[],
255
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown; timeout?: number },
256
- ): Promise<string> {
257
- const result = await runGh(args, ctx);
258
- if (result.code !== 0) {
259
- throw new GhError(args, result, ctx.input);
260
- }
261
- return result.stdout;
262
- }
263
-
264
- function repoArgs(repo?: string): string[] {
265
- return repo ? ["--repo", repo] : [];
266
- }
267
-
268
- /**
269
- * `gh api` for a JSON-array endpoint, following pagination. The REST API pages
270
- * these lists at 30 items by default, so a single page silently drops the rest;
271
- * `--slurp` is required because `--paginate` alone prints the pages back to back
272
- * (not valid JSON), and the page arrays are flattened back into one list.
273
- */
274
- async function ghApiList(
275
- path: string,
276
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
277
- ): Promise<unknown[]> {
278
- const out = await ghExec(["api", "--paginate", "--slurp", path], ctx);
279
- return Value.Parse(Type.Array(Type.Array(Type.Unknown())), JSON.parse(out)).flat();
280
- }
281
-
282
- /** Split `OWNER/REPO`; throws when the name doesn't have exactly one slash. */
283
- function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
284
- const slash = nameWithOwner.indexOf("/");
285
- if (slash <= 0 || slash === nameWithOwner.length - 1 || nameWithOwner.includes("/", slash + 1)) {
286
- throw new Error(`invalid repository: ${nameWithOwner} (expected OWNER/REPO)`);
287
- }
288
- return { owner: nameWithOwner.slice(0, slash), repo: nameWithOwner.slice(slash + 1) };
289
- }
290
-
291
- /** Parse a positive integer toolcall parameter (run/job ids are numbers or numeric strings). */
292
- function toPositiveId(value: number | string, name: string): number {
293
- const id = typeof value === "number" ? value : Number(value);
294
- if (!Number.isSafeInteger(id) || id <= 0) {
295
- throw new Error(`invalid ${name}: ${String(value)} (expected a positive integer)`);
296
- }
297
- return id;
298
- }
299
-
300
- // ── runtime validation schemas for JSON.parse results ───────────────────────
301
-
302
- const repoViewSchema = Type.Object({ nameWithOwner: Type.String() });
303
-
304
- const prHeadSchema = Type.Object({ headRefOid: Type.String() });
305
-
306
- /** `gh release view --json tagName,assets` 里本工具真正读取的字段。 */
307
- const releaseViewSchema = Type.Object({
308
- tagName: Type.String(),
309
- assets: Type.Array(Type.Object({ name: Type.String(), size: Type.Number() })),
310
- });
311
-
312
- function truncate(
313
- text: string,
314
- maxLines = 2000,
315
- maxBytes = 50 * 1024,
316
- ): { text: string; truncated: boolean } {
317
- const lines = text.split("\n");
318
- if (lines.length <= maxLines && Buffer.byteLength(text, "utf8") <= maxBytes) {
319
- return { text, truncated: false };
320
- }
321
-
322
- const out: string[] = [];
323
- let bytes = 0;
324
- for (const line of lines) {
325
- if (out.length >= maxLines) break;
326
- const lineBytes = Buffer.byteLength(line + "\n", "utf8");
327
- if (bytes + lineBytes > maxBytes) break;
328
- out.push(line);
329
- bytes += lineBytes;
330
- }
331
- return { text: out.join("\n"), truncated: true };
332
- }
333
-
334
- /**
335
- * Format a successful gh invocation's stdout into a tool result.
336
- * Failures are thrown by `ghExec` as `GhError`, so only the success path lives here.
337
- */
338
- function toToolResult(
339
- stdout: string,
340
- input?: unknown,
341
- ): {
342
- content: { type: "text"; text: string }[];
343
- details: Record<string, unknown>;
344
- } {
345
- const { text, truncated } = truncate(stdout);
346
- return {
347
- content: [{ type: "text", text }],
348
- details: { ...(input !== undefined && { input }), truncated },
349
- };
350
- }
351
-
352
- /**
353
- * Pendant subtitle for a tool result: `repo=x/y` (when provided) plus the
354
- * tool's id parameter, e.g. `repo=x/y number=123`. Returns undefined when
355
- * neither is available, so the pendant is omitted rather than shown empty.
356
- */
357
- function subtitlePendant<IdKey extends string = never>(
358
- params: { repo?: string } & Partial<Record<IdKey, string | number>>,
359
- idKey?: IdKey,
360
- ): ToolPendant | undefined {
361
- const parts: string[] = [];
362
- if (params.repo) parts.push(`repo=${params.repo}`);
363
- const id = idKey === undefined ? undefined : params[idKey];
364
- if (typeof id === "string" || typeof id === "number") parts.push(`${idKey}=${id}`);
365
- if (parts.length === 0) return undefined;
366
- return { subtitle: parts.join(" ") };
367
- }
368
-
369
- interface ListFilters {
370
- repo?: string;
371
- keywords?: string;
372
- state?: string;
373
- label?: string;
374
- author?: string;
375
- assignee?: string;
376
- milestone?: string;
377
- limit?: number;
378
- /** Comma-separated field names for the keyword-search result rows. */
379
- fields?: string;
380
- }
381
-
382
- /**
383
- * Build the `gh` argv for browsing issues/PRs (no keyword search).
384
- *
385
- * Keyword searches no longer go through the `gh` CLI — the octokit-based client
386
- * in `./lib/github.ts` handles them with state values (`all`, and `merged` for
387
- * PRs) that `gh search` cannot express. Browse calls keep `gh issue list` /
388
- * `gh pr list` semantics: `state` is passed through verbatim, since `gh issue
389
- * list` accepts open/closed/all and `gh pr list` additionally accepts merged.
390
- */
391
- export function listGithubArgs(kind: "issue" | "pr", params: ListFilters): string[] {
392
- const { repo, state, label, author, assignee, milestone, limit } = params;
393
-
394
- const args = [kind, "list", ...repoArgs(repo)];
395
- if (state) args.push("--state", state);
396
- if (label) args.push("--label", label);
397
- if (author) args.push("--author", author);
398
- if (assignee) args.push("--assignee", assignee);
399
- if (milestone) args.push("--milestone", milestone);
400
- if (limit) args.push("--limit", String(limit));
401
- return args;
402
- }
403
-
404
- async function listGithub(
405
- kind: "issue" | "pr",
406
- params: ListFilters,
407
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
408
- ): Promise<string> {
409
- return ghExec(listGithubArgs(kind, params), ctx);
410
- }
411
-
412
- /** Run a keyword search through the octokit client and render the rows. */
413
- async function searchList(
414
- kind: "issue" | "pr",
415
- params: ListFilters,
416
- githubSearch: GithubSearch,
417
- ): Promise<string> {
418
- const hits = await githubSearch.search(kind, params);
419
- if (hits.length === 0) {
420
- return `(no matching ${kind === "issue" ? "issues" : "pull requests"})`;
421
- }
422
- return renderHits(hits, { repo: params.repo, fields: params.fields });
423
- }
424
-
425
- // ── CI helpers ───────────────────────────────────────────────────────────────
426
-
427
- // 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
428
- // 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
429
- const seq = createSeqState();
430
-
431
- /**
432
- * `OWNER/REPO` from a job's `run_url`
433
- * (`https://api.github.com/repos/OWNER/REPO/actions/runs/123`). The path is
434
- * parsed as a URL rather than pattern-matched, and GitHub canonicalizes the
435
- * owner/repo casing in these fields — so this is the spelling to key the log
436
- * cache on, independent of whatever `repo` the caller passed.
437
- */
438
- export function repoFromRunUrl(runUrl: string): string {
439
- const segments = new URL(runUrl).pathname.split("/").filter(Boolean);
440
- const reposAt = segments.indexOf("repos");
441
- const ownerAndRepo = reposAt === -1 ? [] : segments.slice(reposAt + 1, reposAt + 3);
442
- if (ownerAndRepo.length !== 2) {
443
- throw new Error(`unexpected run_url (expected /repos/<owner>/<repo>/...): ${runUrl}`);
444
- }
445
- return ownerAndRepo.join("/");
446
- }
447
-
448
- /** Absolute path of the raw job log cache file written by `getJobLog`. */
449
- export function jobLogPath(repo: string, runId: string, jobId: number): string {
450
- const { owner, repo: name } = splitRepo(repo);
451
- return join(homedir(), ".cache", "pi", "github", "ci-logs", owner, name, runId, `${jobId}.log`);
452
- }
453
-
454
- /**
455
- * Directory the release assets of one release are downloaded into:
456
- * `~/.cache/pi/github/releases/<owner>/<repo>/<tag>/`.
457
- *
458
- * A tag is a git ref name and may contain `/`; only the characters that are safe
459
- * in one path segment survive, so a tag can never escape its own directory.
460
- */
461
- export function releaseAssetDir(repo: string, tag: string): string {
462
- const { owner, repo: name } = splitRepo(repo);
463
- const safeTag = tag.replaceAll(/[^A-Za-z0-9._+-]/g, "_").replace(/^\.+$/, "_");
464
- return join(homedir(), ".cache", "pi", "github", "releases", owner, name, safeTag);
465
- }
466
-
467
- async function getJobLog(
468
- job: RunJob,
469
- signal: AbortSignal | undefined,
470
- cwd: string | undefined,
471
- input?: unknown,
472
- ): Promise<string> {
473
- // The log download only accepts a job id, and the cache is keyed on the
474
- // canonical repo/run from the job itself, not on the caller's `repo` string.
475
- const repo = repoFromRunUrl(job.run_url);
476
- const cacheFile = jobLogPath(repo, String(job.run_id), job.id);
477
- const cacheDir = dirname(cacheFile);
478
-
479
- // 同一 cache 文件的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
480
- // 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
481
- return seq.execute(cacheFile, async () => {
482
- // Check file cache
483
- try {
484
- return await readFile(cacheFile, "utf8");
485
- } catch {
486
- // Not cached, fetch from GitHub
487
- }
488
-
489
- // `gh api` refuses to print responses that contain terminal escape
490
- // sequences unless `--allow-escape-sequences` is passed. Job logs carry
491
- // ANSI color codes, so without this flag the download always fails with
492
- // "the response contains terminal escape sequences; pass
493
- // --allow-escape-sequences to output it anyway". The raw bytes are kept
494
- // as-is (the file is the log exactly as GitHub delivers it); the tool never
495
- // echoes them, and the TUI strips ANSI when rendering tool results.
496
- const log = await ghExec(
497
- ["api", "--allow-escape-sequences", `/repos/${repo}/actions/jobs/${job.id}/logs`],
498
- {
499
- cwd,
500
- signal,
501
- input,
502
- },
503
- );
504
-
505
- // Write to cache
506
- await mkdir(cacheDir, { recursive: true });
507
- await writeFile(cacheFile, log);
508
-
509
- return log;
510
- });
511
- }
512
-
513
- async function resolveRepo(
514
- repo: string | undefined,
515
- signal: AbortSignal | undefined,
516
- cwd: string | undefined,
517
- input?: unknown,
518
- ): Promise<string> {
519
- if (repo) return repo;
520
- const stdout = await ghExec(["repo", "view", "--json", "nameWithOwner"], { cwd, signal, input });
521
- const { nameWithOwner } = Value.Parse(repoViewSchema, JSON.parse(stdout));
522
- return nameWithOwner;
523
- }
524
-
525
- /** Job conclusions that count as "did not succeed" for CI result reporting. */
526
- const FAILED_JOB_CONCLUSIONS = new Set([
527
- "failure",
528
- "timed_out",
529
- "action_required",
530
- "startup_failure",
531
- "cancelled",
532
- ]);
533
-
534
- export function statusIcon(conclusion: string | null): string {
535
- switch (conclusion) {
536
- case "success": {
537
- return "✅";
538
- }
539
- case "failure": {
540
- return "❌";
541
- }
542
- case "cancelled": {
543
- return "🚫";
544
- }
545
- case "skipped": {
546
- return "⏭️";
547
- }
548
- case "timed_out": {
549
- return "⏰";
550
- }
551
- case "action_required": {
552
- return "⚠️";
553
- }
554
- default: {
555
- return "🔄";
556
- }
557
- }
558
- }
559
-
560
- /** A step's line span in the raw job log: 0-based `start`, exclusive `end`. */
561
- export interface StepSpan {
562
- start: number;
563
- end: number;
564
- }
565
-
566
- /** The part of a step the log index needs. `RunJobStep` satisfies it. */
567
- export interface StepRef {
568
- number: number;
569
- name: string;
570
- conclusion?: string | null;
571
- started_at?: string | null;
572
- }
573
-
574
- /** A `##[group]Run …` / `##[group]Post Run …` line: the header of one executed step. */
575
- interface StepHeader {
576
- /** 0-based index of the `##[group]` line. */
577
- line: number;
578
- /** Header text with the `Run ` / `Post Run ` prefix stripped. */
579
- action: string;
580
- /** Runner timestamp on that line (epoch ms), null when unparsable. */
581
- timestamp: number | null;
582
- }
583
-
584
- /** Runner timestamp every log line starts with: `2026-08-05T16:36:08.1842645Z `. */
585
- const LOG_TIMESTAMP_RE = /^\uFEFF?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) /;
586
- const HEADER_PREFIX_RE = /^(Run |Post Run )/;
587
-
588
- /**
589
- * The runner writes a step's header within ~1.6s of the step's API `started_at`
590
- * (the API truncates its timestamps to whole seconds), so a header inside this
591
- * window is evidence of the step it belongs to.
592
- */
593
- const HEADER_WINDOW_MS = 5_000;
594
- /** An exact name match proves the header belongs to that step. */
595
- const NAME_MATCH_SCORE = 4;
596
- /** Weight of a header inside the step's start window. */
597
- const TIME_MATCH_SCORE = 2;
598
-
599
- function headerTimestamp(line: string): number | null {
600
- const match = LOG_TIMESTAMP_RE.exec(line);
601
- if (match === null) return null;
602
- const ms = Date.parse(match[1]);
603
- return Number.isNaN(ms) ? null : ms;
604
- }
605
-
606
- /** Collect the depth-1 `Run ` / `Post Run ` headers, in log order. */
607
- function stepHeaders(lines: string[]): StepHeader[] {
608
- const headers: StepHeader[] = [];
609
- let depth = 0;
610
- for (const [i, line] of lines.entries()) {
611
- if (line.includes("##[endgroup]")) {
612
- if (depth > 0) depth--;
613
- continue;
614
- }
615
- if (!line.includes("##[group]")) continue;
616
- depth++;
617
- if (depth !== 1) continue;
618
- const name = /##\[group\](.*)/.exec(line)?.[1].trim() ?? "";
619
- if (HEADER_PREFIX_RE.test(name)) {
620
- headers.push({
621
- line: i,
622
- action: name.replace(HEADER_PREFIX_RE, "").trim(),
623
- timestamp: headerTimestamp(line),
624
- });
625
- }
626
- }
627
- return headers;
628
- }
629
-
630
- /** How well a step explains a header — 0 means "no evidence, don't guess". */
631
- function headerScore(step: StepRef, header: StepHeader): number {
632
- let score = 0;
633
- const named = step.name.replace(HEADER_PREFIX_RE, "").trim();
634
- if (HEADER_PREFIX_RE.test(step.name) && named === header.action) {
635
- score += NAME_MATCH_SCORE;
636
- }
637
- const started = step.started_at == null ? NaN : Date.parse(step.started_at);
638
- if (!Number.isNaN(started) && header.timestamp !== null) {
639
- const delta = header.timestamp - started;
640
- if (delta >= 0 && delta <= HEADER_WINDOW_MS) {
641
- score += TIME_MATCH_SCORE * (1 - delta / HEADER_WINDOW_MS);
642
- }
643
- }
644
- return score;
645
- }
646
-
647
- /**
648
- * Align steps with headers: an order-preserving best-scoring matching, where
649
- * either side may be left unmatched. Only pairs with real evidence are matched,
650
- * so a step whose block cannot be identified gets no span instead of a guess.
651
- *
652
- * Name evidence disappears as soon as the workflow names a step with `name:`
653
- * (the API name is then the custom one, while the log header carries the action
654
- * or command), which is why the timestamps matter too. Headers belonging to a
655
- * composite action's *internal* steps carry no evidence for any API step, so
656
- * they stay unmatched and are absorbed into the enclosing step's span.
4
+ * The implementation lives in `src/gh/` (shared helpers in `base.ts`, one tool
5
+ * per file in `tools/`, registration in `index.ts`). This file only forwards
6
+ * the registration function and the public API consumed by tests.
657
7
  */
658
- function alignStepsToHeaders(
659
- steps: readonly StepRef[],
660
- headers: StepHeader[],
661
- ): Map<number, number> {
662
- const n = steps.length;
663
- const m = headers.length;
664
- // Equal-scoring alignments are decided in favour of the earlier step: a step
665
- // whose output the runner never logged (post steps, "Complete job") comes last
666
- // in step order, so a header claimed by both belongs to the earlier one. A
667
- // step that ran always emits its header before the next step starts, which
668
- // leaves several steps competing for one header whenever the API timestamps
669
- // (whole seconds) collapse them into the same second.
670
- const TIE_BREAK = 1e-6;
671
- const scores = steps.map((step, i) =>
672
- headers.map((header) => {
673
- const score = headerScore(step, header);
674
- return score > 0 ? score + TIE_BREAK * (n - i) : 0;
675
- }),
676
- );
677
- // best[i][j]: score of aligning the first i steps with the first j headers.
678
- const best: number[][] = Array.from({ length: n + 1 }, () =>
679
- Array.from({ length: m + 1 }, () => 0),
680
- );
681
- const paired: boolean[][] = Array.from({ length: n + 1 }, () =>
682
- Array.from({ length: m + 1 }, () => false),
683
- );
684
-
685
- for (let i = 1; i <= n; i++) {
686
- for (let j = 1; j <= m; j++) {
687
- const pairing = scores[i - 1][j - 1];
688
- const withPairing = pairing > 0 ? best[i - 1][j - 1] + pairing : -Infinity;
689
- if (withPairing >= best[i - 1][j] && withPairing >= best[i][j - 1]) {
690
- best[i][j] = withPairing;
691
- paired[i][j] = true;
692
- } else {
693
- best[i][j] = Math.max(best[i - 1][j], best[i][j - 1]);
694
- }
695
- }
696
- }
697
-
698
- const assignment = new Map<number, number>(); // step number -> header index
699
- for (let i = n, j = m; i > 0 && j > 0;) {
700
- if (paired[i][j]) {
701
- assignment.set(steps[i - 1].number, j - 1);
702
- i--;
703
- j--;
704
- } else if (best[i - 1][j] >= best[i][j - 1]) {
705
- i--;
706
- } else {
707
- j--;
708
- }
709
- }
710
- return assignment;
711
- }
712
-
713
- /** Exclusive end index with trailing blank lines dropped, so a span slices to real text. */
714
- function trimTrailingBlankLines(lines: string[], start: number, end: number): number {
715
- while (end > start && (lines[end - 1] ?? "").trim() === "") end--;
716
- return end;
717
- }
718
-
719
- /**
720
- * Locate a job's steps in its raw log: each executed step emits a depth-1
721
- * `##[group]Run <x>` / `##[group]Post Run <x>` header, and its block runs from
722
- * that header up to the next executed step's header. Everything in between —
723
- * the action's own `::group::` output, a composite action's internal step
724
- * headers — belongs to the enclosing step.
725
- *
726
- * "Set up job" emits no header: it owns the runner preamble before the first
727
- * header. Steps with no header of their own (skipped steps, post steps the
728
- * runner never logged, "Complete job") get no span.
729
- */
730
- export function stepLineSpans(log: string, apiSteps: readonly StepRef[]): Map<number, StepSpan> {
731
- const lines = log.split("\n");
732
- const headers = stepHeaders(lines);
733
- const spans = new Map<number, StepSpan>();
734
-
735
- // "Set up job" is the runner's own preamble and never takes part in matching:
736
- // its start window overlaps the first real step's header.
737
- const preamble = apiSteps.find((s) => s.number === 1 && s.name === "Set up job");
738
- if (preamble !== undefined) {
739
- const end = trimTrailingBlankLines(lines, 0, headers[0]?.line ?? lines.length);
740
- spans.set(preamble.number, { start: 0, end });
741
- }
742
-
743
- // A skipped step never started, so it emitted no header — and its name often
744
- // repeats another step's ("Clear build" twice, "Post Run <action>" next to
745
- // its "Run <action>"), which would let it steal that step's block.
746
- const assignment = alignStepsToHeaders(
747
- apiSteps.filter((s) => s !== preamble && s.conclusion !== "skipped"),
748
- headers,
749
- );
750
- const placed = [...assignment]
751
- .map(([number, headerIndex]) => ({ number, line: headers[headerIndex].line }))
752
- .toSorted((a, b) => a.line - b.line);
753
-
754
- for (const [index, step] of placed.entries()) {
755
- const end = trimTrailingBlankLines(lines, step.line, placed[index + 1]?.line ?? lines.length);
756
- spans.set(step.number, { start: step.line, end });
757
- }
758
-
759
- return spans;
760
- }
761
-
762
- /** Text of `stepNumber` in its raw log, or null when the step never ran. */
763
- export function extractStepFromLog(
764
- log: string,
765
- stepNumber: number,
766
- apiSteps: readonly { number: number; name: string }[],
767
- ): string | null {
768
- const span = stepLineSpans(log, apiSteps).get(stepNumber);
769
- if (span === undefined) return null;
770
- return log.split("\n").slice(span.start, span.end).join("\n").trimEnd();
771
- }
772
-
773
- // ── ci-logs rendering (pure, testable) ──────────────────────────────────────
774
-
775
- export interface ToolResult {
776
- content: { type: "text"; text: string }[];
777
- details: Record<string, unknown>;
778
- }
779
-
780
- /** One step of a job, indexed into the job's raw log file. */
781
- export interface CiLogsStepIndex {
782
- number: number;
783
- name: string;
784
- conclusion: string | null;
785
- /** 1-based inclusive line range of this step's block in `log_file`. */
786
- start_line?: number;
787
- end_line?: number;
788
- }
789
-
790
- /** A job's steps plus the raw log file holding their output. */
791
- export interface CiLogsJobIndex {
792
- name: string;
793
- id: number;
794
- status: string;
795
- conclusion: string | null;
796
- log_file: string;
797
- steps: CiLogsStepIndex[];
798
- }
799
-
800
- /**
801
- * Index a job's steps into its raw log: every step that produced a log block
802
- * gets the `[start_line, end_line]` range (1-based, inclusive) of that block in
803
- * `log_file`; steps that never ran (skipped, or absent from the log) carry no
804
- * range. The step content itself is not returned — the model reads it out of
805
- * the file.
806
- */
807
- export function jobLogIndex(job: RunJob, rawLog: string): CiLogsJobIndex {
808
- const spans = stepLineSpans(rawLog, job.steps);
809
- return {
810
- name: job.name,
811
- id: job.id,
812
- status: job.status,
813
- conclusion: job.conclusion,
814
- log_file: jobLogPath(repoFromRunUrl(job.run_url), String(job.run_id), job.id),
815
- steps: job.steps.map((s) => {
816
- const span = spans.get(s.number);
817
- return {
818
- number: s.number,
819
- name: s.name,
820
- conclusion: s.conclusion,
821
- ...(span && span.end > span.start && { start_line: span.start + 1, end_line: span.end }),
822
- };
823
- }),
824
- };
825
- }
826
-
827
- // ── pr checks watch (pure rendering + poll loop) ────────────────────────────
828
-
829
- const CHECKS_POLL_INTERVAL_MS = 30_000;
830
- const CHECKS_WATCH_DEADLINE_MS = 600_000;
831
-
832
- export type CheckBucket = "pass" | "skipped" | "fail" | "pending";
833
-
834
- /**
835
- * One judged CI check of a commit: a single commit status or check run, kept
836
- * distinct — same-named checks from different sources (push vs pull_request
837
- * events, status vs check run channels) stay separate entries, like the
838
- * GitHub checks UI.
839
- */
840
- export interface MergedCheck {
841
- readonly name: string;
842
- readonly bucket: CheckBucket;
843
- readonly startedAt: string | null;
844
- readonly link: string | null;
845
- /** Triggering workflow event (push, pull_request, ...); null when unknown. */
846
- readonly event: string | null;
847
- /** Actions run id behind this check, for `get-github-workflow-jobs`; null when unknown. */
848
- readonly runId: number | null;
849
- /** Actions job id behind this check, for `read-github-ci-logs`; null when unknown. */
850
- readonly jobId: number | null;
851
- }
852
-
853
- function statusBucket(state: string): CheckBucket {
854
- if (state === "success") return "pass";
855
- if (state === "failure" || state === "error") return "fail";
856
- // pending, expected, and anything unknown must not end the wait
857
- return "pending";
858
- }
859
-
860
- function checkRunBucket(run: CheckRun): CheckBucket {
861
- if (run.status !== "completed" || run.conclusion === null) return "pending";
862
- switch (run.conclusion) {
863
- case "success": {
864
- return "pass";
865
- }
866
- case "skipped":
867
- case "neutral":
868
- case "stale":
869
- case "action_required": {
870
- // awaiting maintainer approval: it will never run, so waiting for it is
871
- // meaningless — treat like skipped
872
- return "skipped";
873
- }
874
- case "failure":
875
- case "timed_out":
876
- case "cancelled":
877
- case "startup_failure": {
878
- return "fail";
879
- }
880
- default: {
881
- return "pending";
882
- }
883
- }
884
- }
885
-
886
- /**
887
- * Judge the commit's statuses and check runs into individual checks, keeping
888
- * same-named entries distinct so the wait verdict (any fail / all
889
- * pass-or-skipped across every entry) can never lose a failure. Pure — no
890
- * network.
891
- */
892
- export function mergeChecks(
893
- statuses: readonly CommitStatus[],
894
- checkRuns: readonly CheckRun[],
895
- ): MergedCheck[] {
896
- return [
897
- ...statuses.map((status) => ({
898
- name: status.context,
899
- bucket: statusBucket(status.state),
900
- startedAt: null,
901
- link: status.targetUrl,
902
- event: null,
903
- runId: null,
904
- jobId: null,
905
- })),
906
- ...checkRuns.map((run) => ({
907
- name: run.name,
908
- bucket: checkRunBucket(run),
909
- startedAt: run.startedAt,
910
- link: run.url,
911
- event: run.event,
912
- runId: run.runId,
913
- jobId: run.jobId,
914
- })),
915
- ];
916
- }
917
-
918
- /** Display name of a check; the trigger event is labelled like the GitHub UI (`build (pull_request)`). */
919
- export function checkDisplayName(check: MergedCheck): string {
920
- return check.event ? `${check.name} (${check.event})` : check.name;
921
- }
922
-
923
- /**
924
- * Render one polling round as a compact bullet list of the checks still in
925
- * flight: running ones first (`- [>]`), queued ones after (`- [ ]`). Completed
926
- * checks are hidden — the header already reports the completion count.
927
- * Pure — no network.
928
- */
929
- export function renderPrChecksList(options: {
930
- /** Report subject, e.g. `PR #7` or `commit 5a7c407`. */
931
- subject: string;
932
- round: number;
933
- checks: readonly MergedCheck[];
934
- }): string {
935
- const { subject, round, checks } = options;
936
- const completed = checks.filter((c) => c.bucket !== "pending").length;
937
-
938
- const pending = checks.filter((c) => c.bucket === "pending");
939
- const ordered = [...pending.filter((c) => c.startedAt), ...pending.filter((c) => !c.startedAt)];
940
- const lines = ordered.map((check) => {
941
- const name = check.link
942
- ? `[${checkDisplayName(check)}](${check.link})`
943
- : checkDisplayName(check);
944
- return `- [${check.startedAt ? ">" : " "}] ${name}`;
945
- });
946
- const body =
947
- checks.length === 0 ? "- _no checks reported_" : lines.length > 0 ? lines.join("\n") : "";
948
-
949
- return `${subject} checks — round ${round}: ${completed}/${checks.length} complete${body ? `\n\n${body}` : ""}`;
950
- }
951
-
952
- function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promise<void> {
953
- return new Promise((resolve, reject) => {
954
- const onAbort = () => {
955
- clearTimeout(timer);
956
- reject(new Error("aborted while waiting for the next checks poll"));
957
- };
958
- const timer = setTimeout(() => {
959
- signal?.removeEventListener("abort", onAbort);
960
- resolve();
961
- }, ms);
962
- if (signal?.aborted) {
963
- onAbort();
964
- return;
965
- }
966
- signal?.addEventListener("abort", onAbort, { once: true });
967
- });
968
- }
969
-
970
- export type ChecksPollOutcome = "completed" | "fail_fast" | "timeout";
971
-
972
- export interface ChecksPollResult {
973
- readonly outcome: ChecksPollOutcome;
974
- readonly checks: readonly MergedCheck[];
975
- readonly elapsedMs: number;
976
- }
977
-
978
- export interface PollPrChecksOptions {
979
- /** Report subject for progress lines, e.g. `PR #7` or `commit 5a7c407`. */
980
- subject: string;
981
- owner: string;
982
- repo: string;
983
- headSha: string;
984
- failFast: boolean;
985
- checks: GithubChecksClient;
986
- /**
987
- * When set, only check runs triggered by this workflow event (e.g. push)
988
- * are judged; commit statuses have an unknown trigger event and are
989
- * excluded. Unset means all checks of the commit.
990
- */
991
- event?: string;
992
- /** Owned by the caller; the poll loop observes it but never aborts it. */
993
- signal: AbortSignal;
994
- /** Test overrides. */
995
- intervalMs?: number;
996
- deadlineMs?: number;
997
- onUpdate?: (msg: ToolResult) => void;
998
- }
999
-
1000
- /**
1001
- * Poll the commit's combined-status and check-runs APIs until the wait
1002
- * semantics are met: return on any failure (immediately under fail-fast) or
1003
- * when every check is complete (pass/skipped). Emits a compact list of
1004
- * in-flight checks via `onUpdate` each round.
1005
- *
1006
- * A failed round (network, auth) does not end the wait — the error is kept
1007
- * and polling continues, so a transient blip or a CI system that has not
1008
- * reported anything yet cannot be mistaken for a completed check set. Only
1009
- * when no round ever succeeded by the deadline is the last error thrown.
1010
- */
1011
- export async function pollPrChecks(options: PollPrChecksOptions): Promise<ChecksPollResult> {
1012
- const { subject, owner, repo, headSha, failFast, checks, signal, onUpdate } = options;
1013
- const intervalMs = options.intervalMs ?? CHECKS_POLL_INTERVAL_MS;
1014
- const deadlineMs = options.deadlineMs ?? CHECKS_WATCH_DEADLINE_MS;
1015
-
1016
- const watchStart = Date.now();
1017
- let lastChecks: readonly MergedCheck[] = [];
1018
- let lastError: unknown;
1019
- let everSucceeded = false;
1020
-
1021
- for (let round = 1; ; round++) {
1022
- if (signal.aborted) throw new Error("PR checks polling was aborted");
1023
- try {
1024
- const [statuses, runs] = await Promise.all([
1025
- checks.statuses(owner, repo, headSha, signal),
1026
- checks.checkRuns(owner, repo, headSha, signal),
1027
- ]);
1028
- everSucceeded = true;
1029
- lastChecks = mergeChecks(statuses, runs);
1030
- if (options.event) {
1031
- lastChecks = lastChecks.filter((c) => c.event === options.event);
1032
- }
1033
- onUpdate?.({
1034
- content: [
1035
- { type: "text", text: renderPrChecksList({ subject, round, checks: lastChecks }) },
1036
- ],
1037
- details: {},
1038
- });
1039
- if (lastChecks.every((c) => c.bucket !== "pending")) {
1040
- return { outcome: "completed", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1041
- }
1042
- if (failFast && lastChecks.some((c) => c.bucket === "fail")) {
1043
- return { outcome: "fail_fast", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1044
- }
1045
- } catch (error) {
1046
- // 不用 if (signal.aborted):循环顶部的同名字段检查把它收窄成 false,
1047
- // TS 会在 catch 里维持这个收窄。
1048
- signal.throwIfAborted();
1049
- lastError = error;
1050
- }
1051
- if (Date.now() - watchStart >= deadlineMs) {
1052
- if (!everSucceeded) {
1053
- const message = lastError instanceof Error ? lastError.message : String(lastError);
1054
- throw new Error(`PR checks polling failed before any round succeeded: ${message}`);
1055
- }
1056
- return { outcome: "timeout", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1057
- }
1058
- await sleepInterruptibly(intervalMs, signal);
1059
- }
1060
- }
1061
-
1062
- /** One Actions job that did not succeed, for the FAILED report details. */
1063
- export interface FailedActionJob {
1064
- readonly runId: number;
1065
- readonly runName: string;
1066
- readonly runUrl: string;
1067
- readonly jobId: number;
1068
- readonly jobName: string;
1069
- readonly conclusion: string;
1070
- readonly jobUrl?: string;
1071
- }
1072
-
1073
- export interface ChecksVerdict {
1074
- readonly status: "success" | "failure" | "pending";
1075
- readonly text: string;
1076
- readonly failedJobs: readonly FailedActionJob[];
1077
- }
1078
-
1079
- /**
1080
- * Turn a poll result into the final report. Verdict comes from the checks
1081
- * buckets alone (so external CI such as Azure counts); Actions jobs are
1082
- * display-only enrichment. Pure — no network.
1083
- */
1084
- export function renderChecksVerdict(options: {
1085
- /** Report subject, e.g. `PR #123` or `commit 5a7c407`. */
1086
- subject: string;
1087
- poll: ChecksPollResult;
1088
- /** All Actions jobs of the head commit; failed/incomplete ones are listed. */
1089
- actionJobs?: readonly ActionJob[];
1090
- /** Set when the Actions job fetch failed; the verdict stays untouched. */
1091
- enrichmentError?: string;
1092
- }): ChecksVerdict {
1093
- const { subject, poll, actionJobs, enrichmentError } = options;
1094
- const totalChecks = poll.checks.length;
1095
- const failed = poll.checks.filter((c) => c.bucket === "fail");
1096
- const pending = poll.checks.filter((c) => c.bucket === "pending");
1097
-
1098
- if (failed.length === 0 && poll.outcome === "completed") {
1099
- return {
1100
- status: "success",
1101
- text: `## ${subject} CI Checks - PASSED\n\nAll ${totalChecks} check(s) passed.`,
1102
- failedJobs: [],
1103
- };
1104
- }
1105
-
1106
- if (failed.length > 0) {
1107
- const failedJobs: FailedActionJob[] = (actionJobs ?? [])
1108
- .filter((j) => !j.conclusion || FAILED_JOB_CONCLUSIONS.has(j.conclusion))
1109
- .map((j) => ({
1110
- runId: j.runId,
1111
- runName: j.runName,
1112
- runUrl: j.runUrl,
1113
- jobId: j.jobId,
1114
- jobName: j.jobName,
1115
- conclusion: j.conclusion ?? "in_progress",
1116
- ...(j.jobUrl && { jobUrl: j.jobUrl }),
1117
- }));
1118
-
1119
- const lines = failed.map(
1120
- (c) =>
1121
- `- ${statusIcon("failure")} **${checkDisplayName(c)}**${c.link ? ` — [view check](${c.link})` : ""}`,
1122
- );
1123
- for (const j of failedJobs) {
1124
- lines.push(
1125
- ` - ${statusIcon(j.conclusion)} job **${j.jobName}** (${j.conclusion}) — [job #${j.jobId}](${j.jobUrl ?? j.runUrl})`,
1126
- ` - workflow: [${j.runName} (#${j.runId})](${j.runUrl})`,
1127
- );
1128
- }
1129
- if (enrichmentError) lines.push(` - _Actions job details unavailable: ${enrichmentError}_`);
1130
- if (pending.length > 0) lines.push(`\n_${pending.length} other check(s) still in flight._`);
1131
-
1132
- return {
1133
- status: "failure",
1134
- text:
1135
- `## ${subject} CI Checks - FAILED\n\n` +
1136
- `${failed.length} of ${totalChecks} check(s) failed:\n\n${lines.join("\n")}`,
1137
- failedJobs,
1138
- };
1139
- }
1140
-
1141
- const waitedMinutes = Math.max(1, Math.round(poll.elapsedMs / 60_000));
1142
- const pendingLines = pending.map(
1143
- (c) => `- [${c.startedAt ? ">" : " "}] ${c.link ? `[${c.name}](${c.link})` : c.name}`,
1144
- );
1145
- return {
1146
- status: "pending",
1147
- text:
1148
- `## ${subject} CI Checks - STILL IN FLIGHT\n\n` +
1149
- `${pending.length} of ${totalChecks} check(s) still incomplete after ~${waitedMinutes}m:\n\n` +
1150
- (pendingLines.length > 0 ? pendingLines.join("\n") : "- _no checks reported_"),
1151
- failedJobs: [],
1152
- };
1153
- }
1154
-
1155
- // ── GitHub REST client ───────────────────────────────────────────────────────
1156
-
1157
- /** Toolcall input for the handlers that read through the REST API. */
1158
- interface PrStatusParams {
1159
- number: number | string;
1160
- repo?: string;
1161
- }
1162
-
1163
- interface RunIdParams {
1164
- run_id: number | string;
1165
- repo?: string;
1166
- }
1167
-
1168
- interface JobIdParams {
1169
- job_id: number | string;
1170
- repo?: string;
1171
- }
1172
-
1173
- interface PrChecksWaitParams {
1174
- number: number | string;
1175
- repo?: string;
1176
- fail_fast?: boolean;
1177
- }
1178
-
1179
- interface CommitChecksWaitParams {
1180
- commit: number | string;
1181
- repo?: string;
1182
- event?: string;
1183
- fail_fast?: boolean;
1184
- }
1185
-
1186
- interface ReleaseDownloadParams {
1187
- repo?: string;
1188
- tag?: string;
1189
- pattern?: string;
1190
- archive?: "zip" | "tar.gz";
1191
- }
1192
-
1193
- /** What a toolcall handler receives from the framework. */
1194
- export interface ToolCall<Params> {
1195
- params: Params;
1196
- ctx: { cwd?: string };
1197
- signal?: AbortSignal;
1198
- /** Streaming progress updates, passed through as-is. */
1199
- onUpdate?: (update: ToolResult) => void;
1200
- }
1201
-
1202
- /**
1203
- * The GitHub reads that go through the REST API, with the HTTP layer injected:
1204
- * production hands in the proxy-aware fetch, tests hand in a stub and never
1205
- * touch the network. Handlers that only shell out to `gh` stay plain functions.
1206
- *
1207
- * `fetch` is a property (not module state) so a caller that needs different HTTP
1208
- * behavior — a test, another host — constructs its own instance.
1209
- */
1210
- export class GhClient {
1211
- readonly fetch: typeof globalThis.fetch;
1212
- private readonly search: GithubSearch;
1213
- private readonly checks: GithubChecksClient;
1214
-
1215
- constructor(fetchImpl: typeof globalThis.fetch = httpProxy.fetch) {
1216
- this.fetch = fetchImpl;
1217
- this.search = createGithubSearch({ fetch: fetchImpl });
1218
- this.checks = createGithubChecks({ fetch: fetchImpl });
1219
- }
1220
-
1221
- /** `list-github-issues` / `list-github-prs`: browse through `gh`, search through the API. */
1222
- private async list(kind: "issue" | "pr", call: ToolCall<ListFilters>): Promise<ToolResult> {
1223
- const { params, ctx, signal } = call;
1224
- const result = toToolResult(
1225
- params.keywords
1226
- ? await searchList(kind, params, this.search)
1227
- : await listGithub(kind, params, { cwd: ctx.cwd, signal, input: params }),
1228
- params,
1229
- );
1230
- result.details.pendant = subtitlePendant(params);
1231
- return result;
1232
- }
1233
-
1234
- listIssues(call: ToolCall<ListFilters>): Promise<ToolResult> {
1235
- return this.list("issue", call);
1236
- }
1237
-
1238
- listPrs(call: ToolCall<ListFilters>): Promise<ToolResult> {
1239
- return this.list("pr", call);
1240
- }
1241
-
1242
- /**
1243
- * `read-github-pr-status`: the PR head commit's checks as a snapshot. Same read
1244
- * path as the wait tools (octokit), but it never polls — pending checks come
1245
- * back as-is.
1246
- */
1247
- async prStatus(call: ToolCall<PrStatusParams>): Promise<ToolResult> {
1248
- const { params, ctx, signal } = call;
1249
- const { number, repo } = params;
1250
- const pullNumber = toPositiveId(number, "number");
1251
- const pendant = subtitlePendant(params, "number");
1252
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1253
- const { owner, repo: name } = splitRepo(effectiveRepo);
1254
-
1255
- const pollSignal = signal ?? new AbortController().signal;
1256
- const headSha = await this.checks.pullHead(owner, name, pullNumber, pollSignal);
1257
- const [statuses, checkRuns] = await Promise.all([
1258
- this.checks.statuses(owner, name, headSha, pollSignal),
1259
- this.checks.checkRuns(owner, name, headSha, pollSignal),
1260
- ]);
1261
- const checks = mergeChecks(statuses, checkRuns).map((check) => ({
1262
- name: check.name,
1263
- bucket: check.bucket,
1264
- event: check.event,
1265
- run_id: check.runId,
1266
- job_id: check.jobId,
1267
- url: check.link,
1268
- }));
1269
-
1270
- const payload = { pr: pullNumber, repo: effectiveRepo, head_sha: headSha, checks };
1271
- return {
1272
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1273
- details: { ...payload, input: params, ...(pendant && { pendant }) },
1274
- };
1275
- }
1276
-
1277
- /** `get-github-workflow-jobs`: every job of a run, all pages. */
1278
- async workflowJobs(call: ToolCall<RunIdParams>): Promise<ToolResult> {
1279
- const { params, ctx, signal } = call;
1280
- const runId = toPositiveId(params.run_id, "run_id");
1281
- const effectiveRepo = await resolveRepo(params.repo, signal, ctx.cwd, params);
1282
- const { owner, repo: name } = splitRepo(effectiveRepo);
1283
-
1284
- const jobs = await this.checks.runJobs(owner, name, runId, signal);
1285
- const result = toToolResult(JSON.stringify({ total_count: jobs.length, jobs }), params);
1286
- result.details.pendant = subtitlePendant(params, "run_id");
1287
- return result;
1288
- }
1289
-
1290
- /** `read-github-ci-logs`: one job's raw log on disk plus its step line ranges. */
1291
- async ciLogs(call: ToolCall<JobIdParams>): Promise<ToolResult> {
1292
- const { params, ctx, signal, onUpdate } = call;
1293
- const { job_id, repo } = params;
1294
- const jobId = toPositiveId(job_id, "job_id");
1295
-
1296
- const pendant = subtitlePendant(params, "job_id");
1297
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1298
- const { owner, repo: name } = splitRepo(effectiveRepo);
1299
-
1300
- const failure = (text: string): ToolResult => ({
1301
- content: [{ type: "text", text }],
1302
- details: { input: params, ...(pendant && { pendant }) },
1303
- });
1304
-
1305
- let target: RunJob;
1306
- try {
1307
- target = await this.checks.job(owner, name, jobId, signal);
1308
- } catch (error) {
1309
- const status = (error as { status?: number }).status;
1310
- if (status !== 404) throw error;
1311
- return failure(
1312
- `Job ${jobId} not found in ${effectiveRepo} — job IDs come from \`get-github-workflow-jobs\`.`,
1313
- );
1314
- }
1315
-
1316
- if (target.status === "queued") {
1317
- return failure(
1318
- `Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
1319
- );
1320
- }
1321
-
1322
- onUpdate?.({
1323
- content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
1324
- details: {},
1325
- });
1326
-
1327
- const rawLog = await getJobLog(target, signal, ctx.cwd, params);
1328
- const index = jobLogIndex(target, rawLog);
1329
-
1330
- return {
1331
- content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
1332
- details: { ...index, input: params, ...(pendant && { pendant }) },
1333
- };
1334
- }
1335
-
1336
- /** `wait-github-pr-checks`: poll the PR's head commit checks until they settle. */
1337
- async waitPrChecks(call: ToolCall<PrChecksWaitParams>): Promise<ToolResult> {
1338
- const { params, ctx, signal, onUpdate } = call;
1339
- const { number, repo, fail_fast } = params;
1340
-
1341
- const pendant = subtitlePendant(params, "number");
1342
- onUpdate?.({
1343
- content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
1344
- details: {},
1345
- });
1346
-
1347
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1348
- const { owner, repo: repoName } = splitRepo(effectiveRepo);
1349
-
1350
- const prOut = await ghExec(
1351
- ["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
1352
- { cwd: ctx.cwd, signal, input: params },
1353
- );
1354
- const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
1355
-
1356
- return this.waitChecksReport({
1357
- subject: `PR #${number}`,
1358
- owner,
1359
- repo: repoName,
1360
- headSha: headRefOid,
1361
- failFast: fail_fast === true,
1362
- signal,
1363
- onUpdate,
1364
- params,
1365
- pendant,
1366
- });
1367
- }
1368
-
1369
- /** `wait-github-commit-checks`: same, addressed by commit/branch/tag instead of a PR. */
1370
- async waitCommitChecks(call: ToolCall<CommitChecksWaitParams>): Promise<ToolResult> {
1371
- const { params, ctx, signal, onUpdate } = call;
1372
- const { commit, repo, event, fail_fast } = params;
1373
-
1374
- const pendant = subtitlePendant(params, "commit");
1375
- onUpdate?.({
1376
- content: [{ type: "text", text: `Watching CI checks for commit ${commit}...` }],
1377
- details: {},
1378
- });
1379
-
1380
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1381
- const { owner, repo: repoName } = splitRepo(effectiveRepo);
1382
-
1383
- const pollSignal = signal ?? new AbortController().signal;
1384
- const sha = await this.checks.headSha(owner, repoName, String(commit), pollSignal);
1385
-
1386
- return this.waitChecksReport({
1387
- subject: `commit ${sha.slice(0, 7)}`,
1388
- owner,
1389
- repo: repoName,
1390
- headSha: sha,
1391
- failFast: fail_fast === true,
1392
- event,
1393
- signal,
1394
- onUpdate,
1395
- params,
1396
- pendant,
1397
- });
1398
- }
1399
-
1400
- /**
1401
- * Shared wait core of `wait-github-pr-checks` and
1402
- * `wait-github-commit-checks`: poll the commit's checks, enrich FAILED
1403
- * reports with Actions job details (display-only), render the verdict.
1404
- * The caller resolves repo/headSha; `subject` formats the report header.
1405
- */
1406
- private async waitChecksReport(options: {
1407
- subject: string;
1408
- owner: string;
1409
- repo: string;
1410
- headSha: string;
1411
- failFast: boolean;
1412
- event?: string;
1413
- signal: AbortSignal | undefined;
1414
- onUpdate: ((msg: ToolResult) => void) | undefined;
1415
- params: unknown;
1416
- pendant?: ToolPendant;
1417
- }): Promise<ToolResult> {
1418
- const { subject, owner, repo, headSha, failFast, event, signal, onUpdate, params, pendant } =
1419
- options;
1420
-
1421
- // 轮询层要求非空 signal;框架可能不给时构造一个占位的(从不取消)。
1422
- const pollSignal = signal ?? new AbortController().signal;
1423
-
1424
- const poll = await pollPrChecks({
1425
- subject,
1426
- owner,
1427
- repo,
1428
- headSha,
1429
- failFast,
1430
- event,
1431
- checks: this.checks,
1432
- signal: pollSignal,
1433
- onUpdate,
1434
- });
1435
-
1436
- // Actions job 详情只做展示补充,不影响判定(判定来自 checks bucket,
1437
- // 覆盖 Azure 等外部 CI)。抓取失败时降级为提示,不推翻结论。
1438
- let actionJobs: readonly ActionJob[] | undefined;
1439
- let enrichmentError: string | undefined;
1440
- if (poll.checks.some((c) => c.bucket === "fail")) {
1441
- try {
1442
- actionJobs = await this.checks.actionJobs(owner, repo, headSha, pollSignal);
1443
- } catch (error) {
1444
- enrichmentError =
1445
- error instanceof Error ? error.message : "Actions job details unavailable";
1446
- }
1447
- }
1448
-
1449
- const verdict = renderChecksVerdict({ subject, poll, actionJobs, enrichmentError });
1450
- return {
1451
- content: [{ type: "text", text: verdict.text }],
1452
- details: {
1453
- status: verdict.status,
1454
- totalChecks: poll.checks.length,
1455
- checks: poll.checks,
1456
- failedJobs: verdict.failedJobs,
1457
- input: params,
1458
- ...(pendant && { pendant }),
1459
- },
1460
- };
1461
- }
1462
- }
1463
-
1464
- // ── release asset download ───────────────────────────────────────────────────
1465
-
1466
- /** One regular file in a release's download directory. */
1467
- interface ReleaseFile {
1468
- name: string;
1469
- path: string;
1470
- bytes: number;
1471
- }
1472
-
1473
- /** Split the comma-separated `pattern` toolcall parameter into gh pattern values. */
1474
- export function releasePatterns(pattern: string | undefined): string[] {
1475
- if (pattern === undefined) return [];
1476
- return pattern
1477
- .split(",")
1478
- .map((value) => value.trim())
1479
- .filter((value) => value !== "");
1480
- }
1481
-
1482
- /**
1483
- * The `gh release download` argv. `--skip-existing` is always on: the download
1484
- * directory is a cache, and rewriting a file that is already there would pull
1485
- * the ground out from under anything reading it.
1486
- */
1487
- export function releaseDownloadArgs(options: {
1488
- tag: string;
1489
- repo: string;
1490
- dir: string;
1491
- patterns: readonly string[];
1492
- archive?: "zip" | "tar.gz";
1493
- }): string[] {
1494
- const { tag, repo, dir, patterns, archive } = options;
1495
- const args = ["release", "download", tag, ...repoArgs(repo)];
1496
- if (archive !== undefined) args.push("--archive", archive);
1497
- for (const pattern of patterns) args.push("--pattern", pattern);
1498
- args.push("--dir", dir, "--skip-existing");
1499
- return args;
1500
- }
1501
-
1502
- /** Regular files directly inside `dir`, with their sizes, sorted by name. */
1503
- async function listReleaseFiles(dir: string): Promise<ReleaseFile[]> {
1504
- const entries = await readdir(dir, { withFileTypes: true });
1505
- const files: ReleaseFile[] = [];
1506
- for (const entry of entries) {
1507
- if (!entry.isFile()) continue;
1508
- const path = join(dir, entry.name);
1509
- const info = await stat(path);
1510
- files.push({ name: entry.name, path, bytes: info.size });
1511
- }
1512
- return files.toSorted((a, b) => a.name.localeCompare(b.name));
1513
- }
1514
-
1515
- /**
1516
- * `gh release download` answers this exact message when a `--pattern` matched no
1517
- * asset. It is the only signal the CLI offers, so the enrichment below degrades
1518
- * to gh's own error (still thrown) if the wording ever changes.
1519
- */
1520
- const GH_NO_ASSET_MATCH = "no assets match the file pattern";
1521
-
1522
- /**
1523
- * `download-github-release-assets`: fetch a release's assets (or source archive)
1524
- * into `releaseAssetDir` with the gh credentials, so private repositories work
1525
- * and the shell sandbox's network limits do not apply.
1526
- *
1527
- * The tag is resolved through `gh release view` before downloading: a tag that
1528
- * does not exist and a pattern that matched nothing are different answers, and
1529
- * the release's own asset names are what the model needs to fix the second one.
1530
- */
1531
- export async function downloadReleaseAssets(
1532
- call: ToolCall<ReleaseDownloadParams>,
1533
- ): Promise<ToolResult> {
1534
- const { params, ctx, signal } = call;
1535
- const patterns = releasePatterns(params.pattern);
1536
- if (params.archive !== undefined && patterns.length > 0) {
1537
- throw new Error(
1538
- "pattern and archive are mutually exclusive (pick asset globs or the source archive)",
1539
- );
1540
- }
1541
-
1542
- const effectiveRepo = await resolveRepo(params.repo, signal, ctx.cwd, params);
1543
- const view = Value.Parse(
1544
- releaseViewSchema,
1545
- JSON.parse(
1546
- await ghExec(
1547
- [
1548
- "release",
1549
- "view",
1550
- ...(params.tag === undefined ? [] : [params.tag]),
1551
- ...repoArgs(effectiveRepo),
1552
- "--json",
1553
- "tagName,assets",
1554
- ],
1555
- { cwd: ctx.cwd, signal, input: params },
1556
- ),
1557
- ),
1558
- );
1559
-
1560
- const assetNames = view.assets.map((asset) => asset.name);
1561
- const dir = releaseAssetDir(effectiveRepo, view.tagName);
1562
- await mkdir(dir, { recursive: true });
1563
- try {
1564
- await ghExec(
1565
- releaseDownloadArgs({
1566
- tag: view.tagName,
1567
- repo: effectiveRepo,
1568
- dir,
1569
- patterns,
1570
- ...(params.archive !== undefined && { archive: params.archive }),
1571
- }),
1572
- { cwd: ctx.cwd, signal, input: params },
1573
- );
1574
- } catch (error) {
1575
- // gh names the fault but not the choices; the release's asset list turns a
1576
- // dead end into the next toolcall.
1577
- if (error instanceof GhError && error.stderr.includes(GH_NO_ASSET_MATCH)) {
1578
- throw new Error(
1579
- `no asset of ${effectiveRepo}@${view.tagName} matched ${JSON.stringify(patterns)}; the release has: ${assetNames.join(", ") || "(no assets)"}`,
1580
- { cause: error },
1581
- );
1582
- }
1583
- throw error;
1584
- }
1585
-
1586
- const files = await listReleaseFiles(dir);
1587
- const payload = { repo: effectiveRepo, tag: view.tagName, dir, files };
1588
- const pendant = subtitlePendant({ repo: effectiveRepo, tag: view.tagName }, "tag");
1589
-
1590
- if (files.length === 0 && params.archive === undefined) {
1591
- return {
1592
- content: [
1593
- {
1594
- type: "text",
1595
- text: `Nothing to download from ${effectiveRepo}@${view.tagName}: the release has no assets (try archive for the source tarball)`,
1596
- },
1597
- ],
1598
- details: {
1599
- ...payload,
1600
- available_assets: assetNames,
1601
- input: params,
1602
- ...(pendant && { pendant }),
1603
- },
1604
- };
1605
- }
1606
-
1607
- return {
1608
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1609
- details: { ...payload, input: params, ...(pendant && { pendant }) },
1610
- };
1611
- }
1612
-
1613
- // ── tools ────────────────────────────────────────────────────────────────────
1614
-
1615
- export default function ghReadonlyTools(pi: ExtensionAPI) {
1616
- // Windows 上禁用:gh 可执行文件的探测(无扩展名 + POSIX 路径)与进程
1617
- // 管理(SIGTERM 信号语义)都是 POSIX 假设,不做 Windows 适配。
1618
- if (process.platform === "win32") {
1619
- pi.on("session_start", (_event, ctx) => {
1620
- ctx.ui.notify("gh-readonly tools are disabled on Windows.", "warning");
1621
- });
1622
- return;
1623
- }
1624
-
1625
- // Fail fast: the `gh` CLI is the only backend for these tools. Without it the
1626
- // extension registers nothing and reports the problem at session start, so
1627
- // the user gets one clear error instead of a dozen failing tool calls.
1628
- if (!isGhAvailable()) {
1629
- pi.on("session_start", (_event, ctx) => {
1630
- ctx.ui.notify(
1631
- "gh CLI not found in PATH: GitHub read-only tools are disabled. Install GitHub CLI (https://cli.github.com/) and reload the session.",
1632
- "error",
1633
- );
1634
- });
1635
- return;
1636
- }
1637
-
1638
- const client = new GhClient();
1639
-
1640
- // ── read-github-issue ──────────────────────────────────────────────────────
1641
- pi.registerTool({
1642
- name: "read-github-issue",
1643
- label: "GitHub Issue",
1644
- description: "Get details of a GitHub issue by number.",
1645
- promptSnippet: "Read a GitHub issue",
1646
- parameters: Type.Object({
1647
- number: Type.Union([Type.Number(), Type.String()], { description: "Issue number" }),
1648
- repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1649
- }),
1650
- async execute(_id, params, signal, _onUpdate, ctx) {
1651
- const { number, repo } = params;
1652
- const result = toToolResult(
1653
- await ghExec(
1654
- [
1655
- "issue",
1656
- "view",
1657
- String(number),
1658
- ...repoArgs(repo),
1659
- "--json",
1660
- "title,state,body,author,createdAt,updatedAt,closedAt,url,labels,assignees,comments,milestone,number",
1661
- ],
1662
- { cwd: ctx.cwd, signal, input: params },
1663
- ),
1664
- params,
1665
- );
1666
- result.details.pendant = subtitlePendant(params, "number");
1667
- return result;
1668
- },
1669
- });
1670
-
1671
- // ── list-github-issues ─────────────────────────────────────────────────────
1672
- pi.registerTool({
1673
- name: "list-github-issues",
1674
- label: "GitHub Issues List",
1675
- description:
1676
- 'List GitHub issues with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword search defaults to open issues — pass state="all" to include closed ones. Set fields to choose the columns of each result row.',
1677
- promptSnippet: "List or search GitHub issues",
1678
- parameters: Type.Object({
1679
- repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1680
- keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
1681
- state: Type.Optional(
1682
- Type.String({
1683
- description:
1684
- "open, closed, all (default: open; all applies to keyword search and covers closed too)",
1685
- }),
1686
- ),
1687
- label: Type.Optional(Type.String({ description: "Filter by label" })),
1688
- author: Type.Optional(Type.String({ description: "Filter by author" })),
1689
- assignee: Type.Optional(
1690
- Type.String({ description: "Filter by assignee (@me for yourself)" }),
1691
- ),
1692
- milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1693
- limit: Type.Optional(Type.Number({ description: "Max results (default 30, max 100)" })),
1694
- fields: Type.Optional(
1695
- Type.String({
1696
- description:
1697
- "Comma-separated columns for keyword-search rows (default: number,state,title,labels,updatedAt; adds repo when no repo given). Valid: number,state,title,url,author,labels,milestone,assignees,comments,repo,createdAt,updatedAt,closedAt",
1698
- }),
1699
- ),
1700
- }),
1701
- async execute(_id, params, signal, onUpdate, ctx) {
1702
- return client.listIssues({ params, ctx, signal, onUpdate });
1703
- },
1704
- });
1705
-
1706
- // ── read-github-pr ─────────────────────────────────────────────────────────
1707
- pi.registerTool({
1708
- name: "read-github-pr",
1709
- label: "GitHub PR",
1710
- description: "Get details of a GitHub pull request by number.",
1711
- promptSnippet: "Read a GitHub PR",
1712
- parameters: Type.Object({
1713
- number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
1714
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1715
- }),
1716
- async execute(_id, params, signal, _onUpdate, ctx) {
1717
- const { number, repo } = params;
1718
- const result = toToolResult(
1719
- await ghExec(
1720
- [
1721
- "pr",
1722
- "view",
1723
- String(number),
1724
- ...repoArgs(repo),
1725
- "--json",
1726
- "title,state,body,author,createdAt,updatedAt,mergedAt,mergedBy,headRefName,baseRefName,url,additions,deletions,changedFiles,labels,assignees,reviewRequests,reviews,comments,number",
1727
- ],
1728
- { cwd: ctx.cwd, signal, input: params },
1729
- ),
1730
- params,
1731
- );
1732
- result.details.pendant = subtitlePendant(params, "number");
1733
- return result;
1734
- },
1735
- });
1736
-
1737
- // ── list-github-prs ────────────────────────────────────────────────────────
1738
- pi.registerTool({
1739
- name: "list-github-prs",
1740
- label: "GitHub PRs List",
1741
- description:
1742
- 'List GitHub pull requests with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword search defaults to open PRs — pass state="merged", state="closed" (merged excluded) or state="all" to broaden. Set fields to choose the columns of each result row.',
1743
- promptSnippet: "List or search GitHub PRs",
1744
- parameters: Type.Object({
1745
- repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1746
- keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
1747
- state: Type.Optional(
1748
- Type.String({
1749
- description:
1750
- "open, closed, merged, all (default: open; all applies to keyword search and covers open + closed + merged)",
1751
- }),
1752
- ),
1753
- label: Type.Optional(Type.String({ description: "Filter by label" })),
1754
- author: Type.Optional(Type.String({ description: "Filter by author" })),
1755
- assignee: Type.Optional(
1756
- Type.String({ description: "Filter by assignee (@me for yourself)" }),
1757
- ),
1758
- milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1759
- limit: Type.Optional(Type.Number({ description: "Max results (default 30, max 100)" })),
1760
- fields: Type.Optional(
1761
- Type.String({
1762
- description:
1763
- "Comma-separated columns for keyword-search rows (default: number,state,title,labels,updatedAt; adds repo when no repo given). Valid: number,state,title,url,author,labels,milestone,assignees,comments,repo,createdAt,updatedAt,closedAt,mergedAt",
1764
- }),
1765
- ),
1766
- }),
1767
- async execute(_id, params, signal, onUpdate, ctx) {
1768
- return client.listPrs({ params, ctx, signal, onUpdate });
1769
- },
1770
- });
1771
-
1772
- // ── read-github-pr-diff ────────────────────────────────────────────────────
1773
- pi.registerTool({
1774
- name: "read-github-pr-diff",
1775
- label: "GitHub PR Diff",
1776
- description: "Get the diff of a GitHub pull request.",
1777
- promptSnippet: "Read a GitHub PR diff",
1778
- parameters: Type.Object({
1779
- number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
1780
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1781
- }),
1782
- async execute(_id, params, signal, _onUpdate, ctx) {
1783
- const { number, repo } = params;
1784
- const args = ["pr", "diff", String(number), ...repoArgs(repo)];
1785
- const result = toToolResult(
1786
- await ghExec(args, { cwd: ctx.cwd, signal, input: params }),
1787
- params,
1788
- );
1789
- result.details.pendant = subtitlePendant(params, "number");
1790
- return result;
1791
- },
1792
- });
1793
-
1794
- // ── read-github-pr-status ──────────────────────────────────────────────────
1795
- pi.registerTool({
1796
- name: "read-github-pr-status",
1797
- label: "GitHub PR Status",
1798
- description:
1799
- "Get the current checks of a pull request's head commit as JSON {pr, repo, head_sha, checks:[{name, bucket, event, run_id, job_id, url}]}. `bucket` is pass / fail / pending / skipped; Actions checks carry the `run_id` and `job_id` behind them (null for other CI), which is what read-github-ci-logs and get-github-workflow-jobs take. Returns the snapshot immediately without waiting — use wait-github-pr-checks to block until the checks finish.",
1800
- promptSnippet: "Read GitHub PR status checks",
1801
- parameters: Type.Object({
1802
- number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
1803
- repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1804
- }),
1805
- async execute(_id, params, signal, onUpdate, ctx) {
1806
- return client.prStatus({ params, ctx, signal, onUpdate });
1807
- },
1808
- });
1809
-
1810
- // ── read-github-pr-comments ────────────────────────────────────────────────
1811
- pi.registerTool({
1812
- name: "read-github-pr-comments",
1813
- label: "GitHub PR Comments",
1814
- description:
1815
- "Get review comments on a GitHub pull request. Set reviews=true for inline code review comments with diff_hunk.",
1816
- promptSnippet: "Read GitHub PR comments",
1817
- parameters: Type.Object({
1818
- number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
1819
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1820
- reviews: Type.Optional(
1821
- Type.Boolean({
1822
- description:
1823
- "If true, returns inline code review comments (with diff_hunk, path, line) via API. Default: false (returns issue comments).",
1824
- }),
1825
- ),
1826
- }),
1827
- async execute(_id, params, signal, _onUpdate, ctx) {
1828
- const { number, repo, reviews } = params;
1829
- let out: string;
1830
- if (reviews) {
1831
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1832
-
1833
- const [reviewComments, reviewSummaries] = await Promise.all([
1834
- ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/comments`, {
1835
- cwd: ctx.cwd,
1836
- signal,
1837
- input: params,
1838
- }),
1839
- ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/reviews`, {
1840
- cwd: ctx.cwd,
1841
- signal,
1842
- input: params,
1843
- }),
1844
- ]);
1845
-
1846
- out = JSON.stringify(
1847
- {
1848
- reviews: reviewSummaries,
1849
- comments: reviewComments,
1850
- },
1851
- null,
1852
- 2,
1853
- );
1854
- } else {
1855
- out = await ghExec(
1856
- ["pr", "view", String(number), ...repoArgs(repo), "--json", "comments"],
1857
- {
1858
- cwd: ctx.cwd,
1859
- signal,
1860
- input: params,
1861
- },
1862
- );
1863
- }
1864
- const { text, truncated } = truncate(out);
1865
- const pendant = subtitlePendant(params, "number");
1866
- return {
1867
- content: [{ type: "text", text }],
1868
- details: { input: params, truncated, ...(pendant && { pendant }) },
1869
- };
1870
- },
1871
- });
1872
-
1873
- // ── read-github-issue-comments ─────────────────────────────────────────────
1874
- pi.registerTool({
1875
- name: "read-github-issue-comments",
1876
- label: "GitHub Issue Comments",
1877
- description: "Get comments on a GitHub issue.",
1878
- promptSnippet: "Read GitHub issue comments",
1879
- parameters: Type.Object({
1880
- number: Type.Union([Type.Number(), Type.String()], { description: "Issue number" }),
1881
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1882
- }),
1883
- async execute(_id, params, signal, _onUpdate, ctx) {
1884
- const { number, repo } = params;
1885
- const result = toToolResult(
1886
- await ghExec(["issue", "view", String(number), ...repoArgs(repo), "--json", "comments"], {
1887
- cwd: ctx.cwd,
1888
- signal,
1889
- input: params,
1890
- }),
1891
- params,
1892
- );
1893
- result.details.pendant = subtitlePendant(params, "number");
1894
- return result;
1895
- },
1896
- });
1897
-
1898
- // ── list-github-workflow-runs ──────────────────────────────────────────────
1899
- pi.registerTool({
1900
- name: "list-github-workflow-runs",
1901
- label: "GitHub Workflow Runs",
1902
- description: "List GitHub Actions workflow runs.",
1903
- promptSnippet: "List GitHub workflow runs",
1904
- parameters: Type.Object({
1905
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1906
- limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1907
- status: Type.Optional(
1908
- Type.String({ description: "Filter by status: success, failure, cancelled, etc." }),
1909
- ),
1910
- workflow: Type.Optional(Type.String({ description: "Filter by workflow name or file" })),
1911
- }),
1912
- async execute(_id, params, signal, _onUpdate, ctx) {
1913
- const { repo, limit, status, workflow } = params;
1914
- const args = ["run", "list", ...repoArgs(repo)];
1915
- if (limit) args.push("--limit", String(limit));
1916
- if (status) args.push("--status", status);
1917
- if (workflow) args.push("--workflow", workflow);
1918
- const result = toToolResult(
1919
- await ghExec(args, { cwd: ctx.cwd, signal, input: params }),
1920
- params,
1921
- );
1922
- result.details.pendant = subtitlePendant(params);
1923
- return result;
1924
- },
1925
- });
1926
-
1927
- // ── read-github-ci-logs ────────────────────────────────────────────────────
1928
- pi.registerTool({
1929
- name: "read-github-ci-logs",
1930
- label: "GitHub CI Logs",
1931
- description:
1932
- "Download one GitHub Actions job's CI log by job ID and index its steps. Returns JSON {name, id, status, conclusion, log_file, steps:[{number, name, conclusion, start_line?, end_line?}]}: `log_file` is the job's complete raw log on disk (runner timestamps and ANSI kept, exactly as GitHub delivers it) and each step carries the 1-based inclusive line range of its block inside that file. Read the content out of the file yourself (read/grep with offset/limit) — it is not echoed back. Get the job IDs from get-github-workflow-jobs, then call this once per job you need." +
1933
- " Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
1934
- promptSnippet: "Read GitHub CI logs",
1935
- parameters: Type.Object({
1936
- job_id: Type.Union([Type.Number(), Type.String()], {
1937
- description: "Job ID, from get-github-workflow-jobs.",
1938
- }),
1939
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1940
- }),
1941
- async execute(_id, params, signal, onUpdate, ctx) {
1942
- return client.ciLogs({ params, ctx, signal, onUpdate });
1943
- },
1944
- });
1945
-
1946
- // ── get-github-workflow-jobs ──────────────────────────────────────────────
1947
- pi.registerTool({
1948
- name: "get-github-workflow-jobs",
1949
- label: "GitHub Workflow Jobs",
1950
- description:
1951
- "Get every job of a workflow run as JSON {total_count, jobs:[{id, run_id, run_url, name, status, conclusion, html_url, steps:[{name, number, status, conclusion, started_at}]}]}. Paginated server-side, so runs with more than 30 jobs return all of them. Use the `id` with read-github-ci-logs after read-github-pr-status / wait-github-commit-checks did not already give you a job id.",
1952
- promptSnippet: "Get GitHub workflow run jobs",
1953
- parameters: Type.Object({
1954
- run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
1955
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1956
- }),
1957
- async execute(_id, params, signal, onUpdate, ctx) {
1958
- return client.workflowJobs({ params, ctx, signal, onUpdate });
1959
- },
1960
- });
1961
-
1962
- // ── read-github-repo ───────────────────────────────────────────────────────
1963
- pi.registerTool({
1964
- name: "read-github-repo",
1965
- label: "GitHub Repo",
1966
- description: "Get repository information.",
1967
- promptSnippet: "Read GitHub repo info",
1968
- parameters: Type.Object({
1969
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1970
- }),
1971
- async execute(_id, params, signal, _onUpdate, ctx) {
1972
- const { repo } = params;
1973
- const args = ["repo", "view"];
1974
- if (repo) args.push(repo);
1975
- const result = toToolResult(
1976
- await ghExec(args, { cwd: ctx.cwd, signal, input: params }),
1977
- params,
1978
- );
1979
- result.details.pendant = subtitlePendant(params);
1980
- return result;
1981
- },
1982
- });
1983
-
1984
- // ── list-github-releases ───────────────────────────────────────────────────
1985
- pi.registerTool({
1986
- name: "list-github-releases",
1987
- label: "GitHub Releases List",
1988
- description: "List GitHub releases.",
1989
- promptSnippet: "List GitHub releases",
1990
- parameters: Type.Object({
1991
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1992
- limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
1993
- }),
1994
- async execute(_id, params, signal, _onUpdate, ctx) {
1995
- const { repo, limit } = params;
1996
- const args = ["release", "list", ...repoArgs(repo)];
1997
- if (limit) args.push("--limit", String(limit));
1998
- const result = toToolResult(
1999
- await ghExec(args, { cwd: ctx.cwd, signal, input: params }),
2000
- params,
2001
- );
2002
- result.details.pendant = subtitlePendant(params);
2003
- return result;
2004
- },
2005
- });
2006
-
2007
- // ── read-github-release ────────────────────────────────────────────────────
2008
- pi.registerTool({
2009
- name: "read-github-release",
2010
- label: "GitHub Release",
2011
- description: "Get details of a specific GitHub release by tag.",
2012
- promptSnippet: "Read a GitHub release",
2013
- parameters: Type.Object({
2014
- tag: Type.String({ description: "Release tag name" }),
2015
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
2016
- }),
2017
- async execute(_id, params, signal, _onUpdate, ctx) {
2018
- const { tag, repo } = params;
2019
- const result = toToolResult(
2020
- await ghExec(["release", "view", tag, ...repoArgs(repo)], {
2021
- cwd: ctx.cwd,
2022
- signal,
2023
- input: params,
2024
- }),
2025
- params,
2026
- );
2027
- result.details.pendant = subtitlePendant(params, "tag");
2028
- return result;
2029
- },
2030
- });
2031
-
2032
- // ── download-github-release-assets ────────────────────────────────────────
2033
- pi.registerTool({
2034
- name: "download-github-release-assets",
2035
- label: "GitHub Release Download",
2036
- description:
2037
- "Download a GitHub release's assets (or its source archive) into " +
2038
- "~/.cache/pi/github/releases/<owner>/<repo>/<tag>/ using the gh CLI's credentials, " +
2039
- "so private repositories and large binaries work where a plain HTTP fetch cannot. " +
2040
- "Files already in that directory are kept, never re-fetched. The result is the JSON " +
2041
- "summary {repo, tag, dir, files:[{name, path, bytes}]} listing everything now in the " +
2042
- "directory; file contents are not echoed. Read the entries you need from `path`.",
2043
- promptSnippet: "Download GitHub release assets",
2044
- parameters: Type.Object({
2045
- repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
2046
- tag: Type.Optional(
2047
- Type.String({ description: "Release tag (defaults to the latest release)" }),
2048
- ),
2049
- pattern: Type.Optional(
2050
- Type.String({
2051
- description:
2052
- 'Comma-separated glob patterns for asset names, e.g. "*.tar.gz,*.deb" (default: every asset)',
2053
- }),
2054
- ),
2055
- archive: Type.Optional(
2056
- Type.Union([Type.Literal("zip"), Type.Literal("tar.gz")], {
2057
- description: "Download the release's source archive instead of its assets",
2058
- }),
2059
- ),
2060
- }),
2061
- async execute(_id, params, signal, _onUpdate, ctx) {
2062
- return downloadReleaseAssets({ params, ctx, signal });
2063
- },
2064
- });
2065
-
2066
- // ── wait-github-pr-checks ─────────────────────────────────────────────────
2067
- pi.registerTool({
2068
- name: "wait-github-pr-checks",
2069
- label: "Watch GitHub PR Checks",
2070
- description:
2071
- "Watch CI status checks for a PR until they complete. Blocks until all checks pass (or are skipped) or one fails. " +
2072
- "Covers both commit statuses (Azure DevOps, Jenkins, ...) and GitHub Actions check runs. " +
2073
- "Each polling round streams a compact bullet list of the checks still in flight via onUpdate; " +
2074
- "on timeout the still-in-flight snapshot is returned instead of a verdict. " +
2075
- "Use this when you need to wait for CI to complete and see the final result.",
2076
- promptSnippet: "Watch and wait for GitHub PR CI checks to complete",
2077
- parameters: Type.Object({
2078
- number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
2079
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
2080
- fail_fast: Type.Optional(
2081
- Type.Boolean({ description: "Exit immediately when any check fails (default: false)" }),
2082
- ),
2083
- }),
2084
- async execute(_id, params, signal, onUpdate, ctx) {
2085
- return client.waitPrChecks({ params, ctx, signal, onUpdate });
2086
- },
2087
- });
2088
-
2089
- // ── wait-github-commit-checks ──────────────────────────────────────────────
2090
- pi.registerTool({
2091
- name: "wait-github-commit-checks",
2092
- label: "Watch GitHub Commit Checks",
2093
- description:
2094
- "Watch CI status checks for a commit until they complete — no pull request required. " +
2095
- "Same semantics as wait-github-pr-checks: returns when any check fails (immediately under fail_fast) " +
2096
- "or all checks pass/skip; on timeout the still-in-flight snapshot is returned. " +
2097
- "With `event`, only check runs triggered by that workflow event (e.g. push) are judged; " +
2098
- "commit statuses have an unknown trigger event and are excluded under a filter. " +
2099
- "Use this to wait for the runs a commit's push triggered, or for checks on an arbitrary ref.",
2100
- promptSnippet: "Watch and wait for GitHub commit CI checks to complete",
2101
- parameters: Type.Object({
2102
- commit: Type.Union([Type.Number(), Type.String()], {
2103
- description:
2104
- "Commit to wait for: full or partial SHA, branch name, or tag name (resolved to the commit's SHA)",
2105
- }),
2106
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
2107
- event: Type.Optional(
2108
- Type.String({
2109
- description:
2110
- "Only judge check runs triggered by this workflow event (e.g. push, pull_request)",
2111
- }),
2112
- ),
2113
- fail_fast: Type.Optional(
2114
- Type.Boolean({ description: "Exit immediately when any check fails (default: false)" }),
2115
- ),
2116
- }),
2117
- async execute(_id, params, signal, onUpdate, ctx) {
2118
- return client.waitCommitChecks({ params, ctx, signal, onUpdate });
2119
- },
2120
- });
2121
-
2122
- // ── watch-github-run ───────────────────────────────────────────────────────
2123
- pi.registerTool({
2124
- name: "watch-github-run",
2125
- label: "Watch GitHub Workflow Run",
2126
- description:
2127
- "Watch a GitHub Actions workflow run until it completes. " +
2128
- "Blocks until the run finishes and shows the final status.",
2129
- promptSnippet: "Watch and wait for a GitHub Actions run to complete",
2130
- parameters: Type.Object({
2131
- run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
2132
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
2133
- }),
2134
- async execute(_id, params, signal, onUpdate, ctx) {
2135
- const { run_id, repo } = params;
2136
-
2137
- const pendant = subtitlePendant(params, "run_id");
2138
- onUpdate?.({
2139
- content: [{ type: "text", text: `Watching workflow run ${run_id}...` }],
2140
- details: {},
2141
- });
2142
-
2143
- const result = await runGh(["run", "watch", String(run_id), ...repoArgs(repo)], {
2144
- cwd: ctx.cwd,
2145
- signal,
2146
- timeout: 600_000,
2147
- });
2148
-
2149
- if (result.code !== 0) {
2150
- throw new Error(`gh run watch failed: ${result.stderr || `exit code ${result.code}`}`);
2151
- }
2152
8
 
2153
- return {
2154
- content: [
2155
- { type: "text", text: `## Workflow Run ${run_id} Completed\n\n${result.stdout}` },
2156
- ],
2157
- details: { exitCode: 0, input: params, ...(pendant && { pendant }) },
2158
- };
2159
- },
2160
- });
2161
- }
9
+ export { default } from "./gh/index.js";
10
+ export * from "./gh/index.js";