@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.547",
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"
@@ -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
- * - read-github-workflow-jobs: Get workflow run jobs
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: { cwd?: string; signal?: AbortSignal; timeout?: number },
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
- env: { ...process.env, GH_PAGER: "cat" },
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: CiLogsJob,
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
- * Locate a job's steps in its raw log by matching step names to "Run " groups.
534
- *
535
- * Each top-level step emits a `##[group]Run <name>` / `##[group]Post Run <name>`
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
- export function stepLineSpans(
554
- log: string,
555
- apiSteps: { number: number; name: string }[],
556
- ): Map<number, StepSpan> {
557
- const lines = log.split("\n");
558
- const spans = new Map<number, StepSpan>();
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
- // Collect depth-1 "Run "/"Post Run " groups in log order.
561
- const groups: { line: number; action: string }[] = [];
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
- depth++;
570
- if (depth === 1) {
571
- const m = /##\[group\](.*)/.exec(line);
572
- const name = m ? m[1].trim() : "";
573
- if (name.startsWith("Run ") || name.startsWith("Post Run ")) {
574
- groups.push({ line: i, action: name.replace(/^(Run |Post Run )/, "").trim() });
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
- // API steps that produce a "Run "/"Post Run " log group, in step order.
581
- const runSteps = apiSteps
582
- .filter((s) => /^(Run |Post Run )/.test(s.name))
583
- .map((s) => ({ number: s.number, action: s.name.replace(/^(Run |Post Run )/, "").trim() }))
584
- .toSorted((a, b) => a.number - b.number);
585
-
586
- // Greedily assign each run step the first unclaimed group whose action name
587
- // matches (log order). Leftover groups are composite-action internals.
588
- const used = new Set<number>();
589
- const stepToGroup = new Map<number, number>(); // api step number -> group index
590
- for (const rs of runSteps) {
591
- const gi = groups.findIndex((g, idx) => !used.has(idx) && g.action === rs.action);
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
- // Anchor sequence in log order.
599
- const anchors = [...stepToGroup]
600
- .map(([stepNum, gi]) => ({ stepNum, line: groups[gi].line }))
601
- .toSorted((a, b) => a.line - b.line);
602
-
603
- for (const s of apiSteps) {
604
- let start: number;
605
- let end: number;
606
-
607
- if (s.number === 1) {
608
- // Step 1 ("Set up job"): everything before the first "Run " group.
609
- start = 0;
610
- end = groups[0]?.line ?? lines.length;
611
- } else {
612
- const anchorIdx = anchors.findIndex((a) => a.stepNum === s.number);
613
- if (anchorIdx === -1) {
614
- // Non-anchor step (explicitly named, e.g. "Setup node"): its group sits
615
- // in the gap between the previous and next anchors' groups. Take the
616
- // first unclaimed group in that span.
617
- const prevAnchor = anchors.reduce<{ stepNum: number; line: number } | undefined>(
618
- (acc, a) => (a.stepNum < s.number ? a : acc),
619
- undefined,
620
- );
621
- const nextAnchor = anchors.find((a) => a.stepNum > s.number);
622
- const gapStart = prevAnchor ? prevAnchor.line + 1 : 0;
623
- const gapEnd = nextAnchor ? nextAnchor.line : lines.length;
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
- const gi = groups.findIndex(
626
- (g, idx) => !used.has(idx) && g.line >= gapStart && g.line < gapEnd,
627
- );
628
- if (gi === -1) continue; // step never ran → no block in the log
629
- start = groups[gi].line;
630
- end = gapEnd;
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
- // Direct anchor hit: span from this anchor to the next one.
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
- // Drop trailing blank lines so the span slices to the step's own text.
639
- while (end > start && lines[end - 1].trim() === "") end--;
640
- spans.set(s.number, { start, end });
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: CiLogsJob, rawLog: string): CiLogsJobIndex {
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
- const githubSearch = createGithubSearch();
1068
- const githubChecks = createGithubChecks();
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 status checks and CI results for a GitHub pull request. Returns the current snapshot immediately; pending checks are reported as-is, not waited on. Use wait-github-pr-checks to block until checks finish.",
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 args = ["pr", "checks", String(number), ...repoArgs(repo)];
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
- // `gh pr checks` exit codes: 0 = all passed, 1 = some failed, 8 = some
1317
- // pending. All three are valid states — return the current snapshot
1318
- // as-is without waiting. `wait-github-pr-checks` is the blocking variant.
1319
- const result = await runGh(args, { cwd: ctx.cwd, signal });
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
- if (result.code !== 0 && result.code !== 1 && result.code !== 8) {
1322
- // Anything else is a real error (cancelled, auth, network, ...)
1323
- throw new GhError(args, result, params);
1324
- }
1325
- const toolResult = toToolResult(result.stdout, params);
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 [comments, reviewsOut] = await Promise.all([
1355
- ghExec(["api", `/repos/${effectiveRepo}/pulls/${String(number)}/comments`], {
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
- ghExec(["api", `/repos/${effectiveRepo}/pulls/${String(number)}/reviews`], {
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 `job` names/ids from read-github-workflow-jobs." +
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
- run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
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 { run_id, repo, job } = params;
1466
- const runId = String(run_id);
1585
+ const { job_id, repo } = params;
1586
+ const jobId = toPositiveId(job_id, "job_id");
1467
1587
 
1468
- const pendant = subtitlePendant(params, "run_id");
1588
+ const pendant = subtitlePendant(params, "job_id");
1469
1589
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1470
- const jobsOut = await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
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
- const isNumeric = /^\d+$/.test(job);
1483
- const target = jobs.find((j) => (isNumeric ? String(j.id) : j.name) === job);
1484
- if (target === undefined) {
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 "${job}" not found in run ${runId}. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
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
- // ── read-github-workflow-jobs ──────────────────────────────────────────────
1629
+ // ── get-github-workflow-jobs ──────────────────────────────────────────────
1511
1630
  pi.registerTool({
1512
- name: "read-github-workflow-jobs",
1631
+ name: "get-github-workflow-jobs",
1513
1632
  label: "GitHub Workflow Jobs",
1514
1633
  description:
1515
- "Get structured job data (name, status, conclusion, job ID) for a workflow run. Useful before reading CI logs to identify which job to inspect.",
1516
- promptSnippet: "Read GitHub workflow run jobs",
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 result = toToolResult(
1525
- await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
1526
- cwd: ctx.cwd,
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({ auth: await ghAuthToken() });
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
- export function createGithubChecks(): GithubChecksClient {
382
- const api = createGithubApi();
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({ owner, repo, ref, request: { signal } }),
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 { data } = await api.call((octokit) =>
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 data.workflow_runs) {
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
- name: run.name,
433
- status: run.status,
434
- conclusion: run.conclusion,
435
- startedAt: run.started_at,
436
- url: run.html_url ?? run.details_url ?? null,
437
- event: events.get(ACTIONS_RUN_URL_RE.exec(run.details_url ?? "")?.[1] ?? "") ?? null,
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
- const { data } = await api.call((octokit) =>
443
- octokit.rest.actions.listWorkflowRunsForRepo({
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 data.workflow_runs) {
453
- const { data: jobsData } = await api.call((octokit) =>
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 jobsData.jobs) {
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。