@trim21/personal-pi-extensions 0.1.547 → 0.1.550
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 +2 -1
- package/src/gh-readonly.ts +299 -184
- package/src/lib/gh-proxy.ts +192 -0
- package/src/lib/github.ts +156 -24
- package/src/skills/github-ci-logs/SKILL.md +73 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.550",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"minimatch": "^10.2.6",
|
|
109
109
|
"octokit": "^5.0.5",
|
|
110
110
|
"turndown": "^7.2.0",
|
|
111
|
+
"undici": "^7.29.0",
|
|
111
112
|
"vscode-jsonrpc": "^9.0.1",
|
|
112
113
|
"vscode-languageserver-types": "^3.18.0",
|
|
113
114
|
"web-tree-sitter": "^0.27.0"
|
package/src/gh-readonly.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - read-github-pr-comments: Get PR comments
|
|
15
15
|
* - read-github-ci-logs: Get CI workflow run logs
|
|
16
16
|
* - read-github-workflow-runs: List workflow runs
|
|
17
|
-
* -
|
|
17
|
+
* - get-github-workflow-jobs: Get workflow run jobs
|
|
18
18
|
* - read-github-repo: Get repo info
|
|
19
19
|
* - list-github-releases: List releases
|
|
20
20
|
* - read-github-release: Get release details
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Or for project-local:
|
|
29
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.
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
37
|
import { spawn } from "node:child_process";
|
|
@@ -39,6 +44,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
39
44
|
import { Type } from "typebox";
|
|
40
45
|
import { Value } from "typebox/value";
|
|
41
46
|
|
|
47
|
+
import { createGhProxy } from "./lib/gh-proxy.js";
|
|
42
48
|
import {
|
|
43
49
|
type ActionJob,
|
|
44
50
|
type CheckRun,
|
|
@@ -48,10 +54,17 @@ import {
|
|
|
48
54
|
type GithubChecksClient,
|
|
49
55
|
type GithubSearch,
|
|
50
56
|
renderHits,
|
|
57
|
+
type RunJob,
|
|
51
58
|
} from "./lib/github.js";
|
|
52
59
|
import { type ToolPendant } from "./lib/pendant.js";
|
|
53
60
|
import { createSeqState } from "./lib/seq-state.js";
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 代理配置(~/.pi/agent/gh.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
|
|
64
|
+
* `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
|
|
65
|
+
*/
|
|
66
|
+
const ghProxy = createGhProxy();
|
|
67
|
+
|
|
55
68
|
interface GhResult {
|
|
56
69
|
stdout: string;
|
|
57
70
|
stderr: string;
|
|
@@ -82,16 +95,25 @@ export function isGhAvailable(): boolean {
|
|
|
82
95
|
return false;
|
|
83
96
|
}
|
|
84
97
|
|
|
85
|
-
export function runGh(
|
|
98
|
+
export async function runGh(
|
|
86
99
|
args: string[],
|
|
87
|
-
ctx: {
|
|
100
|
+
ctx: {
|
|
101
|
+
cwd?: string;
|
|
102
|
+
signal?: AbortSignal;
|
|
103
|
+
timeout?: number;
|
|
104
|
+
/** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
|
|
105
|
+
env?: NodeJS.ProcessEnv;
|
|
106
|
+
},
|
|
88
107
|
): Promise<GhResult> {
|
|
108
|
+
const proxyEnv = await ghProxy.env();
|
|
109
|
+
|
|
89
110
|
return new Promise((resolve) => {
|
|
90
111
|
const proc = spawn("gh", args, {
|
|
91
112
|
cwd: ctx.cwd,
|
|
92
113
|
shell: false,
|
|
93
114
|
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
-
|
|
115
|
+
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
116
|
+
env: { ...process.env, ...proxyEnv, ...ctx.env, GH_PAGER: "cat" },
|
|
95
117
|
});
|
|
96
118
|
|
|
97
119
|
let stdout = "";
|
|
@@ -244,6 +266,20 @@ function repoArgs(repo?: string): string[] {
|
|
|
244
266
|
return repo ? ["--repo", repo] : [];
|
|
245
267
|
}
|
|
246
268
|
|
|
269
|
+
/**
|
|
270
|
+
* `gh api` for a JSON-array endpoint, following pagination. The REST API pages
|
|
271
|
+
* these lists at 30 items by default, so a single page silently drops the rest;
|
|
272
|
+
* `--slurp` is required because `--paginate` alone prints the pages back to back
|
|
273
|
+
* (not valid JSON), and the page arrays are flattened back into one list.
|
|
274
|
+
*/
|
|
275
|
+
async function ghApiList(
|
|
276
|
+
path: string,
|
|
277
|
+
ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
|
|
278
|
+
): Promise<unknown[]> {
|
|
279
|
+
const out = await ghExec(["api", "--paginate", "--slurp", path], ctx);
|
|
280
|
+
return Value.Parse(Type.Array(Type.Array(Type.Unknown())), JSON.parse(out)).flat();
|
|
281
|
+
}
|
|
282
|
+
|
|
247
283
|
/** Split `OWNER/REPO`; throws when the name doesn't have exactly one slash. */
|
|
248
284
|
function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
|
|
249
285
|
const slash = nameWithOwner.indexOf("/");
|
|
@@ -253,30 +289,19 @@ function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
|
|
|
253
289
|
return { owner: nameWithOwner.slice(0, slash), repo: nameWithOwner.slice(slash + 1) };
|
|
254
290
|
}
|
|
255
291
|
|
|
292
|
+
/** Parse a positive integer toolcall parameter (run/job ids are numbers or numeric strings). */
|
|
293
|
+
function toPositiveId(value: number | string, name: string): number {
|
|
294
|
+
const id = typeof value === "number" ? value : Number(value);
|
|
295
|
+
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
296
|
+
throw new Error(`invalid ${name}: ${String(value)} (expected a positive integer)`);
|
|
297
|
+
}
|
|
298
|
+
return id;
|
|
299
|
+
}
|
|
300
|
+
|
|
256
301
|
// ── runtime validation schemas for JSON.parse results ───────────────────────
|
|
257
302
|
|
|
258
303
|
const repoViewSchema = Type.Object({ nameWithOwner: Type.String() });
|
|
259
304
|
|
|
260
|
-
const stepSchema = Type.Object({
|
|
261
|
-
name: Type.String(),
|
|
262
|
-
number: Type.Number(),
|
|
263
|
-
status: Type.String(),
|
|
264
|
-
conclusion: Type.Union([Type.String(), Type.Null()]),
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
const jobRunSchema = Type.Object({
|
|
268
|
-
id: Type.Number(),
|
|
269
|
-
run_id: Type.Number(),
|
|
270
|
-
run_url: Type.String(),
|
|
271
|
-
name: Type.String(),
|
|
272
|
-
status: Type.String(),
|
|
273
|
-
conclusion: Type.Union([Type.String(), Type.Null()]),
|
|
274
|
-
html_url: Type.Optional(Type.String()),
|
|
275
|
-
steps: Type.Array(stepSchema),
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
const jobsResponseSchema = Type.Object({ jobs: Type.Array(jobRunSchema) });
|
|
279
|
-
|
|
280
305
|
const prHeadSchema = Type.Object({ headRefOid: Type.String() });
|
|
281
306
|
|
|
282
307
|
function truncate(
|
|
@@ -396,13 +421,6 @@ async function searchList(
|
|
|
396
421
|
|
|
397
422
|
// ── CI helpers ───────────────────────────────────────────────────────────────
|
|
398
423
|
|
|
399
|
-
export interface StepInfo {
|
|
400
|
-
name: string;
|
|
401
|
-
number: number;
|
|
402
|
-
status: string;
|
|
403
|
-
conclusion: string | null;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
424
|
// 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
|
|
407
425
|
// 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
|
|
408
426
|
const seq = createSeqState();
|
|
@@ -431,7 +449,7 @@ export function jobLogPath(repo: string, runId: string, jobId: number): string {
|
|
|
431
449
|
}
|
|
432
450
|
|
|
433
451
|
async function getJobLog(
|
|
434
|
-
job:
|
|
452
|
+
job: RunJob,
|
|
435
453
|
signal: AbortSignal | undefined,
|
|
436
454
|
cwd: string | undefined,
|
|
437
455
|
input?: unknown,
|
|
@@ -529,115 +547,197 @@ export interface StepSpan {
|
|
|
529
547
|
end: number;
|
|
530
548
|
}
|
|
531
549
|
|
|
550
|
+
/** The part of a step the log index needs. `RunJobStep` satisfies it. */
|
|
551
|
+
export interface StepRef {
|
|
552
|
+
number: number;
|
|
553
|
+
name: string;
|
|
554
|
+
conclusion?: string | null;
|
|
555
|
+
started_at?: string | null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** A `##[group]Run …` / `##[group]Post Run …` line: the header of one executed step. */
|
|
559
|
+
interface StepHeader {
|
|
560
|
+
/** 0-based index of the `##[group]` line. */
|
|
561
|
+
line: number;
|
|
562
|
+
/** Header text with the `Run ` / `Post Run ` prefix stripped. */
|
|
563
|
+
action: string;
|
|
564
|
+
/** Runner timestamp on that line (epoch ms), null when unparsable. */
|
|
565
|
+
timestamp: number | null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Runner timestamp every log line starts with: `2026-08-05T16:36:08.1842645Z `. */
|
|
569
|
+
const LOG_TIMESTAMP_RE = /^\uFEFF?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) /;
|
|
570
|
+
const HEADER_PREFIX_RE = /^(Run |Post Run )/;
|
|
571
|
+
|
|
532
572
|
/**
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
* marker at depth 1. Composite actions emit their internal steps as *additional*
|
|
537
|
-
* depth-1 groups *after* the composite's own `##[endgroup]` (e.g. the internal
|
|
538
|
-
* `Run actions/setup-python@…` groups inside `Run pypa/cibuildwheel@…`), so the
|
|
539
|
-
* log's "Run " groups are NOT one-per-step.
|
|
540
|
-
*
|
|
541
|
-
* To handle that we treat a group as an *anchor* only when its action name
|
|
542
|
-
* (after stripping the "Run "/"Post Run " prefix) matches a top-level API step
|
|
543
|
-
* name. Composite-action internals match no API step and are absorbed into the
|
|
544
|
-
* span of the enclosing step instead of truncating it.
|
|
545
|
-
*
|
|
546
|
-
* Step 1 ("Set up job") maps to everything before the first anchor group.
|
|
547
|
-
* Steps with an anchor map to the span from their anchor to the next anchor.
|
|
548
|
-
* Explicitly named steps that lack a "Run " prefix (e.g. a step named
|
|
549
|
-
* "Setup node" running actions/setup-node) are located between the previous
|
|
550
|
-
* and next anchor's groups.
|
|
551
|
-
* Steps that were skipped and never executed get no span.
|
|
573
|
+
* The runner writes a step's header within ~1.6s of the step's API `started_at`
|
|
574
|
+
* (the API truncates its timestamps to whole seconds), so a header inside this
|
|
575
|
+
* window is evidence of the step it belongs to.
|
|
552
576
|
*/
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
577
|
+
const HEADER_WINDOW_MS = 5_000;
|
|
578
|
+
/** An exact name match proves the header belongs to that step. */
|
|
579
|
+
const NAME_MATCH_SCORE = 4;
|
|
580
|
+
/** Weight of a header inside the step's start window. */
|
|
581
|
+
const TIME_MATCH_SCORE = 2;
|
|
582
|
+
|
|
583
|
+
function headerTimestamp(line: string): number | null {
|
|
584
|
+
const match = LOG_TIMESTAMP_RE.exec(line);
|
|
585
|
+
if (match === null) return null;
|
|
586
|
+
const ms = Date.parse(match[1]);
|
|
587
|
+
return Number.isNaN(ms) ? null : ms;
|
|
588
|
+
}
|
|
559
589
|
|
|
560
|
-
|
|
561
|
-
|
|
590
|
+
/** Collect the depth-1 `Run ` / `Post Run ` headers, in log order. */
|
|
591
|
+
function stepHeaders(lines: string[]): StepHeader[] {
|
|
592
|
+
const headers: StepHeader[] = [];
|
|
562
593
|
let depth = 0;
|
|
563
594
|
for (const [i, line] of lines.entries()) {
|
|
564
595
|
if (line.includes("##[endgroup]")) {
|
|
565
596
|
if (depth > 0) depth--;
|
|
566
597
|
continue;
|
|
567
598
|
}
|
|
568
|
-
if (line.includes("##[group]"))
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
599
|
+
if (!line.includes("##[group]")) continue;
|
|
600
|
+
depth++;
|
|
601
|
+
if (depth !== 1) continue;
|
|
602
|
+
const name = /##\[group\](.*)/.exec(line)?.[1].trim() ?? "";
|
|
603
|
+
if (HEADER_PREFIX_RE.test(name)) {
|
|
604
|
+
headers.push({
|
|
605
|
+
line: i,
|
|
606
|
+
action: name.replace(HEADER_PREFIX_RE, "").trim(),
|
|
607
|
+
timestamp: headerTimestamp(line),
|
|
608
|
+
});
|
|
577
609
|
}
|
|
578
610
|
}
|
|
611
|
+
return headers;
|
|
612
|
+
}
|
|
579
613
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (gi !== -1) {
|
|
593
|
-
used.add(gi);
|
|
594
|
-
stepToGroup.set(rs.number, gi);
|
|
614
|
+
/** How well a step explains a header — 0 means "no evidence, don't guess". */
|
|
615
|
+
function headerScore(step: StepRef, header: StepHeader): number {
|
|
616
|
+
let score = 0;
|
|
617
|
+
const named = step.name.replace(HEADER_PREFIX_RE, "").trim();
|
|
618
|
+
if (HEADER_PREFIX_RE.test(step.name) && named === header.action) {
|
|
619
|
+
score += NAME_MATCH_SCORE;
|
|
620
|
+
}
|
|
621
|
+
const started = step.started_at == null ? NaN : Date.parse(step.started_at);
|
|
622
|
+
if (!Number.isNaN(started) && header.timestamp !== null) {
|
|
623
|
+
const delta = header.timestamp - started;
|
|
624
|
+
if (delta >= 0 && delta <= HEADER_WINDOW_MS) {
|
|
625
|
+
score += TIME_MATCH_SCORE * (1 - delta / HEADER_WINDOW_MS);
|
|
595
626
|
}
|
|
596
627
|
}
|
|
628
|
+
return score;
|
|
629
|
+
}
|
|
597
630
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
631
|
+
/**
|
|
632
|
+
* Align steps with headers: an order-preserving best-scoring matching, where
|
|
633
|
+
* either side may be left unmatched. Only pairs with real evidence are matched,
|
|
634
|
+
* so a step whose block cannot be identified gets no span instead of a guess.
|
|
635
|
+
*
|
|
636
|
+
* Name evidence disappears as soon as the workflow names a step with `name:`
|
|
637
|
+
* (the API name is then the custom one, while the log header carries the action
|
|
638
|
+
* or command), which is why the timestamps matter too. Headers belonging to a
|
|
639
|
+
* composite action's *internal* steps carry no evidence for any API step, so
|
|
640
|
+
* they stay unmatched and are absorbed into the enclosing step's span.
|
|
641
|
+
*/
|
|
642
|
+
function alignStepsToHeaders(
|
|
643
|
+
steps: readonly StepRef[],
|
|
644
|
+
headers: StepHeader[],
|
|
645
|
+
): Map<number, number> {
|
|
646
|
+
const n = steps.length;
|
|
647
|
+
const m = headers.length;
|
|
648
|
+
// Equal-scoring alignments are decided in favour of the earlier step: a step
|
|
649
|
+
// whose output the runner never logged (post steps, "Complete job") comes last
|
|
650
|
+
// in step order, so a header claimed by both belongs to the earlier one. A
|
|
651
|
+
// step that ran always emits its header before the next step starts, which
|
|
652
|
+
// leaves several steps competing for one header whenever the API timestamps
|
|
653
|
+
// (whole seconds) collapse them into the same second.
|
|
654
|
+
const TIE_BREAK = 1e-6;
|
|
655
|
+
const scores = steps.map((step, i) =>
|
|
656
|
+
headers.map((header) => {
|
|
657
|
+
const score = headerScore(step, header);
|
|
658
|
+
return score > 0 ? score + TIE_BREAK * (n - i) : 0;
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
// best[i][j]: score of aligning the first i steps with the first j headers.
|
|
662
|
+
const best: number[][] = Array.from({ length: n + 1 }, () =>
|
|
663
|
+
Array.from({ length: m + 1 }, () => 0),
|
|
664
|
+
);
|
|
665
|
+
const paired: boolean[][] = Array.from({ length: n + 1 }, () =>
|
|
666
|
+
Array.from({ length: m + 1 }, () => false),
|
|
667
|
+
);
|
|
624
668
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
669
|
+
for (let i = 1; i <= n; i++) {
|
|
670
|
+
for (let j = 1; j <= m; j++) {
|
|
671
|
+
const pairing = scores[i - 1][j - 1];
|
|
672
|
+
const withPairing = pairing > 0 ? best[i - 1][j - 1] + pairing : -Infinity;
|
|
673
|
+
if (withPairing >= best[i - 1][j] && withPairing >= best[i][j - 1]) {
|
|
674
|
+
best[i][j] = withPairing;
|
|
675
|
+
paired[i][j] = true;
|
|
631
676
|
} else {
|
|
632
|
-
|
|
633
|
-
start = anchors[anchorIdx].line;
|
|
634
|
-
end = anchorIdx + 1 < anchors.length ? anchors[anchorIdx + 1].line : lines.length;
|
|
677
|
+
best[i][j] = Math.max(best[i - 1][j], best[i][j - 1]);
|
|
635
678
|
}
|
|
636
679
|
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const assignment = new Map<number, number>(); // step number -> header index
|
|
683
|
+
for (let i = n, j = m; i > 0 && j > 0;) {
|
|
684
|
+
if (paired[i][j]) {
|
|
685
|
+
assignment.set(steps[i - 1].number, j - 1);
|
|
686
|
+
i--;
|
|
687
|
+
j--;
|
|
688
|
+
} else if (best[i - 1][j] >= best[i][j - 1]) {
|
|
689
|
+
i--;
|
|
690
|
+
} else {
|
|
691
|
+
j--;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return assignment;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Exclusive end index with trailing blank lines dropped, so a span slices to real text. */
|
|
698
|
+
function trimTrailingBlankLines(lines: string[], start: number, end: number): number {
|
|
699
|
+
while (end > start && (lines[end - 1] ?? "").trim() === "") end--;
|
|
700
|
+
return end;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Locate a job's steps in its raw log: each executed step emits a depth-1
|
|
705
|
+
* `##[group]Run <x>` / `##[group]Post Run <x>` header, and its block runs from
|
|
706
|
+
* that header up to the next executed step's header. Everything in between —
|
|
707
|
+
* the action's own `::group::` output, a composite action's internal step
|
|
708
|
+
* headers — belongs to the enclosing step.
|
|
709
|
+
*
|
|
710
|
+
* "Set up job" emits no header: it owns the runner preamble before the first
|
|
711
|
+
* header. Steps with no header of their own (skipped steps, post steps the
|
|
712
|
+
* runner never logged, "Complete job") get no span.
|
|
713
|
+
*/
|
|
714
|
+
export function stepLineSpans(log: string, apiSteps: readonly StepRef[]): Map<number, StepSpan> {
|
|
715
|
+
const lines = log.split("\n");
|
|
716
|
+
const headers = stepHeaders(lines);
|
|
717
|
+
const spans = new Map<number, StepSpan>();
|
|
718
|
+
|
|
719
|
+
// "Set up job" is the runner's own preamble and never takes part in matching:
|
|
720
|
+
// its start window overlaps the first real step's header.
|
|
721
|
+
const preamble = apiSteps.find((s) => s.number === 1 && s.name === "Set up job");
|
|
722
|
+
if (preamble !== undefined) {
|
|
723
|
+
const end = trimTrailingBlankLines(lines, 0, headers[0]?.line ?? lines.length);
|
|
724
|
+
spans.set(preamble.number, { start: 0, end });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// A skipped step never started, so it emitted no header — and its name often
|
|
728
|
+
// repeats another step's ("Clear build" twice, "Post Run <action>" next to
|
|
729
|
+
// its "Run <action>"), which would let it steal that step's block.
|
|
730
|
+
const assignment = alignStepsToHeaders(
|
|
731
|
+
apiSteps.filter((s) => s !== preamble && s.conclusion !== "skipped"),
|
|
732
|
+
headers,
|
|
733
|
+
);
|
|
734
|
+
const placed = [...assignment]
|
|
735
|
+
.map(([number, headerIndex]) => ({ number, line: headers[headerIndex].line }))
|
|
736
|
+
.toSorted((a, b) => a.line - b.line);
|
|
637
737
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
spans.set(
|
|
738
|
+
for (const [index, step] of placed.entries()) {
|
|
739
|
+
const end = trimTrailingBlankLines(lines, step.line, placed[index + 1]?.line ?? lines.length);
|
|
740
|
+
spans.set(step.number, { start: step.line, end });
|
|
641
741
|
}
|
|
642
742
|
|
|
643
743
|
return spans;
|
|
@@ -647,7 +747,7 @@ export function stepLineSpans(
|
|
|
647
747
|
export function extractStepFromLog(
|
|
648
748
|
log: string,
|
|
649
749
|
stepNumber: number,
|
|
650
|
-
apiSteps: { number: number; name: string }[],
|
|
750
|
+
apiSteps: readonly { number: number; name: string }[],
|
|
651
751
|
): string | null {
|
|
652
752
|
const span = stepLineSpans(log, apiSteps).get(stepNumber);
|
|
653
753
|
if (span === undefined) return null;
|
|
@@ -656,17 +756,6 @@ export function extractStepFromLog(
|
|
|
656
756
|
|
|
657
757
|
// ── ci-logs rendering (pure, testable) ──────────────────────────────────────
|
|
658
758
|
|
|
659
|
-
export interface CiLogsJob {
|
|
660
|
-
id: number;
|
|
661
|
-
run_id: number;
|
|
662
|
-
/** Canonical `api.github.com/repos/<owner>/<repo>/actions/runs/<id>`. */
|
|
663
|
-
run_url: string;
|
|
664
|
-
name: string;
|
|
665
|
-
status: string;
|
|
666
|
-
conclusion: string | null;
|
|
667
|
-
steps: StepInfo[];
|
|
668
|
-
}
|
|
669
|
-
|
|
670
759
|
export interface CiLogsResult {
|
|
671
760
|
content: { type: "text"; text: string }[];
|
|
672
761
|
details: Record<string, unknown>;
|
|
@@ -699,7 +788,7 @@ export interface CiLogsJobIndex {
|
|
|
699
788
|
* range. The step content itself is not returned — the model reads it out of
|
|
700
789
|
* the file.
|
|
701
790
|
*/
|
|
702
|
-
export function jobLogIndex(job:
|
|
791
|
+
export function jobLogIndex(job: RunJob, rawLog: string): CiLogsJobIndex {
|
|
703
792
|
const spans = stepLineSpans(rawLog, job.steps);
|
|
704
793
|
return {
|
|
705
794
|
name: job.name,
|
|
@@ -739,6 +828,10 @@ export interface MergedCheck {
|
|
|
739
828
|
readonly link: string | null;
|
|
740
829
|
/** Triggering workflow event (push, pull_request, ...); null when unknown. */
|
|
741
830
|
readonly event: string | null;
|
|
831
|
+
/** Actions run id behind this check, for `get-github-workflow-jobs`; null when unknown. */
|
|
832
|
+
readonly runId: number | null;
|
|
833
|
+
/** Actions job id behind this check, for `read-github-ci-logs`; null when unknown. */
|
|
834
|
+
readonly jobId: number | null;
|
|
742
835
|
}
|
|
743
836
|
|
|
744
837
|
function statusBucket(state: string): CheckBucket {
|
|
@@ -791,6 +884,8 @@ export function mergeChecks(
|
|
|
791
884
|
startedAt: null,
|
|
792
885
|
link: status.targetUrl,
|
|
793
886
|
event: null,
|
|
887
|
+
runId: null,
|
|
888
|
+
jobId: null,
|
|
794
889
|
})),
|
|
795
890
|
...checkRuns.map((run) => ({
|
|
796
891
|
name: run.name,
|
|
@@ -798,6 +893,8 @@ export function mergeChecks(
|
|
|
798
893
|
startedAt: run.startedAt,
|
|
799
894
|
link: run.url,
|
|
800
895
|
event: run.event,
|
|
896
|
+
runId: run.runId,
|
|
897
|
+
jobId: run.jobId,
|
|
801
898
|
})),
|
|
802
899
|
];
|
|
803
900
|
}
|
|
@@ -1064,8 +1161,20 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1064
1161
|
return;
|
|
1065
1162
|
}
|
|
1066
1163
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1164
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1165
|
+
// 代理配置读不出来(JSON 语法错、字段类型错、proxy 不是 http(s) URL)时只告警,
|
|
1166
|
+
// 工具按直连继续工作——配置写错不该让整套 GitHub 工具不可用。
|
|
1167
|
+
const { error } = await ghProxy.load();
|
|
1168
|
+
if (!error) return;
|
|
1169
|
+
try {
|
|
1170
|
+
ctx.ui.notify(`gh proxy config ignored: ${error}`, "warning");
|
|
1171
|
+
} catch {
|
|
1172
|
+
// 读取期间 session 可能已被替换,失效的 ctx 直接忽略
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
const githubSearch = createGithubSearch({ fetch: ghProxy.fetch });
|
|
1177
|
+
const githubChecks = createGithubChecks({ fetch: ghProxy.fetch });
|
|
1069
1178
|
|
|
1070
1179
|
/**
|
|
1071
1180
|
* Shared wait core of `wait-github-pr-checks` and
|
|
@@ -1303,28 +1412,41 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1303
1412
|
name: "read-github-pr-status",
|
|
1304
1413
|
label: "GitHub PR Status",
|
|
1305
1414
|
description:
|
|
1306
|
-
"Get the current
|
|
1415
|
+
"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.",
|
|
1307
1416
|
promptSnippet: "Read GitHub PR status checks",
|
|
1308
1417
|
parameters: Type.Object({
|
|
1309
1418
|
number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
|
|
1310
|
-
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
1419
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
|
|
1311
1420
|
}),
|
|
1312
1421
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1313
1422
|
const { number, repo } = params;
|
|
1314
|
-
const
|
|
1423
|
+
const pullNumber = toPositiveId(number, "number");
|
|
1424
|
+
const pendant = subtitlePendant(params, "number");
|
|
1425
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1426
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1315
1427
|
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
|
|
1319
|
-
const
|
|
1428
|
+
// 与 wait 工具同一条读取路径(octokit),只是这里不轮询:pending 的 check
|
|
1429
|
+
// 原样返回,等结果走 wait-github-pr-checks。
|
|
1430
|
+
const pollSignal = signal ?? new AbortController().signal;
|
|
1431
|
+
const headSha = await githubChecks.pullHead(owner, name, pullNumber, pollSignal);
|
|
1432
|
+
const [statuses, checkRuns] = await Promise.all([
|
|
1433
|
+
githubChecks.statuses(owner, name, headSha, pollSignal),
|
|
1434
|
+
githubChecks.checkRuns(owner, name, headSha, pollSignal),
|
|
1435
|
+
]);
|
|
1436
|
+
const checks = mergeChecks(statuses, checkRuns).map((check) => ({
|
|
1437
|
+
name: check.name,
|
|
1438
|
+
bucket: check.bucket,
|
|
1439
|
+
event: check.event,
|
|
1440
|
+
run_id: check.runId,
|
|
1441
|
+
job_id: check.jobId,
|
|
1442
|
+
url: check.link,
|
|
1443
|
+
}));
|
|
1320
1444
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
toolResult.details.pendant = subtitlePendant(params, "number");
|
|
1327
|
-
return toolResult;
|
|
1445
|
+
const payload = { pr: pullNumber, repo: effectiveRepo, head_sha: headSha, checks };
|
|
1446
|
+
return {
|
|
1447
|
+
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
|
|
1448
|
+
details: { ...payload, input: params, ...(pendant && { pendant }) },
|
|
1449
|
+
};
|
|
1328
1450
|
},
|
|
1329
1451
|
});
|
|
1330
1452
|
|
|
@@ -1351,22 +1473,19 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1351
1473
|
if (reviews) {
|
|
1352
1474
|
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1353
1475
|
|
|
1354
|
-
const [
|
|
1355
|
-
|
|
1476
|
+
const [reviewComments, reviewSummaries] = await Promise.all([
|
|
1477
|
+
ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/comments`, {
|
|
1356
1478
|
cwd: ctx.cwd,
|
|
1357
1479
|
signal,
|
|
1358
1480
|
input: params,
|
|
1359
1481
|
}),
|
|
1360
|
-
|
|
1482
|
+
ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/reviews`, {
|
|
1361
1483
|
cwd: ctx.cwd,
|
|
1362
1484
|
signal,
|
|
1363
1485
|
input: params,
|
|
1364
1486
|
}),
|
|
1365
1487
|
]);
|
|
1366
1488
|
|
|
1367
|
-
const reviewComments = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(comments));
|
|
1368
|
-
const reviewSummaries = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(reviewsOut));
|
|
1369
|
-
|
|
1370
1489
|
out = JSON.stringify(
|
|
1371
1490
|
{
|
|
1372
1491
|
reviews: reviewSummaries,
|
|
@@ -1453,39 +1572,39 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1453
1572
|
name: "read-github-ci-logs",
|
|
1454
1573
|
label: "GitHub CI Logs",
|
|
1455
1574
|
description:
|
|
1456
|
-
"Download one GitHub Actions job's CI log 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
|
|
1575
|
+
"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." +
|
|
1457
1576
|
" Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
|
|
1458
1577
|
promptSnippet: "Read GitHub CI logs",
|
|
1459
1578
|
parameters: Type.Object({
|
|
1460
|
-
|
|
1579
|
+
job_id: Type.Union([Type.Number(), Type.String()], {
|
|
1580
|
+
description: "Job ID, from get-github-workflow-jobs.",
|
|
1581
|
+
}),
|
|
1461
1582
|
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
1462
|
-
job: Type.String({ description: "Job name or job ID, from read-github-workflow-jobs." }),
|
|
1463
1583
|
}),
|
|
1464
1584
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1465
|
-
const {
|
|
1466
|
-
const
|
|
1585
|
+
const { job_id, repo } = params;
|
|
1586
|
+
const jobId = toPositiveId(job_id, "job_id");
|
|
1467
1587
|
|
|
1468
|
-
const pendant = subtitlePendant(params, "
|
|
1588
|
+
const pendant = subtitlePendant(params, "job_id");
|
|
1469
1589
|
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1470
|
-
const
|
|
1471
|
-
cwd: ctx.cwd,
|
|
1472
|
-
signal,
|
|
1473
|
-
input: params,
|
|
1474
|
-
});
|
|
1475
|
-
const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
|
|
1590
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1476
1591
|
|
|
1477
1592
|
const failure = (text: string): CiLogsResult => ({
|
|
1478
1593
|
content: [{ type: "text", text }],
|
|
1479
1594
|
details: { input: params, ...(pendant && { pendant }) },
|
|
1480
1595
|
});
|
|
1481
1596
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1597
|
+
let target: RunJob;
|
|
1598
|
+
try {
|
|
1599
|
+
target = await githubChecks.job(owner, name, jobId, signal);
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
const status = (error as { status?: number }).status;
|
|
1602
|
+
if (status !== 404) throw error;
|
|
1485
1603
|
return failure(
|
|
1486
|
-
`Job
|
|
1604
|
+
`Job ${jobId} not found in ${effectiveRepo} — job IDs come from \`get-github-workflow-jobs\`.`,
|
|
1487
1605
|
);
|
|
1488
1606
|
}
|
|
1607
|
+
|
|
1489
1608
|
if (target.status === "queued") {
|
|
1490
1609
|
return failure(
|
|
1491
1610
|
`Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
|
|
@@ -1507,28 +1626,24 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1507
1626
|
},
|
|
1508
1627
|
});
|
|
1509
1628
|
|
|
1510
|
-
// ──
|
|
1629
|
+
// ── get-github-workflow-jobs ──────────────────────────────────────────────
|
|
1511
1630
|
pi.registerTool({
|
|
1512
|
-
name: "
|
|
1631
|
+
name: "get-github-workflow-jobs",
|
|
1513
1632
|
label: "GitHub Workflow Jobs",
|
|
1514
1633
|
description:
|
|
1515
|
-
"Get
|
|
1516
|
-
promptSnippet: "
|
|
1634
|
+
"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.",
|
|
1635
|
+
promptSnippet: "Get GitHub workflow run jobs",
|
|
1517
1636
|
parameters: Type.Object({
|
|
1518
1637
|
run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
|
|
1519
1638
|
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
1520
1639
|
}),
|
|
1521
1640
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1522
1641
|
const { run_id, repo } = params;
|
|
1642
|
+
const runId = toPositiveId(run_id, "run_id");
|
|
1523
1643
|
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1524
|
-
const
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
signal,
|
|
1528
|
-
input: params,
|
|
1529
|
-
}),
|
|
1530
|
-
params,
|
|
1531
|
-
);
|
|
1644
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1645
|
+
const jobs = await githubChecks.runJobs(owner, name, runId, signal);
|
|
1646
|
+
const result = toToolResult(JSON.stringify({ total_count: jobs.length, jobs }), params);
|
|
1532
1647
|
result.details.pendant = subtitlePendant(params, "run_id");
|
|
1533
1648
|
return result;
|
|
1534
1649
|
},
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gh-readonly 的代理配置与请求层。
|
|
3
|
+
*
|
|
4
|
+
* 配置来源(配置文件优先,未写的字段回退到环境变量):
|
|
5
|
+
* - ~/.pi/agent/gh.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost,.corp" }
|
|
6
|
+
* - HTTPS_PROXY / HTTP_PROXY / ALL_PROXY(小写变体同样接受)、NO_PROXY
|
|
7
|
+
*
|
|
8
|
+
* 两条出口共用同一份配置:
|
|
9
|
+
* - gh CLI 子进程:env() 给出要注入子进程的 HTTP(S)_PROXY / NO_PROXY 等变量
|
|
10
|
+
* - octokit 请求:fetch 是挂了代理 dispatcher 的 fetch;未配置代理时就是全局 fetch
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { EnvHttpProxyAgent } from "undici";
|
|
19
|
+
|
|
20
|
+
import { parseWithSchema } from "./parse-with-schema.js";
|
|
21
|
+
|
|
22
|
+
const ghConfigSchema = Type.Object({
|
|
23
|
+
proxy: Type.Optional(Type.String()),
|
|
24
|
+
noProxy: Type.Optional(Type.String()),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface GhProxySettings {
|
|
28
|
+
/** 代理 URL(http/https);undefined 表示不使用代理。 */
|
|
29
|
+
proxy?: string;
|
|
30
|
+
/** 不走代理的 host 列表(逗号分隔),语义同 NO_PROXY。 */
|
|
31
|
+
noProxy?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ghProxyConfigPath(): string {
|
|
35
|
+
return join(homedir(), ".pi", "agent", "gh.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 解析 gh.json 的内容;字段类型不符时抛出带字段路径的错误。 */
|
|
39
|
+
export function parseGhProxyConfig(value: unknown): GhProxySettings {
|
|
40
|
+
const parsed = parseWithSchema(ghConfigSchema, value);
|
|
41
|
+
const proxy = parsed.proxy?.trim();
|
|
42
|
+
const noProxy = parsed.noProxy?.trim();
|
|
43
|
+
return { ...(proxy && { proxy }), ...(noProxy && { noProxy }) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const PROXY_ENV_NAMES = [
|
|
47
|
+
"HTTPS_PROXY",
|
|
48
|
+
"https_proxy",
|
|
49
|
+
"HTTP_PROXY",
|
|
50
|
+
"http_proxy",
|
|
51
|
+
"ALL_PROXY",
|
|
52
|
+
"all_proxy",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const NO_PROXY_ENV_NAMES = ["NO_PROXY", "no_proxy"];
|
|
56
|
+
|
|
57
|
+
function firstEnv(names: readonly string[], env: NodeJS.ProcessEnv): string | undefined {
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const value = env[name]?.trim();
|
|
60
|
+
if (value) return value;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 代理必须是 http(s) URL:undici 的 ProxyAgent 只支持 HTTP CONNECT 代理。 */
|
|
66
|
+
function normalizeProxy(value: string | undefined): { proxy?: string; error?: string } {
|
|
67
|
+
if (!value) return {};
|
|
68
|
+
let protocol: string;
|
|
69
|
+
try {
|
|
70
|
+
protocol = new URL(value).protocol;
|
|
71
|
+
} catch {
|
|
72
|
+
return { error: `invalid proxy URL: ${value}` };
|
|
73
|
+
}
|
|
74
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
75
|
+
return { error: `unsupported proxy protocol: ${value} (expected http:// or https://)` };
|
|
76
|
+
}
|
|
77
|
+
return { proxy: value };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GhProxyLoad {
|
|
81
|
+
/** 生效的代理设置(配置文件与环境变量合并后的结果)。 */
|
|
82
|
+
settings: GhProxySettings;
|
|
83
|
+
/** 配置读取/解析失败的原因;未失败时为 undefined。 */
|
|
84
|
+
error?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function describeError(error: unknown): string {
|
|
88
|
+
return error instanceof Error ? error.message : String(error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 读配置文件;文件不存在视为未配置,其它失败只记录不抛出。 */
|
|
92
|
+
async function readConfigFile(configPath: string): Promise<GhProxyLoad> {
|
|
93
|
+
let raw: string;
|
|
94
|
+
try {
|
|
95
|
+
raw = await readFile(configPath, "utf8");
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { settings: {} };
|
|
98
|
+
return { settings: {}, error: `${configPath}: ${describeError(error)}` };
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return { settings: parseGhProxyConfig(JSON.parse(raw)) };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return { settings: {}, error: `${configPath}: ${describeError(error)}` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolveSettings(configPath: string, env: NodeJS.ProcessEnv): Promise<GhProxyLoad> {
|
|
108
|
+
const file = await readConfigFile(configPath);
|
|
109
|
+
const normalized = normalizeProxy(file.settings.proxy ?? firstEnv(PROXY_ENV_NAMES, env));
|
|
110
|
+
const noProxy = file.settings.noProxy ?? firstEnv(NO_PROXY_ENV_NAMES, env);
|
|
111
|
+
return {
|
|
112
|
+
settings: { ...(normalized.proxy && { proxy: normalized.proxy }), ...(noProxy && { noProxy }) },
|
|
113
|
+
error: file.error ?? normalized.error,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 要注入 gh 子进程的代理环境变量;未配置代理时为空对象(子进程继承父进程环境)。 */
|
|
118
|
+
export function proxyEnvVars(settings: GhProxySettings): NodeJS.ProcessEnv {
|
|
119
|
+
const { proxy, noProxy } = settings;
|
|
120
|
+
if (!proxy) return {};
|
|
121
|
+
return {
|
|
122
|
+
// gh 是 Go 程序,https 目标只认 HTTPS_PROXY;统一填全部变量,避免用户只设了
|
|
123
|
+
// HTTP_PROXY 时 https 请求直连。小写变体给 curl 系的子进程用。
|
|
124
|
+
HTTP_PROXY: proxy,
|
|
125
|
+
HTTPS_PROXY: proxy,
|
|
126
|
+
ALL_PROXY: proxy,
|
|
127
|
+
http_proxy: proxy,
|
|
128
|
+
https_proxy: proxy,
|
|
129
|
+
all_proxy: proxy,
|
|
130
|
+
...(noProxy && { NO_PROXY: noProxy, no_proxy: noProxy }),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 挂着代理 dispatcher 的 fetch,供 octokit 的 `request.fetch` 使用。
|
|
136
|
+
*
|
|
137
|
+
* octokit v5 丢弃了 node-fetch 时代的 `agent` 选项(@octokit/request 的选项里没有
|
|
138
|
+
* 这个字段,也没有任何地方把它转交给 fetch),只接受自定义 fetch —— octokit
|
|
139
|
+
* README 的 Proxy Servers 一节就是挂 undici 的代理 dispatcher。NO_PROXY 匹配由
|
|
140
|
+
* EnvHttpProxyAgent 负责;未配置代理时原样返回全局 fetch,默认路径行为不变。
|
|
141
|
+
*/
|
|
142
|
+
function createFetch(settings: GhProxySettings): typeof globalThis.fetch {
|
|
143
|
+
const { proxy, noProxy } = settings;
|
|
144
|
+
if (!proxy) return globalThis.fetch;
|
|
145
|
+
// undici 包与 Node 全局 fetch 各带一份 Dispatcher 类型声明(@types/node 走
|
|
146
|
+
// undici-types),结构一致但 compose 重载对不上,这里只做类型层面的转换。
|
|
147
|
+
const dispatcher = new EnvHttpProxyAgent({
|
|
148
|
+
httpProxy: proxy,
|
|
149
|
+
httpsProxy: proxy,
|
|
150
|
+
...(noProxy && { noProxy }),
|
|
151
|
+
}) as unknown as NonNullable<RequestInit["dispatcher"]>;
|
|
152
|
+
return (input, init) => globalThis.fetch(input, { ...init, dispatcher });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface GhProxy {
|
|
156
|
+
/** 读取配置(首个调用触发读盘,之后返回同一个缓存结果,失败不抛出)。 */
|
|
157
|
+
load(): Promise<GhProxyLoad>;
|
|
158
|
+
/** 要注入 gh 子进程的代理环境变量;未配置代理时为空对象。 */
|
|
159
|
+
env(): Promise<NodeJS.ProcessEnv>;
|
|
160
|
+
/** 走代理的 fetch;未配置代理时就是全局 fetch。 */
|
|
161
|
+
readonly fetch: typeof globalThis.fetch;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 创建代理配置读取器。配置只在首次使用时读一次并缓存;`load` 与 `fetch` 共用这次
|
|
166
|
+
* 读取,因此运行期不会出现两者看到不同配置的情况。
|
|
167
|
+
*/
|
|
168
|
+
export function createGhProxy(
|
|
169
|
+
configPath: string = ghProxyConfigPath(),
|
|
170
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
171
|
+
): GhProxy {
|
|
172
|
+
let loading: Promise<GhProxyLoad> | undefined;
|
|
173
|
+
let proxied: typeof globalThis.fetch | undefined;
|
|
174
|
+
|
|
175
|
+
function load(): Promise<GhProxyLoad> {
|
|
176
|
+
loading ??= resolveSettings(configPath, env);
|
|
177
|
+
return loading;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
load,
|
|
182
|
+
env: async () => {
|
|
183
|
+
const { settings } = await load();
|
|
184
|
+
return proxyEnvVars(settings);
|
|
185
|
+
},
|
|
186
|
+
fetch: async (input, init) => {
|
|
187
|
+
const { settings } = await load();
|
|
188
|
+
proxied ??= createFetch(settings);
|
|
189
|
+
return proxied(input, init);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
package/src/lib/github.ts
CHANGED
|
@@ -248,17 +248,29 @@ export interface GithubApi {
|
|
|
248
248
|
call<T>(fn: (octokit: Octokit) => Promise<T>): Promise<T>;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
export interface GithubClientOptions {
|
|
252
|
+
/**
|
|
253
|
+
* Custom fetch for octokit's `request.fetch` hook — octokit v5 drops the old
|
|
254
|
+
* `agent` option, so a proxy has to arrive as a fetch implementation with a
|
|
255
|
+
* proxy dispatcher attached. Defaults to the global fetch.
|
|
256
|
+
*/
|
|
257
|
+
fetch?: typeof globalThis.fetch;
|
|
258
|
+
}
|
|
259
|
+
|
|
251
260
|
/**
|
|
252
261
|
* Create a shared octokit accessor. The client (and its auth token) is cached
|
|
253
262
|
* in the returned closure, so repeated calls reuse the same client without
|
|
254
263
|
* module-level state. A stale cached token can produce 401s; the cache is
|
|
255
264
|
* dropped and the request retried once in that case.
|
|
256
265
|
*/
|
|
257
|
-
export function createGithubApi(): GithubApi {
|
|
266
|
+
export function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
258
267
|
let client: Octokit | undefined;
|
|
259
268
|
|
|
260
269
|
async function getClient(): Promise<Octokit> {
|
|
261
|
-
client ??= new Octokit({
|
|
270
|
+
client ??= new Octokit({
|
|
271
|
+
auth: await ghAuthToken(),
|
|
272
|
+
...(options.fetch && { request: { fetch: options.fetch } }),
|
|
273
|
+
});
|
|
262
274
|
return client;
|
|
263
275
|
}
|
|
264
276
|
|
|
@@ -287,8 +299,8 @@ export interface GithubSearch {
|
|
|
287
299
|
/**
|
|
288
300
|
* Create a search client backed by a cached octokit instance.
|
|
289
301
|
*/
|
|
290
|
-
export function createGithubSearch(): GithubSearch {
|
|
291
|
-
const api = createGithubApi();
|
|
302
|
+
export function createGithubSearch(options: GithubClientOptions = {}): GithubSearch {
|
|
303
|
+
const api = createGithubApi(options);
|
|
292
304
|
|
|
293
305
|
return {
|
|
294
306
|
async search(kind, params) {
|
|
@@ -334,6 +346,10 @@ export interface CheckRun {
|
|
|
334
346
|
readonly url: string | null;
|
|
335
347
|
/** Triggering workflow event (push, pull_request, ...); null when unknown. */
|
|
336
348
|
readonly event: string | null;
|
|
349
|
+
/** Actions workflow run id parsed from `details_url`; null for non-Actions checks. */
|
|
350
|
+
readonly runId: number | null;
|
|
351
|
+
/** Actions job id parsed from `details_url`; null when the check is run- not job-level. */
|
|
352
|
+
readonly jobId: number | null;
|
|
337
353
|
}
|
|
338
354
|
|
|
339
355
|
/** One Actions job flattened with its workflow run metadata. */
|
|
@@ -347,6 +363,32 @@ export interface ActionJob {
|
|
|
347
363
|
readonly jobUrl?: string;
|
|
348
364
|
}
|
|
349
365
|
|
|
366
|
+
/** One step of a workflow run job, as the REST API reports it. */
|
|
367
|
+
export interface RunJobStep {
|
|
368
|
+
readonly name: string;
|
|
369
|
+
readonly number: number;
|
|
370
|
+
readonly status: string;
|
|
371
|
+
readonly conclusion: string | null;
|
|
372
|
+
/** ISO-8601 UTC, second precision; null/absent for steps that never started. */
|
|
373
|
+
readonly started_at?: string | null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* One job of a workflow run. Field names mirror the REST API because the CI log
|
|
378
|
+
* index consumes them as they arrive: `run_url`/`run_id` locate the job's raw
|
|
379
|
+
* log file, `steps` give the step list to align against that log.
|
|
380
|
+
*/
|
|
381
|
+
export interface RunJob {
|
|
382
|
+
readonly id: number;
|
|
383
|
+
readonly run_id: number;
|
|
384
|
+
readonly run_url: string;
|
|
385
|
+
readonly name: string;
|
|
386
|
+
readonly status: string;
|
|
387
|
+
readonly conclusion: string | null;
|
|
388
|
+
readonly html_url: string | null;
|
|
389
|
+
readonly steps: readonly RunJobStep[];
|
|
390
|
+
}
|
|
391
|
+
|
|
350
392
|
export interface GithubChecksClient {
|
|
351
393
|
statuses(
|
|
352
394
|
owner: string,
|
|
@@ -367,6 +409,21 @@ export interface GithubChecksClient {
|
|
|
367
409
|
headSha: string,
|
|
368
410
|
signal: AbortSignal,
|
|
369
411
|
): Promise<readonly ActionJob[]>;
|
|
412
|
+
/**
|
|
413
|
+
* Every job of one workflow run, steps included. The endpoint pages at 30
|
|
414
|
+
* items by default, which used to hide the jobs past the first page from the
|
|
415
|
+
* CI-log tools; `paginate` follows the Link header so all pages arrive.
|
|
416
|
+
*/
|
|
417
|
+
runJobs(
|
|
418
|
+
owner: string,
|
|
419
|
+
repo: string,
|
|
420
|
+
runId: number,
|
|
421
|
+
signal: AbortSignal | undefined,
|
|
422
|
+
): Promise<readonly RunJob[]>;
|
|
423
|
+
/** One job by id, steps included (`run_url`/`run_id` identify its run). */
|
|
424
|
+
job(owner: string, repo: string, jobId: number, signal: AbortSignal | undefined): Promise<RunJob>;
|
|
425
|
+
/** Head commit SHA of a PR — the commit whose checks are reported. */
|
|
426
|
+
pullHead(owner: string, repo: string, pullNumber: number, signal: AbortSignal): Promise<string>;
|
|
370
427
|
/** Resolve a SHA, branch name, or tag name to the commit's full SHA. */
|
|
371
428
|
headSha(owner: string, repo: string, ref: string, signal: AbortSignal): Promise<string>;
|
|
372
429
|
}
|
|
@@ -378,13 +435,46 @@ export interface GithubChecksClient {
|
|
|
378
435
|
* GitHub Apps) — so external CI is visible to the caller.
|
|
379
436
|
*/
|
|
380
437
|
const ACTIONS_RUN_URL_RE = /\/actions\/runs\/(\d+)/;
|
|
381
|
-
|
|
382
|
-
|
|
438
|
+
/** Job-level check runs point at `.../actions/runs/<run>/job/<job>`. */
|
|
439
|
+
const ACTIONS_JOB_URL_RE = /\/actions\/runs\/\d+\/job\/(\d+)/;
|
|
440
|
+
|
|
441
|
+
/** One REST job object (list and single-job endpoints share the schema). */
|
|
442
|
+
type ApiJob = Awaited<ReturnType<Octokit["rest"]["actions"]["getJobForWorkflowRun"]>>["data"];
|
|
443
|
+
|
|
444
|
+
function toRunJob(job: ApiJob): RunJob {
|
|
445
|
+
return {
|
|
446
|
+
id: job.id,
|
|
447
|
+
run_id: job.run_id,
|
|
448
|
+
run_url: job.run_url,
|
|
449
|
+
name: job.name,
|
|
450
|
+
status: job.status,
|
|
451
|
+
conclusion: job.conclusion,
|
|
452
|
+
html_url: job.html_url ?? null,
|
|
453
|
+
steps: (job.steps ?? []).map((step) => ({
|
|
454
|
+
name: step.name,
|
|
455
|
+
number: step.number,
|
|
456
|
+
status: step.status,
|
|
457
|
+
conclusion: step.conclusion,
|
|
458
|
+
started_at: step.started_at ?? null,
|
|
459
|
+
})),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function createGithubChecks(options: GithubClientOptions = {}): GithubChecksClient {
|
|
464
|
+
const api = createGithubApi(options);
|
|
383
465
|
|
|
384
466
|
return {
|
|
385
467
|
async statuses(owner, repo, ref, signal) {
|
|
386
468
|
const { data } = await api.call((octokit) =>
|
|
387
|
-
octokit.rest.repos.getCombinedStatusForRef({
|
|
469
|
+
octokit.rest.repos.getCombinedStatusForRef({
|
|
470
|
+
owner,
|
|
471
|
+
repo,
|
|
472
|
+
ref,
|
|
473
|
+
// The statuses array is paginated and defaults to 30 per page; a
|
|
474
|
+
// commit carrying more than 100 statuses is not a real scenario.
|
|
475
|
+
per_page: 100,
|
|
476
|
+
request: { signal },
|
|
477
|
+
}),
|
|
388
478
|
);
|
|
389
479
|
return data.statuses.map((status) => ({
|
|
390
480
|
context: status.context,
|
|
@@ -414,8 +504,8 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
414
504
|
);
|
|
415
505
|
const events = new Map<string, string>();
|
|
416
506
|
if (runIds.size > 0) {
|
|
417
|
-
const
|
|
418
|
-
octokit.rest.actions.listWorkflowRunsForRepo
|
|
507
|
+
const commitRuns = await api.call((octokit) =>
|
|
508
|
+
octokit.paginate(octokit.rest.actions.listWorkflowRunsForRepo, {
|
|
419
509
|
owner,
|
|
420
510
|
repo,
|
|
421
511
|
head_sha: ref,
|
|
@@ -423,24 +513,34 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
423
513
|
request: { signal },
|
|
424
514
|
}),
|
|
425
515
|
);
|
|
426
|
-
for (const run of
|
|
516
|
+
for (const run of commitRuns) {
|
|
427
517
|
const id = String(run.id);
|
|
428
518
|
if (runIds.has(id)) events.set(id, run.event);
|
|
429
519
|
}
|
|
430
520
|
}
|
|
431
|
-
return runs.map((run) =>
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
521
|
+
return runs.map((run) => {
|
|
522
|
+
const detailsUrl = run.details_url ?? "";
|
|
523
|
+
const runId = ACTIONS_RUN_URL_RE.exec(detailsUrl)?.[1];
|
|
524
|
+
const jobId = ACTIONS_JOB_URL_RE.exec(detailsUrl)?.[1];
|
|
525
|
+
return {
|
|
526
|
+
name: run.name,
|
|
527
|
+
status: run.status,
|
|
528
|
+
conclusion: run.conclusion,
|
|
529
|
+
startedAt: run.started_at,
|
|
530
|
+
url: run.html_url ?? run.details_url ?? null,
|
|
531
|
+
event: events.get(runId ?? "") ?? null,
|
|
532
|
+
runId: runId === undefined ? null : Number(runId),
|
|
533
|
+
jobId: jobId === undefined ? null : Number(jobId),
|
|
534
|
+
};
|
|
535
|
+
});
|
|
439
536
|
},
|
|
440
537
|
|
|
441
538
|
async actionJobs(owner, repo, headSha, signal) {
|
|
442
|
-
|
|
443
|
-
|
|
539
|
+
// Both levels are paginated: a commit can carry several workflow runs
|
|
540
|
+
// (push and pull_request), and a single run can have more jobs than one
|
|
541
|
+
// page — a missing page would silently drop failed jobs from the report.
|
|
542
|
+
const runs = await api.call((octokit) =>
|
|
543
|
+
octokit.paginate(octokit.rest.actions.listWorkflowRunsForRepo, {
|
|
444
544
|
owner,
|
|
445
545
|
repo,
|
|
446
546
|
head_sha: headSha,
|
|
@@ -449,9 +549,9 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
449
549
|
}),
|
|
450
550
|
);
|
|
451
551
|
const jobs: ActionJob[] = [];
|
|
452
|
-
for (const run of
|
|
453
|
-
const
|
|
454
|
-
octokit.rest.actions.listJobsForWorkflowRun
|
|
552
|
+
for (const run of runs) {
|
|
553
|
+
const runJobs = await api.call((octokit) =>
|
|
554
|
+
octokit.paginate(octokit.rest.actions.listJobsForWorkflowRun, {
|
|
455
555
|
owner,
|
|
456
556
|
repo,
|
|
457
557
|
run_id: run.id,
|
|
@@ -459,7 +559,7 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
459
559
|
request: { signal },
|
|
460
560
|
}),
|
|
461
561
|
);
|
|
462
|
-
for (const job of
|
|
562
|
+
for (const job of runJobs) {
|
|
463
563
|
jobs.push({
|
|
464
564
|
runId: run.id,
|
|
465
565
|
runName: run.name ?? "",
|
|
@@ -474,6 +574,38 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
474
574
|
return jobs;
|
|
475
575
|
},
|
|
476
576
|
|
|
577
|
+
async runJobs(owner, repo, runId, signal) {
|
|
578
|
+
const jobs = await api.call((octokit) =>
|
|
579
|
+
octokit.paginate(octokit.rest.actions.listJobsForWorkflowRun, {
|
|
580
|
+
owner,
|
|
581
|
+
repo,
|
|
582
|
+
run_id: runId,
|
|
583
|
+
per_page: 100,
|
|
584
|
+
request: { signal },
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
return jobs.map((job) => toRunJob(job));
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
async job(owner, repo, jobId, signal) {
|
|
591
|
+
const { data } = await api.call((octokit) =>
|
|
592
|
+
octokit.rest.actions.getJobForWorkflowRun({
|
|
593
|
+
owner,
|
|
594
|
+
repo,
|
|
595
|
+
job_id: jobId,
|
|
596
|
+
request: { signal },
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
return toRunJob(data);
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
async pullHead(owner, repo, pullNumber, signal) {
|
|
603
|
+
const { data } = await api.call((octokit) =>
|
|
604
|
+
octokit.rest.pulls.get({ owner, repo, pull_number: pullNumber, request: { signal } }),
|
|
605
|
+
);
|
|
606
|
+
return data.head.sha;
|
|
607
|
+
},
|
|
608
|
+
|
|
477
609
|
async headSha(owner, repo, ref, signal) {
|
|
478
610
|
const { data } = await api.call((octokit) =>
|
|
479
611
|
octokit.rest.repos.getCommit({ owner, repo, ref, request: { signal } }),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: github-ci-logs
|
|
3
|
+
description: Use when 排查某个仓库的 CI 失败、要拿到具体 job 的日志 —— 从 repo + PR number / commit sha / branch name / tag 一路定位到 job 日志:read-github-pr-status 或 wait-github-commit-checks 找出失败的 check 及其 run_id/job_id,get-github-workflow-jobs 列某个 run 的全部 job,read-github-ci-logs 按 job_id 把原始日志落盘并按 step 行号读取。全程走工具,不需要手工跑 gh CLI。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 从 PR / commit 定位到 CI 日志
|
|
7
|
+
|
|
8
|
+
## 核心原则
|
|
9
|
+
|
|
10
|
+
- **id 是唯一可靠的坐标**:日志工具只收 `job_id`,不接受 job 名称。名称 → id 必须先经过 check 列表或 job 列表工具。
|
|
11
|
+
- **不手工调 gh**:check / job / PR 数据都由工具封装好了 ,自己拼 `gh api` 只会重复劳动、还可能踩分页坑(jobs 端点默认一页 30 条)。
|
|
12
|
+
- **一次调用一个 job**:多个失败 job 就多次调用,逐个取。
|
|
13
|
+
- **日志内容不进上下文**:`read-github-ci-logs` 只返回索引(落盘路径 + 每个 step 的行号区间),内容自己用 Read 读区间、Grep 搜错误。
|
|
14
|
+
|
|
15
|
+
## 入口选择
|
|
16
|
+
|
|
17
|
+
| 手里有什么 | 第一步 | 拿到什么 |
|
|
18
|
+
| ----------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
19
|
+
| PR number | `read-github-pr-status { number, repo? }` | 该 PR head commit 的 checks 快照:`bucket` / `event` / `run_id` / `job_id` / `url` |
|
|
20
|
+
| commit sha / branch / tag | `wait-github-commit-checks { commit, repo?, event? }` | 已失败时立即返回;报告失败 job 的行带 `#<jobId>` |
|
|
21
|
+
| 只有 repo(不知道是哪个 run) | `list-github-workflow-runs { repo?, workflow?, status?, limit? }` | run 列表(含 run id) |
|
|
22
|
+
| 只有 run_id | `get-github-workflow-jobs { run_id, repo? }` | 该 run 的全部 job:`id` / `name` / `status` / `conclusion` / `html_url` / `steps` |
|
|
23
|
+
|
|
24
|
+
`repo` 都可省略,缺省用当前目录解析。
|
|
25
|
+
|
|
26
|
+
## 主流程
|
|
27
|
+
|
|
28
|
+
1. 用上表挑一个入口,拿到 checks 或 jobs 列表。
|
|
29
|
+
2. 找失败项:`bucket == "fail"`(PR 快照)或报告里 `(failure)` / `(timed_out)` / `(cancelled)` 的行。
|
|
30
|
+
3. 有 `job_id` 就直接进第 4 步。只有 `run_id`、或者想看看同一个 run 里还有哪些 job,就先 `get-github-workflow-jobs { run_id }`,按 `name` / `status` 挑出目标 job 的 `id`。
|
|
31
|
+
4. `read-github-ci-logs { job_id, repo? }` → JSON 索引:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"name": "lint",
|
|
36
|
+
"id": 92374541920,
|
|
37
|
+
"status": "completed",
|
|
38
|
+
"conclusion": "failure",
|
|
39
|
+
"log_file": "/home/me/.cache/pi/github/ci-logs/a/b/42/92374541920.log",
|
|
40
|
+
"steps": [
|
|
41
|
+
{
|
|
42
|
+
"number": 6,
|
|
43
|
+
"name": "Run npx prettier --check ./",
|
|
44
|
+
"conclusion": "failure",
|
|
45
|
+
"start_line": 120,
|
|
46
|
+
"end_line": 148
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
5. 读日志:优先读失败 step 的 `[start_line, end_line]`;要检索关键词(`##[error]`、`error:`、报错文件名)就在 `log_file` 上 Grep。`steps` 里没有行号的 step 是跳过或没产生日志的。
|
|
53
|
+
6. 还有别的失败 job → 回到第 4 步(用各自 job id)。
|
|
54
|
+
|
|
55
|
+
## 输出与边界
|
|
56
|
+
|
|
57
|
+
- `read-github-pr-status`:**立即返回快照,不轮询**。字段:`checks[].{name,bucket,event,run_id,job_id,url}`,非 Actions 的 check(Azure DevOps 等)`run_id`/`job_id` 为 null —— 这类没有 GitHub 侧 job 日志可读。
|
|
58
|
+
- `wait-github-commit-checks`:用于「没有 PR、只有 sha/branch/tag」的入口。它等的是 checks 全部结束,但**已经失败的 check 会让它第一轮就返回**;失败报告里的 Actions job 行带 `#jobId`。要更快返回可传 `fail_fast: true`;只想看某个事件的 check 用 `event`(如 `push`)。
|
|
59
|
+
- `get-github-workflow-jobs`:SDK 分页,一个 run 有 72 个 job 也会全部返回(旧版只返回前 30 个,第 31 个之后既列不出也选不中)。
|
|
60
|
+
- `read-github-ci-logs`:job 还在 `queued` 时不出日志,提示用 `watch-github-run` 等它开始;日志原样落盘到 `~/.cache/pi/github/ci-logs/<owner>/<repo>/<run_id>/<job_id>.log`(保留 runner 时间戳与 ANSI),同一 job 重复调用命中缓存,不重复下载。
|
|
61
|
+
- 工具**不做**日志截断、清洗或回显;拿到的是文件路径 + 行号,正文自己读。
|
|
62
|
+
|
|
63
|
+
## 与相邻工具的分工
|
|
64
|
+
|
|
65
|
+
- `read-github-pr-status`(快照即返)vs `wait-github-pr-checks`(阻塞轮询到检查结束):前者用来定位,后者用来等结果。
|
|
66
|
+
- `watch-github-run`:已知 run_id、要等这个 run 跑完时用。
|
|
67
|
+
- `list-github-workflow-runs`:run 列表走 `gh run list`,适合「某个 workflow 最近一次跑得怎么样」这类不需要 PR 上下文的场景。
|
|
68
|
+
|
|
69
|
+
## 反例
|
|
70
|
+
|
|
71
|
+
- 用 job 名称去调 `read-github-ci-logs` —— 它只认 id,名称先经 `get-github-workflow-jobs` 转成 id。
|
|
72
|
+
- 为了拿日志去 Bash 跑 `gh api /repos/.../actions/runs/<id>/jobs` —— 直接用 `get-github-workflow-jobs`,分页与 id 提取已经处理好。
|
|
73
|
+
- 把整份日志读进上下文 —— 先用索引里的行号区间,或对 `log_file` 做 Grep。
|