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