@trim21/personal-pi-extensions 0.1.548 → 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.548",
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,31 +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
- started_at: Type.Optional(Type.Union([Type.String(), Type.Null()])),
266
- });
267
-
268
- const jobRunSchema = Type.Object({
269
- id: Type.Number(),
270
- run_id: Type.Number(),
271
- run_url: Type.String(),
272
- name: Type.String(),
273
- status: Type.String(),
274
- conclusion: Type.Union([Type.String(), Type.Null()]),
275
- html_url: Type.Optional(Type.String()),
276
- steps: Type.Array(stepSchema),
277
- });
278
-
279
- const jobsResponseSchema = Type.Object({ jobs: Type.Array(jobRunSchema) });
280
-
281
305
  const prHeadSchema = Type.Object({ headRefOid: Type.String() });
282
306
 
283
307
  function truncate(
@@ -397,15 +421,6 @@ async function searchList(
397
421
 
398
422
  // ── CI helpers ───────────────────────────────────────────────────────────────
399
423
 
400
- export interface StepInfo {
401
- name: string;
402
- number: number;
403
- status: string;
404
- conclusion: string | null;
405
- /** ISO-8601 UTC, second precision. Null for steps that never started. */
406
- started_at?: string | null;
407
- }
408
-
409
424
  // 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
410
425
  // 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
411
426
  const seq = createSeqState();
@@ -434,7 +449,7 @@ export function jobLogPath(repo: string, runId: string, jobId: number): string {
434
449
  }
435
450
 
436
451
  async function getJobLog(
437
- job: CiLogsJob,
452
+ job: RunJob,
438
453
  signal: AbortSignal | undefined,
439
454
  cwd: string | undefined,
440
455
  input?: unknown,
@@ -532,7 +547,7 @@ export interface StepSpan {
532
547
  end: number;
533
548
  }
534
549
 
535
- /** The part of a step the log index needs. `StepInfo` satisfies it. */
550
+ /** The part of a step the log index needs. `RunJobStep` satisfies it. */
536
551
  export interface StepRef {
537
552
  number: number;
538
553
  name: string;
@@ -624,7 +639,10 @@ function headerScore(step: StepRef, header: StepHeader): number {
624
639
  * composite action's *internal* steps carry no evidence for any API step, so
625
640
  * they stay unmatched and are absorbed into the enclosing step's span.
626
641
  */
627
- function alignStepsToHeaders(steps: StepRef[], headers: StepHeader[]): Map<number, number> {
642
+ function alignStepsToHeaders(
643
+ steps: readonly StepRef[],
644
+ headers: StepHeader[],
645
+ ): Map<number, number> {
628
646
  const n = steps.length;
629
647
  const m = headers.length;
630
648
  // Equal-scoring alignments are decided in favour of the earlier step: a step
@@ -693,7 +711,7 @@ function trimTrailingBlankLines(lines: string[], start: number, end: number): nu
693
711
  * header. Steps with no header of their own (skipped steps, post steps the
694
712
  * runner never logged, "Complete job") get no span.
695
713
  */
696
- export function stepLineSpans(log: string, apiSteps: StepRef[]): Map<number, StepSpan> {
714
+ export function stepLineSpans(log: string, apiSteps: readonly StepRef[]): Map<number, StepSpan> {
697
715
  const lines = log.split("\n");
698
716
  const headers = stepHeaders(lines);
699
717
  const spans = new Map<number, StepSpan>();
@@ -729,7 +747,7 @@ export function stepLineSpans(log: string, apiSteps: StepRef[]): Map<number, Ste
729
747
  export function extractStepFromLog(
730
748
  log: string,
731
749
  stepNumber: number,
732
- apiSteps: { number: number; name: string }[],
750
+ apiSteps: readonly { number: number; name: string }[],
733
751
  ): string | null {
734
752
  const span = stepLineSpans(log, apiSteps).get(stepNumber);
735
753
  if (span === undefined) return null;
@@ -738,17 +756,6 @@ export function extractStepFromLog(
738
756
 
739
757
  // ── ci-logs rendering (pure, testable) ──────────────────────────────────────
740
758
 
741
- export interface CiLogsJob {
742
- id: number;
743
- run_id: number;
744
- /** Canonical `api.github.com/repos/<owner>/<repo>/actions/runs/<id>`. */
745
- run_url: string;
746
- name: string;
747
- status: string;
748
- conclusion: string | null;
749
- steps: StepInfo[];
750
- }
751
-
752
759
  export interface CiLogsResult {
753
760
  content: { type: "text"; text: string }[];
754
761
  details: Record<string, unknown>;
@@ -781,7 +788,7 @@ export interface CiLogsJobIndex {
781
788
  * range. The step content itself is not returned — the model reads it out of
782
789
  * the file.
783
790
  */
784
- export function jobLogIndex(job: CiLogsJob, rawLog: string): CiLogsJobIndex {
791
+ export function jobLogIndex(job: RunJob, rawLog: string): CiLogsJobIndex {
785
792
  const spans = stepLineSpans(rawLog, job.steps);
786
793
  return {
787
794
  name: job.name,
@@ -821,6 +828,10 @@ export interface MergedCheck {
821
828
  readonly link: string | null;
822
829
  /** Triggering workflow event (push, pull_request, ...); null when unknown. */
823
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;
824
835
  }
825
836
 
826
837
  function statusBucket(state: string): CheckBucket {
@@ -873,6 +884,8 @@ export function mergeChecks(
873
884
  startedAt: null,
874
885
  link: status.targetUrl,
875
886
  event: null,
887
+ runId: null,
888
+ jobId: null,
876
889
  })),
877
890
  ...checkRuns.map((run) => ({
878
891
  name: run.name,
@@ -880,6 +893,8 @@ export function mergeChecks(
880
893
  startedAt: run.startedAt,
881
894
  link: run.url,
882
895
  event: run.event,
896
+ runId: run.runId,
897
+ jobId: run.jobId,
883
898
  })),
884
899
  ];
885
900
  }
@@ -1146,8 +1161,20 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1146
1161
  return;
1147
1162
  }
1148
1163
 
1149
- const githubSearch = createGithubSearch();
1150
- 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 });
1151
1178
 
1152
1179
  /**
1153
1180
  * Shared wait core of `wait-github-pr-checks` and
@@ -1385,28 +1412,41 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1385
1412
  name: "read-github-pr-status",
1386
1413
  label: "GitHub PR Status",
1387
1414
  description:
1388
- "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.",
1389
1416
  promptSnippet: "Read GitHub PR status checks",
1390
1417
  parameters: Type.Object({
1391
1418
  number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
1392
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1419
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1393
1420
  }),
1394
1421
  async execute(_id, params, signal, _onUpdate, ctx) {
1395
1422
  const { number, repo } = params;
1396
- 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);
1397
1427
 
1398
- // `gh pr checks` exit codes: 0 = all passed, 1 = some failed, 8 = some
1399
- // pending. All three are valid states — return the current snapshot
1400
- // as-is without waiting. `wait-github-pr-checks` is the blocking variant.
1401
- 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
+ }));
1402
1444
 
1403
- if (result.code !== 0 && result.code !== 1 && result.code !== 8) {
1404
- // Anything else is a real error (cancelled, auth, network, ...)
1405
- throw new GhError(args, result, params);
1406
- }
1407
- const toolResult = toToolResult(result.stdout, params);
1408
- toolResult.details.pendant = subtitlePendant(params, "number");
1409
- 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
+ };
1410
1450
  },
1411
1451
  });
1412
1452
 
@@ -1433,22 +1473,19 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1433
1473
  if (reviews) {
1434
1474
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1435
1475
 
1436
- const [comments, reviewsOut] = await Promise.all([
1437
- ghExec(["api", `/repos/${effectiveRepo}/pulls/${String(number)}/comments`], {
1476
+ const [reviewComments, reviewSummaries] = await Promise.all([
1477
+ ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/comments`, {
1438
1478
  cwd: ctx.cwd,
1439
1479
  signal,
1440
1480
  input: params,
1441
1481
  }),
1442
- ghExec(["api", `/repos/${effectiveRepo}/pulls/${String(number)}/reviews`], {
1482
+ ghApiList(`/repos/${effectiveRepo}/pulls/${String(number)}/reviews`, {
1443
1483
  cwd: ctx.cwd,
1444
1484
  signal,
1445
1485
  input: params,
1446
1486
  }),
1447
1487
  ]);
1448
1488
 
1449
- const reviewComments = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(comments));
1450
- const reviewSummaries = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(reviewsOut));
1451
-
1452
1489
  out = JSON.stringify(
1453
1490
  {
1454
1491
  reviews: reviewSummaries,
@@ -1535,39 +1572,39 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1535
1572
  name: "read-github-ci-logs",
1536
1573
  label: "GitHub CI Logs",
1537
1574
  description:
1538
- "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." +
1539
1576
  " Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
1540
1577
  promptSnippet: "Read GitHub CI logs",
1541
1578
  parameters: Type.Object({
1542
- 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
+ }),
1543
1582
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1544
- job: Type.String({ description: "Job name or job ID, from read-github-workflow-jobs." }),
1545
1583
  }),
1546
1584
  async execute(_id, params, signal, onUpdate, ctx) {
1547
- const { run_id, repo, job } = params;
1548
- const runId = String(run_id);
1585
+ const { job_id, repo } = params;
1586
+ const jobId = toPositiveId(job_id, "job_id");
1549
1587
 
1550
- const pendant = subtitlePendant(params, "run_id");
1588
+ const pendant = subtitlePendant(params, "job_id");
1551
1589
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1552
- const jobsOut = await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
1553
- cwd: ctx.cwd,
1554
- signal,
1555
- input: params,
1556
- });
1557
- const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
1590
+ const { owner, repo: name } = splitRepo(effectiveRepo);
1558
1591
 
1559
1592
  const failure = (text: string): CiLogsResult => ({
1560
1593
  content: [{ type: "text", text }],
1561
1594
  details: { input: params, ...(pendant && { pendant }) },
1562
1595
  });
1563
1596
 
1564
- const isNumeric = /^\d+$/.test(job);
1565
- const target = jobs.find((j) => (isNumeric ? String(j.id) : j.name) === job);
1566
- 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;
1567
1603
  return failure(
1568
- `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\`.`,
1569
1605
  );
1570
1606
  }
1607
+
1571
1608
  if (target.status === "queued") {
1572
1609
  return failure(
1573
1610
  `Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
@@ -1589,28 +1626,24 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1589
1626
  },
1590
1627
  });
1591
1628
 
1592
- // ── read-github-workflow-jobs ──────────────────────────────────────────────
1629
+ // ── get-github-workflow-jobs ──────────────────────────────────────────────
1593
1630
  pi.registerTool({
1594
- name: "read-github-workflow-jobs",
1631
+ name: "get-github-workflow-jobs",
1595
1632
  label: "GitHub Workflow Jobs",
1596
1633
  description:
1597
- "Get structured job data (name, status, conclusion, job ID) for a workflow run. Useful before reading CI logs to identify which job to inspect.",
1598
- 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",
1599
1636
  parameters: Type.Object({
1600
1637
  run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
1601
1638
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1602
1639
  }),
1603
1640
  async execute(_id, params, signal, _onUpdate, ctx) {
1604
1641
  const { run_id, repo } = params;
1642
+ const runId = toPositiveId(run_id, "run_id");
1605
1643
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1606
- const result = toToolResult(
1607
- await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
1608
- cwd: ctx.cwd,
1609
- signal,
1610
- input: params,
1611
- }),
1612
- params,
1613
- );
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);
1614
1647
  result.details.pendant = subtitlePendant(params, "run_id");
1615
1648
  return result;
1616
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。