@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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read-github-ci-logs`: fetch a job's raw log (with a file cache) and index
|
|
3
|
+
* each executed step's block inside it, so the model can read the exact line
|
|
4
|
+
* range of the step it needs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
|
|
14
|
+
import type { RunJob } from "../../lib/github.js";
|
|
15
|
+
import { createSeqState } from "../../lib/seq-state.js";
|
|
16
|
+
import {
|
|
17
|
+
type GhClient,
|
|
18
|
+
ghExec,
|
|
19
|
+
resolveRepo,
|
|
20
|
+
splitRepo,
|
|
21
|
+
subtitlePendant,
|
|
22
|
+
type ToolCall,
|
|
23
|
+
type ToolResult,
|
|
24
|
+
toPositiveId,
|
|
25
|
+
} from "../base.js";
|
|
26
|
+
|
|
27
|
+
// 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
|
|
28
|
+
// 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
|
|
29
|
+
const seq = createSeqState();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `OWNER/REPO` from a job's `run_url`
|
|
33
|
+
* (`https://api.github.com/repos/OWNER/REPO/actions/runs/123`). The path is
|
|
34
|
+
* parsed as a URL rather than pattern-matched, and GitHub canonicalizes the
|
|
35
|
+
* owner/repo casing in these fields — so this is the spelling to key the log
|
|
36
|
+
* cache on, independent of whatever `repo` the caller passed.
|
|
37
|
+
*/
|
|
38
|
+
export function repoFromRunUrl(runUrl: string): string {
|
|
39
|
+
const segments = new URL(runUrl).pathname.split("/").filter(Boolean);
|
|
40
|
+
const reposAt = segments.indexOf("repos");
|
|
41
|
+
const ownerAndRepo = reposAt === -1 ? [] : segments.slice(reposAt + 1, reposAt + 3);
|
|
42
|
+
if (ownerAndRepo.length !== 2) {
|
|
43
|
+
throw new Error(`unexpected run_url (expected /repos/<owner>/<repo>/...): ${runUrl}`);
|
|
44
|
+
}
|
|
45
|
+
return ownerAndRepo.join("/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Absolute path of the raw job log cache file written by `getJobLog`. */
|
|
49
|
+
export function jobLogPath(repo: string, runId: string, jobId: number): string {
|
|
50
|
+
const { owner, repo: name } = splitRepo(repo);
|
|
51
|
+
return join(homedir(), ".cache", "pi", "github", "ci-logs", owner, name, runId, `${jobId}.log`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getJobLog(
|
|
55
|
+
job: RunJob,
|
|
56
|
+
signal: AbortSignal | undefined,
|
|
57
|
+
cwd: string | undefined,
|
|
58
|
+
input?: unknown,
|
|
59
|
+
): Promise<string> {
|
|
60
|
+
// The log download only accepts a job id, and the cache is keyed on the
|
|
61
|
+
// canonical repo/run from the job itself, not on the caller's `repo` string.
|
|
62
|
+
const repo = repoFromRunUrl(job.run_url);
|
|
63
|
+
const cacheFile = jobLogPath(repo, String(job.run_id), job.id);
|
|
64
|
+
const cacheDir = dirname(cacheFile);
|
|
65
|
+
|
|
66
|
+
// 同一 cache 文件的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
|
|
67
|
+
// 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
|
|
68
|
+
return seq.execute(cacheFile, async () => {
|
|
69
|
+
// Check file cache
|
|
70
|
+
try {
|
|
71
|
+
return await readFile(cacheFile, "utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
// Not cached, fetch from GitHub
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `gh api` refuses to print responses that contain terminal escape
|
|
77
|
+
// sequences unless `--allow-escape-sequences` is passed. Job logs carry
|
|
78
|
+
// ANSI color codes, so without this flag the download always fails with
|
|
79
|
+
// "the response contains terminal escape sequences; pass
|
|
80
|
+
// --allow-escape-sequences to output it anyway". The raw bytes are kept
|
|
81
|
+
// as-is (the file is the log exactly as GitHub delivers it); the tool never
|
|
82
|
+
// echoes them, and the TUI strips ANSI when rendering tool results.
|
|
83
|
+
const log = await ghExec(
|
|
84
|
+
["api", "--allow-escape-sequences", `/repos/${repo}/actions/jobs/${job.id}/logs`],
|
|
85
|
+
{
|
|
86
|
+
cwd,
|
|
87
|
+
signal,
|
|
88
|
+
input,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Write to cache
|
|
93
|
+
await mkdir(cacheDir, { recursive: true });
|
|
94
|
+
await writeFile(cacheFile, log);
|
|
95
|
+
|
|
96
|
+
return log;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A step's line span in the raw job log: 0-based `start`, exclusive `end`. */
|
|
101
|
+
export interface StepSpan {
|
|
102
|
+
start: number;
|
|
103
|
+
end: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The part of a step the log index needs. `RunJobStep` satisfies it. */
|
|
107
|
+
export interface StepRef {
|
|
108
|
+
number: number;
|
|
109
|
+
name: string;
|
|
110
|
+
conclusion?: string | null;
|
|
111
|
+
started_at?: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A `##[group]Run …` / `##[group]Post Run …` line: the header of one executed step. */
|
|
115
|
+
interface StepHeader {
|
|
116
|
+
/** 0-based index of the `##[group]` line. */
|
|
117
|
+
line: number;
|
|
118
|
+
/** Header text with the `Run ` / `Post Run ` prefix stripped. */
|
|
119
|
+
action: string;
|
|
120
|
+
/** Runner timestamp on that line (epoch ms), null when unparsable. */
|
|
121
|
+
timestamp: number | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Runner timestamp every log line starts with: `2026-08-05T16:36:08.1842645Z `. */
|
|
125
|
+
const LOG_TIMESTAMP_RE = /^\uFEFF?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) /;
|
|
126
|
+
const HEADER_PREFIX_RE = /^(Run |Post Run )/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The runner writes a step's header within ~1.6s of the step's API `started_at`
|
|
130
|
+
* (the API truncates its timestamps to whole seconds), so a header inside this
|
|
131
|
+
* window is evidence of the step it belongs to.
|
|
132
|
+
*/
|
|
133
|
+
const HEADER_WINDOW_MS = 5_000;
|
|
134
|
+
/** An exact name match proves the header belongs to that step. */
|
|
135
|
+
const NAME_MATCH_SCORE = 4;
|
|
136
|
+
/** Weight of a header inside the step's start window. */
|
|
137
|
+
const TIME_MATCH_SCORE = 2;
|
|
138
|
+
|
|
139
|
+
function headerTimestamp(line: string): number | null {
|
|
140
|
+
const match = LOG_TIMESTAMP_RE.exec(line);
|
|
141
|
+
if (match === null) return null;
|
|
142
|
+
const ms = Date.parse(match[1]);
|
|
143
|
+
return Number.isNaN(ms) ? null : ms;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Collect the depth-1 `Run ` / `Post Run ` headers, in log order. */
|
|
147
|
+
function stepHeaders(lines: string[]): StepHeader[] {
|
|
148
|
+
const headers: StepHeader[] = [];
|
|
149
|
+
let depth = 0;
|
|
150
|
+
for (const [i, line] of lines.entries()) {
|
|
151
|
+
if (line.includes("##[endgroup]")) {
|
|
152
|
+
if (depth > 0) depth--;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!line.includes("##[group]")) continue;
|
|
156
|
+
depth++;
|
|
157
|
+
if (depth !== 1) continue;
|
|
158
|
+
const name = /##\[group\](.*)/.exec(line)?.[1].trim() ?? "";
|
|
159
|
+
if (HEADER_PREFIX_RE.test(name)) {
|
|
160
|
+
headers.push({
|
|
161
|
+
line: i,
|
|
162
|
+
action: name.replace(HEADER_PREFIX_RE, "").trim(),
|
|
163
|
+
timestamp: headerTimestamp(line),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return headers;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** How well a step explains a header — 0 means "no evidence, don't guess". */
|
|
171
|
+
function headerScore(step: StepRef, header: StepHeader): number {
|
|
172
|
+
let score = 0;
|
|
173
|
+
const named = step.name.replace(HEADER_PREFIX_RE, "").trim();
|
|
174
|
+
if (HEADER_PREFIX_RE.test(step.name) && named === header.action) {
|
|
175
|
+
score += NAME_MATCH_SCORE;
|
|
176
|
+
}
|
|
177
|
+
const started = step.started_at == null ? NaN : Date.parse(step.started_at);
|
|
178
|
+
if (!Number.isNaN(started) && header.timestamp !== null) {
|
|
179
|
+
const delta = header.timestamp - started;
|
|
180
|
+
if (delta >= 0 && delta <= HEADER_WINDOW_MS) {
|
|
181
|
+
score += TIME_MATCH_SCORE * (1 - delta / HEADER_WINDOW_MS);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return score;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Align steps with headers: an order-preserving best-scoring matching, where
|
|
189
|
+
* either side may be left unmatched. Only pairs with real evidence are matched,
|
|
190
|
+
* so a step whose block cannot be identified gets no span instead of a guess.
|
|
191
|
+
*
|
|
192
|
+
* Name evidence disappears as soon as the workflow names a step with `name:`
|
|
193
|
+
* (the API name is then the custom one, while the log header carries the action
|
|
194
|
+
* or command), which is why the timestamps matter too. Headers belonging to a
|
|
195
|
+
* composite action's *internal* steps carry no evidence for any API step, so
|
|
196
|
+
* they stay unmatched and are absorbed into the enclosing step's span.
|
|
197
|
+
*/
|
|
198
|
+
function alignStepsToHeaders(
|
|
199
|
+
steps: readonly StepRef[],
|
|
200
|
+
headers: StepHeader[],
|
|
201
|
+
): Map<number, number> {
|
|
202
|
+
const n = steps.length;
|
|
203
|
+
const m = headers.length;
|
|
204
|
+
// Equal-scoring alignments are decided in favour of the earlier step: a step
|
|
205
|
+
// whose output the runner never logged (post steps, "Complete job") comes last
|
|
206
|
+
// in step order, so a header claimed by both belongs to the earlier one. A
|
|
207
|
+
// step that ran always emits its header before the next step starts, which
|
|
208
|
+
// leaves several steps competing for one header whenever the API timestamps
|
|
209
|
+
// (whole seconds) collapse them into the same second.
|
|
210
|
+
const TIE_BREAK = 1e-6;
|
|
211
|
+
const scores = steps.map((step, i) =>
|
|
212
|
+
headers.map((header) => {
|
|
213
|
+
const score = headerScore(step, header);
|
|
214
|
+
return score > 0 ? score + TIE_BREAK * (n - i) : 0;
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
// best[i][j]: score of aligning the first i steps with the first j headers.
|
|
218
|
+
const best: number[][] = Array.from({ length: n + 1 }, () =>
|
|
219
|
+
Array.from({ length: m + 1 }, () => 0),
|
|
220
|
+
);
|
|
221
|
+
const paired: boolean[][] = Array.from({ length: n + 1 }, () =>
|
|
222
|
+
Array.from({ length: m + 1 }, () => false),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
for (let i = 1; i <= n; i++) {
|
|
226
|
+
for (let j = 1; j <= m; j++) {
|
|
227
|
+
const pairing = scores[i - 1][j - 1];
|
|
228
|
+
const withPairing = pairing > 0 ? best[i - 1][j - 1] + pairing : -Infinity;
|
|
229
|
+
if (withPairing >= best[i - 1][j] && withPairing >= best[i][j - 1]) {
|
|
230
|
+
best[i][j] = withPairing;
|
|
231
|
+
paired[i][j] = true;
|
|
232
|
+
} else {
|
|
233
|
+
best[i][j] = Math.max(best[i - 1][j], best[i][j - 1]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const assignment = new Map<number, number>(); // step number -> header index
|
|
239
|
+
for (let i = n, j = m; i > 0 && j > 0;) {
|
|
240
|
+
if (paired[i][j]) {
|
|
241
|
+
assignment.set(steps[i - 1].number, j - 1);
|
|
242
|
+
i--;
|
|
243
|
+
j--;
|
|
244
|
+
} else if (best[i - 1][j] >= best[i][j - 1]) {
|
|
245
|
+
i--;
|
|
246
|
+
} else {
|
|
247
|
+
j--;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return assignment;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Exclusive end index with trailing blank lines dropped, so a span slices to real text. */
|
|
254
|
+
function trimTrailingBlankLines(lines: string[], start: number, end: number): number {
|
|
255
|
+
while (end > start && (lines[end - 1] ?? "").trim() === "") end--;
|
|
256
|
+
return end;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Locate a job's steps in its raw log: each executed step emits a depth-1
|
|
261
|
+
* `##[group]Run <x>` / `##[group]Post Run <x>` header, and its block runs from
|
|
262
|
+
* that header up to the next executed step's header. Everything in between —
|
|
263
|
+
* the action's own `::group::` output, a composite action's internal step
|
|
264
|
+
* headers — belongs to the enclosing step.
|
|
265
|
+
*
|
|
266
|
+
* "Set up job" emits no header: it owns the runner preamble before the first
|
|
267
|
+
* header. Steps with no header of their own (skipped steps, post steps the
|
|
268
|
+
* runner never logged, "Complete job") get no span.
|
|
269
|
+
*/
|
|
270
|
+
export function stepLineSpans(log: string, apiSteps: readonly StepRef[]): Map<number, StepSpan> {
|
|
271
|
+
const lines = log.split("\n");
|
|
272
|
+
const headers = stepHeaders(lines);
|
|
273
|
+
const spans = new Map<number, StepSpan>();
|
|
274
|
+
|
|
275
|
+
// "Set up job" is the runner's own preamble and never takes part in matching:
|
|
276
|
+
// its start window overlaps the first real step's header.
|
|
277
|
+
const preamble = apiSteps.find((s) => s.number === 1 && s.name === "Set up job");
|
|
278
|
+
if (preamble !== undefined) {
|
|
279
|
+
const end = trimTrailingBlankLines(lines, 0, headers[0]?.line ?? lines.length);
|
|
280
|
+
spans.set(preamble.number, { start: 0, end });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A skipped step never started, so it emitted no header — and its name often
|
|
284
|
+
// repeats another step's ("Clear build" twice, "Post Run <action>" next to
|
|
285
|
+
// its "Run <action>"), which would let it steal that step's block.
|
|
286
|
+
const assignment = alignStepsToHeaders(
|
|
287
|
+
apiSteps.filter((s) => s !== preamble && s.conclusion !== "skipped"),
|
|
288
|
+
headers,
|
|
289
|
+
);
|
|
290
|
+
const placed = [...assignment]
|
|
291
|
+
.map(([number, headerIndex]) => ({ number, line: headers[headerIndex].line }))
|
|
292
|
+
.toSorted((a, b) => a.line - b.line);
|
|
293
|
+
|
|
294
|
+
for (const [index, step] of placed.entries()) {
|
|
295
|
+
const end = trimTrailingBlankLines(lines, step.line, placed[index + 1]?.line ?? lines.length);
|
|
296
|
+
spans.set(step.number, { start: step.line, end });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return spans;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Text of `stepNumber` in its raw log, or null when the step never ran. */
|
|
303
|
+
export function extractStepFromLog(
|
|
304
|
+
log: string,
|
|
305
|
+
stepNumber: number,
|
|
306
|
+
apiSteps: readonly { number: number; name: string }[],
|
|
307
|
+
): string | null {
|
|
308
|
+
const span = stepLineSpans(log, apiSteps).get(stepNumber);
|
|
309
|
+
if (span === undefined) return null;
|
|
310
|
+
return log.split("\n").slice(span.start, span.end).join("\n").trimEnd();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One step of a job, indexed into the job's raw log file. */
|
|
314
|
+
export interface CiLogsStepIndex {
|
|
315
|
+
number: number;
|
|
316
|
+
name: string;
|
|
317
|
+
conclusion: string | null;
|
|
318
|
+
/** 1-based inclusive line range of this step's block in `log_file`. */
|
|
319
|
+
start_line?: number;
|
|
320
|
+
end_line?: number;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** A job's steps plus the raw log file holding their output. */
|
|
324
|
+
export interface CiLogsJobIndex {
|
|
325
|
+
name: string;
|
|
326
|
+
id: number;
|
|
327
|
+
status: string;
|
|
328
|
+
conclusion: string | null;
|
|
329
|
+
log_file: string;
|
|
330
|
+
steps: CiLogsStepIndex[];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Index a job's steps into its raw log: every step that produced a log block
|
|
335
|
+
* gets the `[start_line, end_line]` range (1-based, inclusive) of that block in
|
|
336
|
+
* `log_file`; steps that never ran (skipped, or absent from the log) carry no
|
|
337
|
+
* range. The step content itself is not returned — the model reads it out of
|
|
338
|
+
* the file.
|
|
339
|
+
*/
|
|
340
|
+
export function jobLogIndex(job: RunJob, rawLog: string): CiLogsJobIndex {
|
|
341
|
+
const spans = stepLineSpans(rawLog, job.steps);
|
|
342
|
+
return {
|
|
343
|
+
name: job.name,
|
|
344
|
+
id: job.id,
|
|
345
|
+
status: job.status,
|
|
346
|
+
conclusion: job.conclusion,
|
|
347
|
+
log_file: jobLogPath(repoFromRunUrl(job.run_url), String(job.run_id), job.id),
|
|
348
|
+
steps: job.steps.map((s) => {
|
|
349
|
+
const span = spans.get(s.number);
|
|
350
|
+
return {
|
|
351
|
+
number: s.number,
|
|
352
|
+
name: s.name,
|
|
353
|
+
conclusion: s.conclusion,
|
|
354
|
+
...(span && span.end > span.start && { start_line: span.start + 1, end_line: span.end }),
|
|
355
|
+
};
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
interface JobIdParams {
|
|
361
|
+
job_id: number | string;
|
|
362
|
+
repo?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The toolcall handler behind `read-github-ci-logs`. */
|
|
366
|
+
async function ciLogs(gh: GhClient, call: ToolCall<JobIdParams>): Promise<ToolResult> {
|
|
367
|
+
const { params, ctx, signal, onUpdate } = call;
|
|
368
|
+
const { job_id, repo } = params;
|
|
369
|
+
const jobId = toPositiveId(job_id, "job_id");
|
|
370
|
+
|
|
371
|
+
const pendant = subtitlePendant(params, "job_id");
|
|
372
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
373
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
374
|
+
|
|
375
|
+
const failure = (text: string): ToolResult => ({
|
|
376
|
+
content: [{ type: "text", text }],
|
|
377
|
+
details: { input: params, ...(pendant && { pendant }) },
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
let target: RunJob;
|
|
381
|
+
try {
|
|
382
|
+
target = await gh.checks.job(owner, name, jobId, signal);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const status = (error as { status?: number }).status;
|
|
385
|
+
if (status !== 404) throw error;
|
|
386
|
+
return failure(
|
|
387
|
+
`Job ${jobId} not found in ${effectiveRepo} — job IDs come from \`get-github-workflow-jobs\`.`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (target.status === "queued") {
|
|
392
|
+
return failure(
|
|
393
|
+
`Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
onUpdate?.({
|
|
398
|
+
content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
|
|
399
|
+
details: {},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const rawLog = await getJobLog(target, signal, ctx.cwd, params);
|
|
403
|
+
const index = jobLogIndex(target, rawLog);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
|
|
407
|
+
details: { ...index, input: params, ...(pendant && { pendant }) },
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function addReadCiLogsTool(gh: GhClient, pi: ExtensionAPI) {
|
|
412
|
+
pi.registerTool({
|
|
413
|
+
name: "read-github-ci-logs",
|
|
414
|
+
label: "GitHub CI Logs",
|
|
415
|
+
description:
|
|
416
|
+
"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." +
|
|
417
|
+
" Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
|
|
418
|
+
promptSnippet: "Read GitHub CI logs",
|
|
419
|
+
parameters: Type.Object({
|
|
420
|
+
job_id: Type.Union([Type.Number(), Type.String()], {
|
|
421
|
+
description: "Job ID, from get-github-workflow-jobs.",
|
|
422
|
+
}),
|
|
423
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
424
|
+
}),
|
|
425
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
426
|
+
return ciLogs(gh, { params, ctx, signal, onUpdate });
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import { type GhClient, ghExec, repoArgs, subtitlePendant, toToolResult } from "../base.js";
|
|
5
|
+
|
|
6
|
+
export function addReadIssueCommentsTool(_gh: GhClient, pi: ExtensionAPI) {
|
|
7
|
+
pi.registerTool({
|
|
8
|
+
name: "read-github-issue-comments",
|
|
9
|
+
label: "GitHub Issue Comments",
|
|
10
|
+
description: "Get comments on a GitHub issue.",
|
|
11
|
+
promptSnippet: "Read GitHub issue comments",
|
|
12
|
+
parameters: Type.Object({
|
|
13
|
+
number: Type.Union([Type.Number(), Type.String()], { description: "Issue number" }),
|
|
14
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
15
|
+
}),
|
|
16
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
17
|
+
const { number, repo } = params;
|
|
18
|
+
const result = toToolResult(
|
|
19
|
+
await ghExec(["issue", "view", String(number), ...repoArgs(repo), "--json", "comments"], {
|
|
20
|
+
cwd: ctx.cwd,
|
|
21
|
+
signal,
|
|
22
|
+
input: params,
|
|
23
|
+
}),
|
|
24
|
+
params,
|
|
25
|
+
);
|
|
26
|
+
result.details.pendant = subtitlePendant(params, "number");
|
|
27
|
+
return result;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import { type GhClient, ghExec, repoArgs, subtitlePendant, toToolResult } from "../base.js";
|
|
5
|
+
|
|
6
|
+
export function addReadIssueTool(_gh: GhClient, pi: ExtensionAPI) {
|
|
7
|
+
pi.registerTool({
|
|
8
|
+
name: "read-github-issue",
|
|
9
|
+
label: "GitHub Issue",
|
|
10
|
+
description: "Get details of a GitHub issue by number.",
|
|
11
|
+
promptSnippet: "Read a GitHub issue",
|
|
12
|
+
parameters: Type.Object({
|
|
13
|
+
number: Type.Union([Type.Number(), Type.String()], { description: "Issue number" }),
|
|
14
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
|
|
15
|
+
}),
|
|
16
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
17
|
+
const { number, repo } = params;
|
|
18
|
+
const result = toToolResult(
|
|
19
|
+
await ghExec(
|
|
20
|
+
[
|
|
21
|
+
"issue",
|
|
22
|
+
"view",
|
|
23
|
+
String(number),
|
|
24
|
+
...repoArgs(repo),
|
|
25
|
+
"--json",
|
|
26
|
+
"title,state,body,author,createdAt,updatedAt,closedAt,url,labels,assignees,comments,milestone,number",
|
|
27
|
+
],
|
|
28
|
+
{ cwd: ctx.cwd, signal, input: params },
|
|
29
|
+
),
|
|
30
|
+
params,
|
|
31
|
+
);
|
|
32
|
+
result.details.pendant = subtitlePendant(params, "number");
|
|
33
|
+
return result;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ghApiList,
|
|
6
|
+
type GhClient,
|
|
7
|
+
ghExec,
|
|
8
|
+
repoArgs,
|
|
9
|
+
resolveRepo,
|
|
10
|
+
subtitlePendant,
|
|
11
|
+
truncate,
|
|
12
|
+
} from "../base.js";
|
|
13
|
+
|
|
14
|
+
export function addReadPrCommentsTool(_gh: GhClient, pi: ExtensionAPI) {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: "read-github-pr-comments",
|
|
17
|
+
label: "GitHub PR Comments",
|
|
18
|
+
description:
|
|
19
|
+
"Get review comments on a GitHub pull request. Set reviews=true for inline code review comments with diff_hunk.",
|
|
20
|
+
promptSnippet: "Read GitHub PR comments",
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
|
|
23
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
24
|
+
reviews: Type.Optional(
|
|
25
|
+
Type.Boolean({
|
|
26
|
+
description:
|
|
27
|
+
"If true, returns inline code review comments (with diff_hunk, path, line) via API. Default: false (returns issue comments).",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
32
|
+
const { number, repo, reviews } = params;
|
|
33
|
+
let out: string;
|
|
34
|
+
if (reviews) {
|
|
35
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
36
|
+
|
|
37
|
+
const [reviewComments, reviewSummaries] = await Promise.all([
|
|
38
|
+
ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/comments`, {
|
|
39
|
+
cwd: ctx.cwd,
|
|
40
|
+
signal,
|
|
41
|
+
input: params,
|
|
42
|
+
}),
|
|
43
|
+
ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/reviews`, {
|
|
44
|
+
cwd: ctx.cwd,
|
|
45
|
+
signal,
|
|
46
|
+
input: params,
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
out = JSON.stringify(
|
|
51
|
+
{
|
|
52
|
+
reviews: reviewSummaries,
|
|
53
|
+
comments: reviewComments,
|
|
54
|
+
},
|
|
55
|
+
null,
|
|
56
|
+
2,
|
|
57
|
+
);
|
|
58
|
+
} else {
|
|
59
|
+
out = await ghExec(
|
|
60
|
+
["pr", "view", String(number), ...repoArgs(repo), "--json", "comments"],
|
|
61
|
+
{
|
|
62
|
+
cwd: ctx.cwd,
|
|
63
|
+
signal,
|
|
64
|
+
input: params,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const { text, truncated } = truncate(out);
|
|
69
|
+
const pendant = subtitlePendant(params, "number");
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: "text", text }],
|
|
72
|
+
details: { input: params, truncated, ...(pendant && { pendant }) },
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import { type GhClient, ghExec, repoArgs, subtitlePendant, toToolResult } from "../base.js";
|
|
5
|
+
|
|
6
|
+
export function addReadPrDiffTool(_gh: GhClient, pi: ExtensionAPI) {
|
|
7
|
+
pi.registerTool({
|
|
8
|
+
name: "read-github-pr-diff",
|
|
9
|
+
label: "GitHub PR Diff",
|
|
10
|
+
description: "Get the diff of a GitHub pull request.",
|
|
11
|
+
promptSnippet: "Read a GitHub PR diff",
|
|
12
|
+
parameters: Type.Object({
|
|
13
|
+
number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
|
|
14
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
15
|
+
}),
|
|
16
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
17
|
+
const { number, repo } = params;
|
|
18
|
+
const args = ["pr", "diff", String(number), ...repoArgs(repo)];
|
|
19
|
+
const result = toToolResult(
|
|
20
|
+
await ghExec(args, { cwd: ctx.cwd, signal, input: params }),
|
|
21
|
+
params,
|
|
22
|
+
);
|
|
23
|
+
result.details.pendant = subtitlePendant(params, "number");
|
|
24
|
+
return result;
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type GhClient,
|
|
6
|
+
mergeChecks,
|
|
7
|
+
resolveRepo,
|
|
8
|
+
splitRepo,
|
|
9
|
+
subtitlePendant,
|
|
10
|
+
type ToolCall,
|
|
11
|
+
type ToolResult,
|
|
12
|
+
toPositiveId,
|
|
13
|
+
} from "../base.js";
|
|
14
|
+
|
|
15
|
+
interface PrStatusParams {
|
|
16
|
+
number: number | string;
|
|
17
|
+
repo?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `read-github-pr-status`: the PR head commit's checks as a snapshot. Same read
|
|
22
|
+
* path as the wait tools (octokit), but it never polls — pending checks come
|
|
23
|
+
* back as-is.
|
|
24
|
+
*/
|
|
25
|
+
export async function prStatus(gh: GhClient, call: ToolCall<PrStatusParams>): Promise<ToolResult> {
|
|
26
|
+
const { params, ctx, signal } = call;
|
|
27
|
+
const { number, repo } = params;
|
|
28
|
+
const pullNumber = toPositiveId(number, "number");
|
|
29
|
+
const pendant = subtitlePendant(params, "number");
|
|
30
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
31
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
32
|
+
|
|
33
|
+
const pollSignal = signal ?? new AbortController().signal;
|
|
34
|
+
const headSha = await gh.checks.pullHead(owner, name, pullNumber, pollSignal);
|
|
35
|
+
const [statuses, checkRuns] = await Promise.all([
|
|
36
|
+
gh.checks.statuses(owner, name, headSha, pollSignal),
|
|
37
|
+
gh.checks.checkRuns(owner, name, headSha, pollSignal),
|
|
38
|
+
]);
|
|
39
|
+
const checks = mergeChecks(statuses, checkRuns).map((check) => ({
|
|
40
|
+
name: check.name,
|
|
41
|
+
bucket: check.bucket,
|
|
42
|
+
event: check.event,
|
|
43
|
+
run_id: check.runId,
|
|
44
|
+
job_id: check.jobId,
|
|
45
|
+
url: check.link,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const payload = { pr: pullNumber, repo: effectiveRepo, head_sha: headSha, checks };
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
51
|
+
details: { ...payload, input: params, ...(pendant && { pendant }) },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function addReadPrStatusTool(gh: GhClient, pi: ExtensionAPI) {
|
|
56
|
+
pi.registerTool({
|
|
57
|
+
name: "read-github-pr-status",
|
|
58
|
+
label: "GitHub PR Status",
|
|
59
|
+
description:
|
|
60
|
+
"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.",
|
|
61
|
+
promptSnippet: "Read GitHub PR status checks",
|
|
62
|
+
parameters: Type.Object({
|
|
63
|
+
number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
|
|
64
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
|
|
65
|
+
}),
|
|
66
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
67
|
+
return prStatus(gh, { params, ctx, signal, onUpdate });
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|