@trim21/personal-pi-extensions 0.1.548 → 0.1.551
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/gh-readonly.ts +134 -101
- package/src/lib/gh-proxy.ts +196 -0
- package/src/lib/github.ts +156 -24
- package/src/skills/github-ci-logs/SKILL.md +73 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.551",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"minimatch": "^10.2.6",
|
|
109
109
|
"octokit": "^5.0.5",
|
|
110
110
|
"turndown": "^7.2.0",
|
|
111
|
+
"undici": "^7.29.0",
|
|
111
112
|
"vscode-jsonrpc": "^9.0.1",
|
|
112
113
|
"vscode-languageserver-types": "^3.18.0",
|
|
113
114
|
"web-tree-sitter": "^0.27.0"
|
package/src/gh-readonly.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - read-github-pr-comments: Get PR comments
|
|
15
15
|
* - read-github-ci-logs: Get CI workflow run logs
|
|
16
16
|
* - read-github-workflow-runs: List workflow runs
|
|
17
|
-
* -
|
|
17
|
+
* - get-github-workflow-jobs: Get workflow run jobs
|
|
18
18
|
* - read-github-repo: Get repo info
|
|
19
19
|
* - list-github-releases: List releases
|
|
20
20
|
* - read-github-release: Get release details
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Or for project-local:
|
|
29
29
|
* cp gh-readonly.ts .pi/extensions/
|
|
30
|
+
*
|
|
31
|
+
* Proxy (for the gh CLI and for the octokit-backed search/checks requests):
|
|
32
|
+
* ~/.pi/agent/gh.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost" }
|
|
33
|
+
* HTTPS_PROXY / HTTP_PROXY / ALL_PROXY and NO_PROXY are used instead for the
|
|
34
|
+
* fields the config file leaves out. The config is read once per process.
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
37
|
import { spawn } from "node:child_process";
|
|
@@ -39,6 +44,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
39
44
|
import { Type } from "typebox";
|
|
40
45
|
import { Value } from "typebox/value";
|
|
41
46
|
|
|
47
|
+
import { createGhProxy } from "./lib/gh-proxy.js";
|
|
42
48
|
import {
|
|
43
49
|
type ActionJob,
|
|
44
50
|
type CheckRun,
|
|
@@ -48,10 +54,17 @@ import {
|
|
|
48
54
|
type GithubChecksClient,
|
|
49
55
|
type GithubSearch,
|
|
50
56
|
renderHits,
|
|
57
|
+
type RunJob,
|
|
51
58
|
} from "./lib/github.js";
|
|
52
59
|
import { type ToolPendant } from "./lib/pendant.js";
|
|
53
60
|
import { createSeqState } from "./lib/seq-state.js";
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 代理配置(~/.pi/agent/gh.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
|
|
64
|
+
* `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
|
|
65
|
+
*/
|
|
66
|
+
const ghProxy = createGhProxy();
|
|
67
|
+
|
|
55
68
|
interface GhResult {
|
|
56
69
|
stdout: string;
|
|
57
70
|
stderr: string;
|
|
@@ -82,16 +95,25 @@ export function isGhAvailable(): boolean {
|
|
|
82
95
|
return false;
|
|
83
96
|
}
|
|
84
97
|
|
|
85
|
-
export function runGh(
|
|
98
|
+
export async function runGh(
|
|
86
99
|
args: string[],
|
|
87
|
-
ctx: {
|
|
100
|
+
ctx: {
|
|
101
|
+
cwd?: string;
|
|
102
|
+
signal?: AbortSignal;
|
|
103
|
+
timeout?: number;
|
|
104
|
+
/** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
|
|
105
|
+
env?: NodeJS.ProcessEnv;
|
|
106
|
+
},
|
|
88
107
|
): Promise<GhResult> {
|
|
108
|
+
const proxyEnv = await ghProxy.env();
|
|
109
|
+
|
|
89
110
|
return new Promise((resolve) => {
|
|
90
111
|
const proc = spawn("gh", args, {
|
|
91
112
|
cwd: ctx.cwd,
|
|
92
113
|
shell: false,
|
|
93
114
|
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
-
|
|
115
|
+
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
116
|
+
env: { ...process.env, ...proxyEnv, ...ctx.env, GH_PAGER: "cat" },
|
|
95
117
|
});
|
|
96
118
|
|
|
97
119
|
let stdout = "";
|
|
@@ -244,6 +266,20 @@ function repoArgs(repo?: string): string[] {
|
|
|
244
266
|
return repo ? ["--repo", repo] : [];
|
|
245
267
|
}
|
|
246
268
|
|
|
269
|
+
/**
|
|
270
|
+
* `gh api` for a JSON-array endpoint, following pagination. The REST API pages
|
|
271
|
+
* these lists at 30 items by default, so a single page silently drops the rest;
|
|
272
|
+
* `--slurp` is required because `--paginate` alone prints the pages back to back
|
|
273
|
+
* (not valid JSON), and the page arrays are flattened back into one list.
|
|
274
|
+
*/
|
|
275
|
+
async function ghApiList(
|
|
276
|
+
path: string,
|
|
277
|
+
ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
|
|
278
|
+
): Promise<unknown[]> {
|
|
279
|
+
const out = await ghExec(["api", "--paginate", "--slurp", path], ctx);
|
|
280
|
+
return Value.Parse(Type.Array(Type.Array(Type.Unknown())), JSON.parse(out)).flat();
|
|
281
|
+
}
|
|
282
|
+
|
|
247
283
|
/** Split `OWNER/REPO`; throws when the name doesn't have exactly one slash. */
|
|
248
284
|
function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
|
|
249
285
|
const slash = nameWithOwner.indexOf("/");
|
|
@@ -253,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:
|
|
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. `
|
|
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(
|
|
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:
|
|
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
|
-
|
|
1150
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
|
|
1401
|
-
const
|
|
1428
|
+
// 与 wait 工具同一条读取路径(octokit),只是这里不轮询:pending 的 check
|
|
1429
|
+
// 原样返回,等结果走 wait-github-pr-checks。
|
|
1430
|
+
const pollSignal = signal ?? new AbortController().signal;
|
|
1431
|
+
const headSha = await githubChecks.pullHead(owner, name, pullNumber, pollSignal);
|
|
1432
|
+
const [statuses, checkRuns] = await Promise.all([
|
|
1433
|
+
githubChecks.statuses(owner, name, headSha, pollSignal),
|
|
1434
|
+
githubChecks.checkRuns(owner, name, headSha, pollSignal),
|
|
1435
|
+
]);
|
|
1436
|
+
const checks = mergeChecks(statuses, checkRuns).map((check) => ({
|
|
1437
|
+
name: check.name,
|
|
1438
|
+
bucket: check.bucket,
|
|
1439
|
+
event: check.event,
|
|
1440
|
+
run_id: check.runId,
|
|
1441
|
+
job_id: check.jobId,
|
|
1442
|
+
url: check.link,
|
|
1443
|
+
}));
|
|
1402
1444
|
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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 [
|
|
1437
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
1548
|
-
const
|
|
1585
|
+
const { job_id, repo } = params;
|
|
1586
|
+
const jobId = toPositiveId(job_id, "job_id");
|
|
1549
1587
|
|
|
1550
|
-
const pendant = subtitlePendant(params, "
|
|
1588
|
+
const pendant = subtitlePendant(params, "job_id");
|
|
1551
1589
|
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1552
|
-
const
|
|
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
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
|
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
|
-
// ──
|
|
1629
|
+
// ── get-github-workflow-jobs ──────────────────────────────────────────────
|
|
1593
1630
|
pi.registerTool({
|
|
1594
|
-
name: "
|
|
1631
|
+
name: "get-github-workflow-jobs",
|
|
1595
1632
|
label: "GitHub Workflow Jobs",
|
|
1596
1633
|
description:
|
|
1597
|
-
"Get
|
|
1598
|
-
promptSnippet: "
|
|
1634
|
+
"Get every job of a workflow run as JSON {total_count, jobs:[{id, run_id, run_url, name, status, conclusion, html_url, steps:[{name, number, status, conclusion, started_at}]}]}. Paginated server-side, so runs with more than 30 jobs return all of them. Use the `id` with read-github-ci-logs after read-github-pr-status / wait-github-commit-checks did not already give you a job id.",
|
|
1635
|
+
promptSnippet: "Get GitHub workflow run jobs",
|
|
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
|
|
1607
|
-
|
|
1608
|
-
|
|
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,196 @@
|
|
|
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:NO_PROXY 匹配、CONNECT 隧道都交给 undici 的
|
|
136
|
+
* EnvHttpProxyAgent,octokit 侧只认自定义 fetch(v5 丢掉了 node-fetch 时代的
|
|
137
|
+
* `agent` 选项,@octokit/request 的选项里没有这个字段,也没有任何地方把它转交给
|
|
138
|
+
* fetch),所以代理只能从 fetch 挂进去。
|
|
139
|
+
*/
|
|
140
|
+
function createProxyDispatcher(
|
|
141
|
+
proxy: string,
|
|
142
|
+
noProxy: string | undefined,
|
|
143
|
+
): NonNullable<RequestInit["dispatcher"]> {
|
|
144
|
+
// undici 包与 Node 全局 fetch 各带一份 Dispatcher 类型声明(@types/node 走
|
|
145
|
+
// undici-types),结构一致但 compose 重载对不上,这里只做类型层面的转换。
|
|
146
|
+
return new EnvHttpProxyAgent({
|
|
147
|
+
httpProxy: proxy,
|
|
148
|
+
httpsProxy: proxy,
|
|
149
|
+
...(noProxy && { noProxy }),
|
|
150
|
+
}) as unknown as NonNullable<RequestInit["dispatcher"]>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface GhProxy {
|
|
154
|
+
/** 读取配置(首个调用触发读盘,之后返回同一个缓存结果,失败不抛出)。 */
|
|
155
|
+
load(): Promise<GhProxyLoad>;
|
|
156
|
+
/** 要注入 gh 子进程的代理环境变量;未配置代理时为空对象。 */
|
|
157
|
+
env(): Promise<NodeJS.ProcessEnv>;
|
|
158
|
+
/** 走代理的 fetch;未配置代理时就是全局 fetch。 */
|
|
159
|
+
readonly fetch: typeof globalThis.fetch;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 创建代理配置读取器。配置只在首次使用时读一次并缓存;`load` 与 `fetch` 共用这次
|
|
164
|
+
* 读取,因此运行期不会出现两者看到不同配置的情况。
|
|
165
|
+
*
|
|
166
|
+
* 缓存的是 dispatcher(连接池复用),**不是** `globalThis.fetch` 本身:每次调用都
|
|
167
|
+
* 取当前的全局 fetch,否则首个请求之后替换 `globalThis.fetch`(插桩、测试替身)
|
|
168
|
+
* 就不再生效。
|
|
169
|
+
*/
|
|
170
|
+
export function createGhProxy(
|
|
171
|
+
configPath: string = ghProxyConfigPath(),
|
|
172
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
173
|
+
): GhProxy {
|
|
174
|
+
let loading: Promise<GhProxyLoad> | undefined;
|
|
175
|
+
let dispatcher: NonNullable<RequestInit["dispatcher"]> | undefined;
|
|
176
|
+
|
|
177
|
+
function load(): Promise<GhProxyLoad> {
|
|
178
|
+
loading ??= resolveSettings(configPath, env);
|
|
179
|
+
return loading;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
load,
|
|
184
|
+
env: async () => {
|
|
185
|
+
const { settings } = await load();
|
|
186
|
+
return proxyEnvVars(settings);
|
|
187
|
+
},
|
|
188
|
+
fetch: async (input, init) => {
|
|
189
|
+
const { settings } = await load();
|
|
190
|
+
const { proxy, noProxy } = settings;
|
|
191
|
+
if (!proxy) return globalThis.fetch(input, init);
|
|
192
|
+
dispatcher ??= createProxyDispatcher(proxy, noProxy);
|
|
193
|
+
return globalThis.fetch(input, { ...init, dispatcher });
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/lib/github.ts
CHANGED
|
@@ -248,17 +248,29 @@ export interface GithubApi {
|
|
|
248
248
|
call<T>(fn: (octokit: Octokit) => Promise<T>): Promise<T>;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
export interface GithubClientOptions {
|
|
252
|
+
/**
|
|
253
|
+
* Custom fetch for octokit's `request.fetch` hook — octokit v5 drops the old
|
|
254
|
+
* `agent` option, so a proxy has to arrive as a fetch implementation with a
|
|
255
|
+
* proxy dispatcher attached. Defaults to the global fetch.
|
|
256
|
+
*/
|
|
257
|
+
fetch?: typeof globalThis.fetch;
|
|
258
|
+
}
|
|
259
|
+
|
|
251
260
|
/**
|
|
252
261
|
* Create a shared octokit accessor. The client (and its auth token) is cached
|
|
253
262
|
* in the returned closure, so repeated calls reuse the same client without
|
|
254
263
|
* module-level state. A stale cached token can produce 401s; the cache is
|
|
255
264
|
* dropped and the request retried once in that case.
|
|
256
265
|
*/
|
|
257
|
-
export function createGithubApi(): GithubApi {
|
|
266
|
+
export function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
258
267
|
let client: Octokit | undefined;
|
|
259
268
|
|
|
260
269
|
async function getClient(): Promise<Octokit> {
|
|
261
|
-
client ??= new Octokit({
|
|
270
|
+
client ??= new Octokit({
|
|
271
|
+
auth: await ghAuthToken(),
|
|
272
|
+
...(options.fetch && { request: { fetch: options.fetch } }),
|
|
273
|
+
});
|
|
262
274
|
return client;
|
|
263
275
|
}
|
|
264
276
|
|
|
@@ -287,8 +299,8 @@ export interface GithubSearch {
|
|
|
287
299
|
/**
|
|
288
300
|
* Create a search client backed by a cached octokit instance.
|
|
289
301
|
*/
|
|
290
|
-
export function createGithubSearch(): GithubSearch {
|
|
291
|
-
const api = createGithubApi();
|
|
302
|
+
export function createGithubSearch(options: GithubClientOptions = {}): GithubSearch {
|
|
303
|
+
const api = createGithubApi(options);
|
|
292
304
|
|
|
293
305
|
return {
|
|
294
306
|
async search(kind, params) {
|
|
@@ -334,6 +346,10 @@ export interface CheckRun {
|
|
|
334
346
|
readonly url: string | null;
|
|
335
347
|
/** Triggering workflow event (push, pull_request, ...); null when unknown. */
|
|
336
348
|
readonly event: string | null;
|
|
349
|
+
/** Actions workflow run id parsed from `details_url`; null for non-Actions checks. */
|
|
350
|
+
readonly runId: number | null;
|
|
351
|
+
/** Actions job id parsed from `details_url`; null when the check is run- not job-level. */
|
|
352
|
+
readonly jobId: number | null;
|
|
337
353
|
}
|
|
338
354
|
|
|
339
355
|
/** One Actions job flattened with its workflow run metadata. */
|
|
@@ -347,6 +363,32 @@ export interface ActionJob {
|
|
|
347
363
|
readonly jobUrl?: string;
|
|
348
364
|
}
|
|
349
365
|
|
|
366
|
+
/** One step of a workflow run job, as the REST API reports it. */
|
|
367
|
+
export interface RunJobStep {
|
|
368
|
+
readonly name: string;
|
|
369
|
+
readonly number: number;
|
|
370
|
+
readonly status: string;
|
|
371
|
+
readonly conclusion: string | null;
|
|
372
|
+
/** ISO-8601 UTC, second precision; null/absent for steps that never started. */
|
|
373
|
+
readonly started_at?: string | null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* One job of a workflow run. Field names mirror the REST API because the CI log
|
|
378
|
+
* index consumes them as they arrive: `run_url`/`run_id` locate the job's raw
|
|
379
|
+
* log file, `steps` give the step list to align against that log.
|
|
380
|
+
*/
|
|
381
|
+
export interface RunJob {
|
|
382
|
+
readonly id: number;
|
|
383
|
+
readonly run_id: number;
|
|
384
|
+
readonly run_url: string;
|
|
385
|
+
readonly name: string;
|
|
386
|
+
readonly status: string;
|
|
387
|
+
readonly conclusion: string | null;
|
|
388
|
+
readonly html_url: string | null;
|
|
389
|
+
readonly steps: readonly RunJobStep[];
|
|
390
|
+
}
|
|
391
|
+
|
|
350
392
|
export interface GithubChecksClient {
|
|
351
393
|
statuses(
|
|
352
394
|
owner: string,
|
|
@@ -367,6 +409,21 @@ export interface GithubChecksClient {
|
|
|
367
409
|
headSha: string,
|
|
368
410
|
signal: AbortSignal,
|
|
369
411
|
): Promise<readonly ActionJob[]>;
|
|
412
|
+
/**
|
|
413
|
+
* Every job of one workflow run, steps included. The endpoint pages at 30
|
|
414
|
+
* items by default, which used to hide the jobs past the first page from the
|
|
415
|
+
* CI-log tools; `paginate` follows the Link header so all pages arrive.
|
|
416
|
+
*/
|
|
417
|
+
runJobs(
|
|
418
|
+
owner: string,
|
|
419
|
+
repo: string,
|
|
420
|
+
runId: number,
|
|
421
|
+
signal: AbortSignal | undefined,
|
|
422
|
+
): Promise<readonly RunJob[]>;
|
|
423
|
+
/** One job by id, steps included (`run_url`/`run_id` identify its run). */
|
|
424
|
+
job(owner: string, repo: string, jobId: number, signal: AbortSignal | undefined): Promise<RunJob>;
|
|
425
|
+
/** Head commit SHA of a PR — the commit whose checks are reported. */
|
|
426
|
+
pullHead(owner: string, repo: string, pullNumber: number, signal: AbortSignal): Promise<string>;
|
|
370
427
|
/** Resolve a SHA, branch name, or tag name to the commit's full SHA. */
|
|
371
428
|
headSha(owner: string, repo: string, ref: string, signal: AbortSignal): Promise<string>;
|
|
372
429
|
}
|
|
@@ -378,13 +435,46 @@ export interface GithubChecksClient {
|
|
|
378
435
|
* GitHub Apps) — so external CI is visible to the caller.
|
|
379
436
|
*/
|
|
380
437
|
const ACTIONS_RUN_URL_RE = /\/actions\/runs\/(\d+)/;
|
|
381
|
-
|
|
382
|
-
|
|
438
|
+
/** Job-level check runs point at `.../actions/runs/<run>/job/<job>`. */
|
|
439
|
+
const ACTIONS_JOB_URL_RE = /\/actions\/runs\/\d+\/job\/(\d+)/;
|
|
440
|
+
|
|
441
|
+
/** One REST job object (list and single-job endpoints share the schema). */
|
|
442
|
+
type ApiJob = Awaited<ReturnType<Octokit["rest"]["actions"]["getJobForWorkflowRun"]>>["data"];
|
|
443
|
+
|
|
444
|
+
function toRunJob(job: ApiJob): RunJob {
|
|
445
|
+
return {
|
|
446
|
+
id: job.id,
|
|
447
|
+
run_id: job.run_id,
|
|
448
|
+
run_url: job.run_url,
|
|
449
|
+
name: job.name,
|
|
450
|
+
status: job.status,
|
|
451
|
+
conclusion: job.conclusion,
|
|
452
|
+
html_url: job.html_url ?? null,
|
|
453
|
+
steps: (job.steps ?? []).map((step) => ({
|
|
454
|
+
name: step.name,
|
|
455
|
+
number: step.number,
|
|
456
|
+
status: step.status,
|
|
457
|
+
conclusion: step.conclusion,
|
|
458
|
+
started_at: step.started_at ?? null,
|
|
459
|
+
})),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function createGithubChecks(options: GithubClientOptions = {}): GithubChecksClient {
|
|
464
|
+
const api = createGithubApi(options);
|
|
383
465
|
|
|
384
466
|
return {
|
|
385
467
|
async statuses(owner, repo, ref, signal) {
|
|
386
468
|
const { data } = await api.call((octokit) =>
|
|
387
|
-
octokit.rest.repos.getCombinedStatusForRef({
|
|
469
|
+
octokit.rest.repos.getCombinedStatusForRef({
|
|
470
|
+
owner,
|
|
471
|
+
repo,
|
|
472
|
+
ref,
|
|
473
|
+
// The statuses array is paginated and defaults to 30 per page; a
|
|
474
|
+
// commit carrying more than 100 statuses is not a real scenario.
|
|
475
|
+
per_page: 100,
|
|
476
|
+
request: { signal },
|
|
477
|
+
}),
|
|
388
478
|
);
|
|
389
479
|
return data.statuses.map((status) => ({
|
|
390
480
|
context: status.context,
|
|
@@ -414,8 +504,8 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
414
504
|
);
|
|
415
505
|
const events = new Map<string, string>();
|
|
416
506
|
if (runIds.size > 0) {
|
|
417
|
-
const
|
|
418
|
-
octokit.rest.actions.listWorkflowRunsForRepo
|
|
507
|
+
const commitRuns = await api.call((octokit) =>
|
|
508
|
+
octokit.paginate(octokit.rest.actions.listWorkflowRunsForRepo, {
|
|
419
509
|
owner,
|
|
420
510
|
repo,
|
|
421
511
|
head_sha: ref,
|
|
@@ -423,24 +513,34 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
423
513
|
request: { signal },
|
|
424
514
|
}),
|
|
425
515
|
);
|
|
426
|
-
for (const run of
|
|
516
|
+
for (const run of commitRuns) {
|
|
427
517
|
const id = String(run.id);
|
|
428
518
|
if (runIds.has(id)) events.set(id, run.event);
|
|
429
519
|
}
|
|
430
520
|
}
|
|
431
|
-
return runs.map((run) =>
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
521
|
+
return runs.map((run) => {
|
|
522
|
+
const detailsUrl = run.details_url ?? "";
|
|
523
|
+
const runId = ACTIONS_RUN_URL_RE.exec(detailsUrl)?.[1];
|
|
524
|
+
const jobId = ACTIONS_JOB_URL_RE.exec(detailsUrl)?.[1];
|
|
525
|
+
return {
|
|
526
|
+
name: run.name,
|
|
527
|
+
status: run.status,
|
|
528
|
+
conclusion: run.conclusion,
|
|
529
|
+
startedAt: run.started_at,
|
|
530
|
+
url: run.html_url ?? run.details_url ?? null,
|
|
531
|
+
event: events.get(runId ?? "") ?? null,
|
|
532
|
+
runId: runId === undefined ? null : Number(runId),
|
|
533
|
+
jobId: jobId === undefined ? null : Number(jobId),
|
|
534
|
+
};
|
|
535
|
+
});
|
|
439
536
|
},
|
|
440
537
|
|
|
441
538
|
async actionJobs(owner, repo, headSha, signal) {
|
|
442
|
-
|
|
443
|
-
|
|
539
|
+
// Both levels are paginated: a commit can carry several workflow runs
|
|
540
|
+
// (push and pull_request), and a single run can have more jobs than one
|
|
541
|
+
// page — a missing page would silently drop failed jobs from the report.
|
|
542
|
+
const runs = await api.call((octokit) =>
|
|
543
|
+
octokit.paginate(octokit.rest.actions.listWorkflowRunsForRepo, {
|
|
444
544
|
owner,
|
|
445
545
|
repo,
|
|
446
546
|
head_sha: headSha,
|
|
@@ -449,9 +549,9 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
449
549
|
}),
|
|
450
550
|
);
|
|
451
551
|
const jobs: ActionJob[] = [];
|
|
452
|
-
for (const run of
|
|
453
|
-
const
|
|
454
|
-
octokit.rest.actions.listJobsForWorkflowRun
|
|
552
|
+
for (const run of runs) {
|
|
553
|
+
const runJobs = await api.call((octokit) =>
|
|
554
|
+
octokit.paginate(octokit.rest.actions.listJobsForWorkflowRun, {
|
|
455
555
|
owner,
|
|
456
556
|
repo,
|
|
457
557
|
run_id: run.id,
|
|
@@ -459,7 +559,7 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
459
559
|
request: { signal },
|
|
460
560
|
}),
|
|
461
561
|
);
|
|
462
|
-
for (const job of
|
|
562
|
+
for (const job of runJobs) {
|
|
463
563
|
jobs.push({
|
|
464
564
|
runId: run.id,
|
|
465
565
|
runName: run.name ?? "",
|
|
@@ -474,6 +574,38 @@ export function createGithubChecks(): GithubChecksClient {
|
|
|
474
574
|
return jobs;
|
|
475
575
|
},
|
|
476
576
|
|
|
577
|
+
async runJobs(owner, repo, runId, signal) {
|
|
578
|
+
const jobs = await api.call((octokit) =>
|
|
579
|
+
octokit.paginate(octokit.rest.actions.listJobsForWorkflowRun, {
|
|
580
|
+
owner,
|
|
581
|
+
repo,
|
|
582
|
+
run_id: runId,
|
|
583
|
+
per_page: 100,
|
|
584
|
+
request: { signal },
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
return jobs.map((job) => toRunJob(job));
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
async job(owner, repo, jobId, signal) {
|
|
591
|
+
const { data } = await api.call((octokit) =>
|
|
592
|
+
octokit.rest.actions.getJobForWorkflowRun({
|
|
593
|
+
owner,
|
|
594
|
+
repo,
|
|
595
|
+
job_id: jobId,
|
|
596
|
+
request: { signal },
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
return toRunJob(data);
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
async pullHead(owner, repo, pullNumber, signal) {
|
|
603
|
+
const { data } = await api.call((octokit) =>
|
|
604
|
+
octokit.rest.pulls.get({ owner, repo, pull_number: pullNumber, request: { signal } }),
|
|
605
|
+
);
|
|
606
|
+
return data.head.sha;
|
|
607
|
+
},
|
|
608
|
+
|
|
477
609
|
async headSha(owner, repo, ref, signal) {
|
|
478
610
|
const { data } = await api.call((octokit) =>
|
|
479
611
|
octokit.rest.repos.getCommit({ owner, repo, ref, request: { signal } }),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: github-ci-logs
|
|
3
|
+
description: Use when 排查某个仓库的 CI 失败、要拿到具体 job 的日志 —— 从 repo + PR number / commit sha / branch name / tag 一路定位到 job 日志:read-github-pr-status 或 wait-github-commit-checks 找出失败的 check 及其 run_id/job_id,get-github-workflow-jobs 列某个 run 的全部 job,read-github-ci-logs 按 job_id 把原始日志落盘并按 step 行号读取。全程走工具,不需要手工跑 gh CLI。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 从 PR / commit 定位到 CI 日志
|
|
7
|
+
|
|
8
|
+
## 核心原则
|
|
9
|
+
|
|
10
|
+
- **id 是唯一可靠的坐标**:日志工具只收 `job_id`,不接受 job 名称。名称 → id 必须先经过 check 列表或 job 列表工具。
|
|
11
|
+
- **不手工调 gh**:check / job / PR 数据都由工具封装好了 ,自己拼 `gh api` 只会重复劳动、还可能踩分页坑(jobs 端点默认一页 30 条)。
|
|
12
|
+
- **一次调用一个 job**:多个失败 job 就多次调用,逐个取。
|
|
13
|
+
- **日志内容不进上下文**:`read-github-ci-logs` 只返回索引(落盘路径 + 每个 step 的行号区间),内容自己用 Read 读区间、Grep 搜错误。
|
|
14
|
+
|
|
15
|
+
## 入口选择
|
|
16
|
+
|
|
17
|
+
| 手里有什么 | 第一步 | 拿到什么 |
|
|
18
|
+
| ----------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
19
|
+
| PR number | `read-github-pr-status { number, repo? }` | 该 PR head commit 的 checks 快照:`bucket` / `event` / `run_id` / `job_id` / `url` |
|
|
20
|
+
| commit sha / branch / tag | `wait-github-commit-checks { commit, repo?, event? }` | 已失败时立即返回;报告失败 job 的行带 `#<jobId>` |
|
|
21
|
+
| 只有 repo(不知道是哪个 run) | `list-github-workflow-runs { repo?, workflow?, status?, limit? }` | run 列表(含 run id) |
|
|
22
|
+
| 只有 run_id | `get-github-workflow-jobs { run_id, repo? }` | 该 run 的全部 job:`id` / `name` / `status` / `conclusion` / `html_url` / `steps` |
|
|
23
|
+
|
|
24
|
+
`repo` 都可省略,缺省用当前目录解析。
|
|
25
|
+
|
|
26
|
+
## 主流程
|
|
27
|
+
|
|
28
|
+
1. 用上表挑一个入口,拿到 checks 或 jobs 列表。
|
|
29
|
+
2. 找失败项:`bucket == "fail"`(PR 快照)或报告里 `(failure)` / `(timed_out)` / `(cancelled)` 的行。
|
|
30
|
+
3. 有 `job_id` 就直接进第 4 步。只有 `run_id`、或者想看看同一个 run 里还有哪些 job,就先 `get-github-workflow-jobs { run_id }`,按 `name` / `status` 挑出目标 job 的 `id`。
|
|
31
|
+
4. `read-github-ci-logs { job_id, repo? }` → JSON 索引:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"name": "lint",
|
|
36
|
+
"id": 92374541920,
|
|
37
|
+
"status": "completed",
|
|
38
|
+
"conclusion": "failure",
|
|
39
|
+
"log_file": "/home/me/.cache/pi/github/ci-logs/a/b/42/92374541920.log",
|
|
40
|
+
"steps": [
|
|
41
|
+
{
|
|
42
|
+
"number": 6,
|
|
43
|
+
"name": "Run npx prettier --check ./",
|
|
44
|
+
"conclusion": "failure",
|
|
45
|
+
"start_line": 120,
|
|
46
|
+
"end_line": 148
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
5. 读日志:优先读失败 step 的 `[start_line, end_line]`;要检索关键词(`##[error]`、`error:`、报错文件名)就在 `log_file` 上 Grep。`steps` 里没有行号的 step 是跳过或没产生日志的。
|
|
53
|
+
6. 还有别的失败 job → 回到第 4 步(用各自 job id)。
|
|
54
|
+
|
|
55
|
+
## 输出与边界
|
|
56
|
+
|
|
57
|
+
- `read-github-pr-status`:**立即返回快照,不轮询**。字段:`checks[].{name,bucket,event,run_id,job_id,url}`,非 Actions 的 check(Azure DevOps 等)`run_id`/`job_id` 为 null —— 这类没有 GitHub 侧 job 日志可读。
|
|
58
|
+
- `wait-github-commit-checks`:用于「没有 PR、只有 sha/branch/tag」的入口。它等的是 checks 全部结束,但**已经失败的 check 会让它第一轮就返回**;失败报告里的 Actions job 行带 `#jobId`。要更快返回可传 `fail_fast: true`;只想看某个事件的 check 用 `event`(如 `push`)。
|
|
59
|
+
- `get-github-workflow-jobs`:SDK 分页,一个 run 有 72 个 job 也会全部返回(旧版只返回前 30 个,第 31 个之后既列不出也选不中)。
|
|
60
|
+
- `read-github-ci-logs`:job 还在 `queued` 时不出日志,提示用 `watch-github-run` 等它开始;日志原样落盘到 `~/.cache/pi/github/ci-logs/<owner>/<repo>/<run_id>/<job_id>.log`(保留 runner 时间戳与 ANSI),同一 job 重复调用命中缓存,不重复下载。
|
|
61
|
+
- 工具**不做**日志截断、清洗或回显;拿到的是文件路径 + 行号,正文自己读。
|
|
62
|
+
|
|
63
|
+
## 与相邻工具的分工
|
|
64
|
+
|
|
65
|
+
- `read-github-pr-status`(快照即返)vs `wait-github-pr-checks`(阻塞轮询到检查结束):前者用来定位,后者用来等结果。
|
|
66
|
+
- `watch-github-run`:已知 run_id、要等这个 run 跑完时用。
|
|
67
|
+
- `list-github-workflow-runs`:run 列表走 `gh run list`,适合「某个 workflow 最近一次跑得怎么样」这类不需要 PR 上下文的场景。
|
|
68
|
+
|
|
69
|
+
## 反例
|
|
70
|
+
|
|
71
|
+
- 用 job 名称去调 `read-github-ci-logs` —— 它只认 id,名称先经 `get-github-workflow-jobs` 转成 id。
|
|
72
|
+
- 为了拿日志去 Bash 跑 `gh api /repos/.../actions/runs/<id>/jobs` —— 直接用 `get-github-workflow-jobs`,分页与 id 提取已经处理好。
|
|
73
|
+
- 把整份日志读进上下文 —— 先用索引里的行号区间,或对 `log_file` 做 Grep。
|