@trim21/personal-pi-extensions 0.1.520 → 0.1.522

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.520",
3
+ "version": "0.1.522",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -19,6 +19,7 @@
19
19
  * - list-github-releases: List releases
20
20
  * - read-github-release: Get release details
21
21
  * - wait-github-pr-checks: Watch PR CI checks
22
+ * - wait-github-commit-checks: Watch CI checks of a commit (no PR required)
22
23
  * - watch-github-run: Watch a workflow run
23
24
  *
24
25
  * Install:
@@ -35,10 +36,19 @@ import { homedir } from "node:os";
35
36
  import { delimiter, dirname, join, resolve } from "node:path";
36
37
 
37
38
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
- import { type Static, Type } from "typebox";
39
+ import { Type } from "typebox";
39
40
  import { Value } from "typebox/value";
40
41
 
41
- import { createGithubSearch, type GithubSearch, renderHits } from "./lib/github.js";
42
+ import {
43
+ type ActionJob,
44
+ type CheckRun,
45
+ type CommitStatus,
46
+ createGithubChecks,
47
+ createGithubSearch,
48
+ type GithubChecksClient,
49
+ type GithubSearch,
50
+ renderHits,
51
+ } from "./lib/github.js";
42
52
  import { type ToolPendant } from "./lib/pendant.js";
43
53
  import { createSeqState } from "./lib/seq-state.js";
44
54
 
@@ -234,6 +244,15 @@ function repoArgs(repo?: string): string[] {
234
244
  return repo ? ["--repo", repo] : [];
235
245
  }
236
246
 
247
+ /** Split `OWNER/REPO`; throws when the name doesn't have exactly one slash. */
248
+ function splitRepo(nameWithOwner: string): { owner: string; repo: string } {
249
+ const slash = nameWithOwner.indexOf("/");
250
+ if (slash <= 0 || slash === nameWithOwner.length - 1 || nameWithOwner.includes("/", slash + 1)) {
251
+ throw new Error(`invalid repository: ${nameWithOwner} (expected OWNER/REPO)`);
252
+ }
253
+ return { owner: nameWithOwner.slice(0, slash), repo: nameWithOwner.slice(slash + 1) };
254
+ }
255
+
237
256
  // ── runtime validation schemas for JSON.parse results ───────────────────────
238
257
 
239
258
  const repoViewSchema = Type.Object({ nameWithOwner: Type.String() });
@@ -258,31 +277,6 @@ const jobsResponseSchema = Type.Object({ jobs: Type.Array(jobRunSchema) });
258
277
 
259
278
  const prHeadSchema = Type.Object({ headRefOid: Type.String() });
260
279
 
261
- const workflowRunSchema = Type.Object({
262
- id: Type.Number(),
263
- name: Type.String(),
264
- html_url: Type.String(),
265
- });
266
-
267
- const workflowRunsSchema = Type.Object({ workflow_runs: Type.Array(workflowRunSchema) });
268
-
269
- /**
270
- * One entry of `gh pr checks --json`. In JSON mode gh exits 0 once it could
271
- * fetch the checks (real errors still exit non-zero); pass/fail/pending is
272
- * only conveyed by the `bucket` field, never by the exit code.
273
- */
274
- const prCheckSchema = Type.Object({
275
- name: Type.String(),
276
- state: Type.String(),
277
- bucket: Type.String(),
278
- startedAt: Type.Union([Type.String(), Type.Null()]),
279
- completedAt: Type.Union([Type.String(), Type.Null()]),
280
- link: Type.Union([Type.String(), Type.Null()]),
281
- workflow: Type.Union([Type.String(), Type.Null()]),
282
- });
283
-
284
- type PrCheck = Static<typeof prCheckSchema>;
285
-
286
280
  function truncate(
287
281
  text: string,
288
282
  maxLines = 2000,
@@ -1130,34 +1124,119 @@ export async function writeLogFile(
1130
1124
 
1131
1125
  // ── pr checks watch (pure rendering + poll loop) ────────────────────────────
1132
1126
 
1133
- const PR_CHECKS_JSON_FIELDS = "name,state,bucket,startedAt,completedAt,link,workflow";
1134
1127
  const CHECKS_POLL_INTERVAL_MS = 30_000;
1135
1128
  const CHECKS_WATCH_DEADLINE_MS = 600_000;
1136
1129
 
1130
+ export type CheckBucket = "pass" | "skipped" | "fail" | "pending";
1131
+
1132
+ /**
1133
+ * One judged CI check of a commit: a single commit status or check run, kept
1134
+ * distinct — same-named checks from different sources (push vs pull_request
1135
+ * events, status vs check run channels) stay separate entries, like the
1136
+ * GitHub checks UI.
1137
+ */
1138
+ export interface MergedCheck {
1139
+ readonly name: string;
1140
+ readonly bucket: CheckBucket;
1141
+ readonly startedAt: string | null;
1142
+ readonly link: string | null;
1143
+ /** Triggering workflow event (push, pull_request, ...); null when unknown. */
1144
+ readonly event: string | null;
1145
+ }
1146
+
1147
+ function statusBucket(state: string): CheckBucket {
1148
+ if (state === "success") return "pass";
1149
+ if (state === "failure" || state === "error") return "fail";
1150
+ // pending, expected, and anything unknown must not end the wait
1151
+ return "pending";
1152
+ }
1153
+
1154
+ function checkRunBucket(run: CheckRun): CheckBucket {
1155
+ if (run.status !== "completed" || run.conclusion === null) return "pending";
1156
+ switch (run.conclusion) {
1157
+ case "success": {
1158
+ return "pass";
1159
+ }
1160
+ case "skipped":
1161
+ case "neutral":
1162
+ case "stale":
1163
+ case "action_required": {
1164
+ // awaiting maintainer approval: it will never run, so waiting for it is
1165
+ // meaningless — treat like skipped
1166
+ return "skipped";
1167
+ }
1168
+ case "failure":
1169
+ case "timed_out":
1170
+ case "cancelled":
1171
+ case "startup_failure": {
1172
+ return "fail";
1173
+ }
1174
+ default: {
1175
+ return "pending";
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * Judge the commit's statuses and check runs into individual checks, keeping
1182
+ * same-named entries distinct so the wait verdict (any fail / all
1183
+ * pass-or-skipped across every entry) can never lose a failure. Pure — no
1184
+ * network.
1185
+ */
1186
+ export function mergeChecks(
1187
+ statuses: readonly CommitStatus[],
1188
+ checkRuns: readonly CheckRun[],
1189
+ ): MergedCheck[] {
1190
+ return [
1191
+ ...statuses.map((status) => ({
1192
+ name: status.context,
1193
+ bucket: statusBucket(status.state),
1194
+ startedAt: null,
1195
+ link: status.targetUrl,
1196
+ event: null,
1197
+ })),
1198
+ ...checkRuns.map((run) => ({
1199
+ name: run.name,
1200
+ bucket: checkRunBucket(run),
1201
+ startedAt: run.startedAt,
1202
+ link: run.url,
1203
+ event: run.event,
1204
+ })),
1205
+ ];
1206
+ }
1207
+
1208
+ /** Display name of a check; the trigger event is labelled like the GitHub UI (`build (pull_request)`). */
1209
+ export function checkDisplayName(check: MergedCheck): string {
1210
+ return check.event ? `${check.name} (${check.event})` : check.name;
1211
+ }
1212
+
1137
1213
  /**
1138
- * Render one polling round of `gh pr checks` as a compact bullet list of the
1139
- * checks still in flight: running ones first (`- [>]`), queued ones after
1140
- * (`- [ ]`). Completed checks are hidden — the header already reports the
1141
- * completion count. Pure — no network.
1214
+ * Render one polling round as a compact bullet list of the checks still in
1215
+ * flight: running ones first (`- [>]`), queued ones after (`- [ ]`). Completed
1216
+ * checks are hidden — the header already reports the completion count.
1217
+ * Pure — no network.
1142
1218
  */
1143
1219
  export function renderPrChecksList(options: {
1144
- prNumber: number | string;
1220
+ /** Report subject, e.g. `PR #7` or `commit 5a7c407`. */
1221
+ subject: string;
1145
1222
  round: number;
1146
- checks: readonly PrCheck[];
1223
+ checks: readonly MergedCheck[];
1147
1224
  }): string {
1148
- const { prNumber, round, checks } = options;
1225
+ const { subject, round, checks } = options;
1149
1226
  const completed = checks.filter((c) => c.bucket !== "pending").length;
1150
1227
 
1151
1228
  const pending = checks.filter((c) => c.bucket === "pending");
1152
1229
  const ordered = [...pending.filter((c) => c.startedAt), ...pending.filter((c) => !c.startedAt)];
1153
1230
  const lines = ordered.map((check) => {
1154
- const name = check.link ? `[${check.name}](${check.link})` : check.name;
1231
+ const name = check.link
1232
+ ? `[${checkDisplayName(check)}](${check.link})`
1233
+ : checkDisplayName(check);
1155
1234
  return `- [${check.startedAt ? ">" : " "}] ${name}`;
1156
1235
  });
1157
1236
  const body =
1158
1237
  checks.length === 0 ? "- _no checks reported_" : lines.length > 0 ? lines.join("\n") : "";
1159
1238
 
1160
- return `### PR #${prNumber} checks — round ${round}: ${completed}/${checks.length} complete${body ? `\n\n${body}` : ""}`;
1239
+ return `${subject} checks — round ${round}: ${completed}/${checks.length} complete${body ? `\n\n${body}` : ""}`;
1161
1240
  }
1162
1241
 
1163
1242
  function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promise<void> {
@@ -1178,12 +1257,30 @@ function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promis
1178
1257
  });
1179
1258
  }
1180
1259
 
1260
+ export type ChecksPollOutcome = "completed" | "fail_fast" | "timeout";
1261
+
1262
+ export interface ChecksPollResult {
1263
+ readonly outcome: ChecksPollOutcome;
1264
+ readonly checks: readonly MergedCheck[];
1265
+ readonly elapsedMs: number;
1266
+ }
1267
+
1181
1268
  export interface PollPrChecksOptions {
1182
- prNumber: number | string;
1183
- repo?: string;
1269
+ /** Report subject for progress lines, e.g. `PR #7` or `commit 5a7c407`. */
1270
+ subject: string;
1271
+ owner: string;
1272
+ repo: string;
1273
+ headSha: string;
1184
1274
  failFast: boolean;
1185
- cwd?: string;
1186
- signal?: AbortSignal;
1275
+ checks: GithubChecksClient;
1276
+ /**
1277
+ * When set, only check runs triggered by this workflow event (e.g. push)
1278
+ * are judged; commit statuses have an unknown trigger event and are
1279
+ * excluded. Unset means all checks of the commit.
1280
+ */
1281
+ event?: string;
1282
+ /** Owned by the caller; the poll loop observes it but never aborts it. */
1283
+ signal: AbortSignal;
1187
1284
  /** Test overrides. */
1188
1285
  intervalMs?: number;
1189
1286
  deadlineMs?: number;
@@ -1191,52 +1288,158 @@ export interface PollPrChecksOptions {
1191
1288
  }
1192
1289
 
1193
1290
  /**
1194
- * Poll `gh pr checks --json` until no check is pending (or a fail-fast
1195
- * failure, or the deadline), emitting a compact list of in-flight checks via
1196
- * `onUpdate` each round. In JSON mode gh exits 0 whenever it could fetch the
1197
- * checks
1198
- * completion is judged from the `bucket` field, not the exit code. A non-zero
1199
- * exit (no checks reported, auth, network) is not fatal here: the caller
1200
- * proceeds to the Actions API verification, which either produces the final
1201
- * report or surfaces the error.
1291
+ * Poll the commit's combined-status and check-runs APIs until the wait
1292
+ * semantics are met: return on any failure (immediately under fail-fast) or
1293
+ * when every check is complete (pass/skipped). Emits a compact list of
1294
+ * in-flight checks via `onUpdate` each round.
1295
+ *
1296
+ * A failed round (network, auth) does not end the wait — the error is kept
1297
+ * and polling continues, so a transient blip or a CI system that has not
1298
+ * reported anything yet cannot be mistaken for a completed check set. Only
1299
+ * when no round ever succeeded by the deadline is the last error thrown.
1202
1300
  */
1203
- export async function pollPrChecks(options: PollPrChecksOptions): Promise<void> {
1204
- const { prNumber, repo, failFast, cwd, signal, onUpdate } = options;
1301
+ export async function pollPrChecks(options: PollPrChecksOptions): Promise<ChecksPollResult> {
1302
+ const { subject, owner, repo, headSha, failFast, checks, signal, onUpdate } = options;
1205
1303
  const intervalMs = options.intervalMs ?? CHECKS_POLL_INTERVAL_MS;
1206
1304
  const deadlineMs = options.deadlineMs ?? CHECKS_WATCH_DEADLINE_MS;
1207
- const args = [
1208
- "pr",
1209
- "checks",
1210
- String(prNumber),
1211
- ...repoArgs(repo),
1212
- "--json",
1213
- PR_CHECKS_JSON_FIELDS,
1214
- ];
1215
1305
 
1216
1306
  const watchStart = Date.now();
1307
+ let lastChecks: readonly MergedCheck[] = [];
1308
+ let lastError: unknown;
1309
+ let everSucceeded = false;
1310
+
1217
1311
  for (let round = 1; ; round++) {
1218
- const result = await runGh(args, { cwd, signal });
1219
- if (result.killed) {
1220
- throw new Error(
1221
- result.reason === "timeout" ? "gh pr checks poll timed out" : "gh pr checks was aborted",
1222
- );
1312
+ if (signal.aborted) throw new Error("PR checks polling was aborted");
1313
+ try {
1314
+ const [statuses, runs] = await Promise.all([
1315
+ checks.statuses(owner, repo, headSha, signal),
1316
+ checks.checkRuns(owner, repo, headSha, signal),
1317
+ ]);
1318
+ everSucceeded = true;
1319
+ lastChecks = mergeChecks(statuses, runs);
1320
+ if (options.event) {
1321
+ lastChecks = lastChecks.filter((c) => c.event === options.event);
1322
+ }
1323
+ onUpdate?.({
1324
+ content: [
1325
+ { type: "text", text: renderPrChecksList({ subject, round, checks: lastChecks }) },
1326
+ ],
1327
+ details: {},
1328
+ });
1329
+ if (lastChecks.every((c) => c.bucket !== "pending")) {
1330
+ return { outcome: "completed", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1331
+ }
1332
+ if (failFast && lastChecks.some((c) => c.bucket === "fail")) {
1333
+ return { outcome: "fail_fast", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1334
+ }
1335
+ } catch (error) {
1336
+ // 不用 if (signal.aborted):循环顶部的同名字段检查把它收窄成 false,
1337
+ // TS 会在 catch 里维持这个收窄。
1338
+ signal.throwIfAborted();
1339
+ lastError = error;
1223
1340
  }
1224
- if (result.code !== 0) {
1225
- return;
1341
+ if (Date.now() - watchStart >= deadlineMs) {
1342
+ if (!everSucceeded) {
1343
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
1344
+ throw new Error(`PR checks polling failed before any round succeeded: ${message}`);
1345
+ }
1346
+ return { outcome: "timeout", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1226
1347
  }
1227
- const checks: PrCheck[] = Value.Parse(Type.Array(prCheckSchema), JSON.parse(result.stdout));
1348
+ await sleepInterruptibly(intervalMs, signal);
1349
+ }
1350
+ }
1228
1351
 
1229
- onUpdate?.({
1230
- content: [{ type: "text", text: renderPrChecksList({ prNumber, round, checks }) }],
1231
- details: {},
1232
- });
1352
+ /** One Actions job that did not succeed, for the FAILED report details. */
1353
+ export interface FailedActionJob {
1354
+ readonly runId: number;
1355
+ readonly runName: string;
1356
+ readonly runUrl: string;
1357
+ readonly jobId: number;
1358
+ readonly jobName: string;
1359
+ readonly conclusion: string;
1360
+ readonly jobUrl?: string;
1361
+ }
1233
1362
 
1234
- const hasPending = checks.some((c) => c.bucket === "pending");
1235
- if (!hasPending) return;
1236
- if (failFast && checks.some((c) => c.bucket === "fail")) return;
1237
- if (Date.now() - watchStart >= deadlineMs) return;
1238
- await sleepInterruptibly(intervalMs, signal);
1363
+ export interface ChecksVerdict {
1364
+ readonly status: "success" | "failure" | "pending";
1365
+ readonly text: string;
1366
+ readonly failedJobs: readonly FailedActionJob[];
1367
+ }
1368
+
1369
+ /**
1370
+ * Turn a poll result into the final report. Verdict comes from the checks
1371
+ * buckets alone (so external CI such as Azure counts); Actions jobs are
1372
+ * display-only enrichment. Pure — no network.
1373
+ */
1374
+ export function renderChecksVerdict(options: {
1375
+ /** Report subject, e.g. `PR #123` or `commit 5a7c407`. */
1376
+ subject: string;
1377
+ poll: ChecksPollResult;
1378
+ /** All Actions jobs of the head commit; failed/incomplete ones are listed. */
1379
+ actionJobs?: readonly ActionJob[];
1380
+ /** Set when the Actions job fetch failed; the verdict stays untouched. */
1381
+ enrichmentError?: string;
1382
+ }): ChecksVerdict {
1383
+ const { subject, poll, actionJobs, enrichmentError } = options;
1384
+ const totalChecks = poll.checks.length;
1385
+ const failed = poll.checks.filter((c) => c.bucket === "fail");
1386
+ const pending = poll.checks.filter((c) => c.bucket === "pending");
1387
+
1388
+ if (failed.length === 0 && poll.outcome === "completed") {
1389
+ return {
1390
+ status: "success",
1391
+ text: `## ${subject} CI Checks - PASSED\n\nAll ${totalChecks} check(s) passed.`,
1392
+ failedJobs: [],
1393
+ };
1239
1394
  }
1395
+
1396
+ if (failed.length > 0) {
1397
+ const failedJobs: FailedActionJob[] = (actionJobs ?? [])
1398
+ .filter((j) => !j.conclusion || FAILED_JOB_CONCLUSIONS.has(j.conclusion))
1399
+ .map((j) => ({
1400
+ runId: j.runId,
1401
+ runName: j.runName,
1402
+ runUrl: j.runUrl,
1403
+ jobId: j.jobId,
1404
+ jobName: j.jobName,
1405
+ conclusion: j.conclusion ?? "in_progress",
1406
+ ...(j.jobUrl && { jobUrl: j.jobUrl }),
1407
+ }));
1408
+
1409
+ const lines = failed.map(
1410
+ (c) =>
1411
+ `- ${statusIcon("failure")} **${checkDisplayName(c)}**${c.link ? ` — [view check](${c.link})` : ""}`,
1412
+ );
1413
+ for (const j of failedJobs) {
1414
+ lines.push(
1415
+ ` - ${statusIcon(j.conclusion)} job **${j.jobName}** (${j.conclusion}) — [job #${j.jobId}](${j.jobUrl ?? j.runUrl})`,
1416
+ ` - workflow: [${j.runName} (#${j.runId})](${j.runUrl})`,
1417
+ );
1418
+ }
1419
+ if (enrichmentError) lines.push(` - _Actions job details unavailable: ${enrichmentError}_`);
1420
+ if (pending.length > 0) lines.push(`\n_${pending.length} other check(s) still in flight._`);
1421
+
1422
+ return {
1423
+ status: "failure",
1424
+ text:
1425
+ `## ${subject} CI Checks - FAILED\n\n` +
1426
+ `${failed.length} of ${totalChecks} check(s) failed:\n\n${lines.join("\n")}`,
1427
+ failedJobs,
1428
+ };
1429
+ }
1430
+
1431
+ const waitedMinutes = Math.max(1, Math.round(poll.elapsedMs / 60_000));
1432
+ const pendingLines = pending.map(
1433
+ (c) => `- [${c.startedAt ? ">" : " "}] ${c.link ? `[${c.name}](${c.link})` : c.name}`,
1434
+ );
1435
+ return {
1436
+ status: "pending",
1437
+ text:
1438
+ `## ${subject} CI Checks - STILL IN FLIGHT\n\n` +
1439
+ `${pending.length} of ${totalChecks} check(s) still incomplete after ~${waitedMinutes}m:\n\n` +
1440
+ (pendingLines.length > 0 ? pendingLines.join("\n") : "- _no checks reported_"),
1441
+ failedJobs: [],
1442
+ };
1240
1443
  }
1241
1444
 
1242
1445
  // ── tools ────────────────────────────────────────────────────────────────────
@@ -1265,6 +1468,70 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1265
1468
  }
1266
1469
 
1267
1470
  const githubSearch = createGithubSearch();
1471
+ const githubChecks = createGithubChecks();
1472
+
1473
+ /**
1474
+ * Shared wait core of `wait-github-pr-checks` and
1475
+ * `wait-github-commit-checks`: poll the commit's checks, enrich FAILED
1476
+ * reports with Actions job details (display-only), render the verdict.
1477
+ * The caller resolves repo/headSha; `subject` formats the report header.
1478
+ */
1479
+ async function waitChecksReport(options: {
1480
+ subject: string;
1481
+ owner: string;
1482
+ repo: string;
1483
+ headSha: string;
1484
+ failFast: boolean;
1485
+ event?: string;
1486
+ signal: AbortSignal | undefined;
1487
+ onUpdate: ((msg: CiLogsResult) => void) | undefined;
1488
+ params: unknown;
1489
+ pendant?: ToolPendant;
1490
+ }) {
1491
+ const { subject, owner, repo, headSha, failFast, event, signal, onUpdate, params, pendant } =
1492
+ options;
1493
+
1494
+ // 轮询层要求非空 signal;框架可能不给时构造一个占位的(从不取消)。
1495
+ const pollSignal = signal ?? new AbortController().signal;
1496
+
1497
+ const poll = await pollPrChecks({
1498
+ subject,
1499
+ owner,
1500
+ repo,
1501
+ headSha,
1502
+ failFast,
1503
+ event,
1504
+ checks: githubChecks,
1505
+ signal: pollSignal,
1506
+ onUpdate,
1507
+ });
1508
+
1509
+ // Actions job 详情只做展示补充,不影响判定(判定来自 checks bucket,
1510
+ // 覆盖 Azure 等外部 CI)。抓取失败时降级为提示,不推翻结论。
1511
+ let actionJobs: readonly ActionJob[] | undefined;
1512
+ let enrichmentError: string | undefined;
1513
+ if (poll.checks.some((c) => c.bucket === "fail")) {
1514
+ try {
1515
+ actionJobs = await githubChecks.actionJobs(owner, repo, headSha, pollSignal);
1516
+ } catch (error) {
1517
+ enrichmentError =
1518
+ error instanceof Error ? error.message : "Actions job details unavailable";
1519
+ }
1520
+ }
1521
+
1522
+ const verdict = renderChecksVerdict({ subject, poll, actionJobs, enrichmentError });
1523
+ return {
1524
+ content: [{ type: "text" as const, text: verdict.text }],
1525
+ details: {
1526
+ status: verdict.status,
1527
+ totalChecks: poll.checks.length,
1528
+ checks: poll.checks,
1529
+ failedJobs: verdict.failedJobs,
1530
+ input: params,
1531
+ ...(pendant && { pendant }),
1532
+ },
1533
+ };
1534
+ }
1268
1535
 
1269
1536
  // ── read-github-issue ──────────────────────────────────────────────────────
1270
1537
  pi.registerTool({
@@ -1794,8 +2061,10 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1794
2061
  name: "wait-github-pr-checks",
1795
2062
  label: "Watch GitHub PR Checks",
1796
2063
  description:
1797
- "Watch CI status checks for a PR until they complete. Blocks until all checks finish or one fails. " +
1798
- "Each polling round streams a compact bullet list of the checks still in flight via onUpdate. " +
2064
+ "Watch CI status checks for a PR until they complete. Blocks until all checks pass (or are skipped) or one fails. " +
2065
+ "Covers both commit statuses (Azure DevOps, Jenkins, ...) and GitHub Actions check runs. " +
2066
+ "Each polling round streams a compact bullet list of the checks still in flight via onUpdate; " +
2067
+ "on timeout the still-in-flight snapshot is returned instead of a verdict. " +
1799
2068
  "Use this when you need to wait for CI to complete and see the final result.",
1800
2069
  promptSnippet: "Watch and wait for GitHub PR CI checks to complete",
1801
2070
  parameters: Type.Object({
@@ -1814,121 +2083,84 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1814
2083
  details: {},
1815
2084
  });
1816
2085
 
1817
- // 轮询 `gh pr checks --json`(每轮经 onUpdate 流式输出 GitHub Web UI 风格
1818
- // 的检查表),结束后再用 Actions API 抓取该 PR head 提交关联的所有
1819
- // workflow job,以 job 的真实 conclusion 为准判断成功/失败。
1820
- await pollPrChecks({
1821
- prNumber: number,
1822
- repo,
1823
- failFast: fail_fast === true,
1824
- cwd: ctx.cwd,
1825
- signal,
1826
- onUpdate,
1827
- });
1828
-
1829
2086
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
2087
+ const { owner, repo: repoName } = splitRepo(effectiveRepo);
2088
+
1830
2089
  const prOut = await ghExec(
1831
2090
  ["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
1832
2091
  { cwd: ctx.cwd, signal, input: params },
1833
2092
  );
1834
2093
  const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
1835
2094
 
2095
+ return waitChecksReport({
2096
+ subject: `PR #${number}`,
2097
+ owner,
2098
+ repo: repoName,
2099
+ headSha: headRefOid,
2100
+ failFast: fail_fast === true,
2101
+ signal,
2102
+ onUpdate,
2103
+ params,
2104
+ pendant,
2105
+ });
2106
+ },
2107
+ });
2108
+
2109
+ // ── wait-github-commit-checks ──────────────────────────────────────────────
2110
+ pi.registerTool({
2111
+ name: "wait-github-commit-checks",
2112
+ label: "Watch GitHub Commit Checks",
2113
+ description:
2114
+ "Watch CI status checks for a commit until they complete — no pull request required. " +
2115
+ "Same semantics as wait-github-pr-checks: returns when any check fails (immediately under fail_fast) " +
2116
+ "or all checks pass/skip; on timeout the still-in-flight snapshot is returned. " +
2117
+ "With `event`, only check runs triggered by that workflow event (e.g. push) are judged; " +
2118
+ "commit statuses have an unknown trigger event and are excluded under a filter. " +
2119
+ "Use this to wait for the runs a commit's push triggered, or for checks on an arbitrary ref.",
2120
+ promptSnippet: "Watch and wait for GitHub commit CI checks to complete",
2121
+ parameters: Type.Object({
2122
+ commit: Type.Union([Type.Number(), Type.String()], {
2123
+ description:
2124
+ "Commit to wait for: full or partial SHA, branch name, or tag name (resolved to the commit's SHA)",
2125
+ }),
2126
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
2127
+ event: Type.Optional(
2128
+ Type.String({
2129
+ description:
2130
+ "Only judge check runs triggered by this workflow event (e.g. push, pull_request)",
2131
+ }),
2132
+ ),
2133
+ fail_fast: Type.Optional(
2134
+ Type.Boolean({ description: "Exit immediately when any check fails (default: false)" }),
2135
+ ),
2136
+ }),
2137
+ async execute(_id, params, signal, onUpdate, ctx) {
2138
+ const { commit, repo, event, fail_fast } = params;
2139
+
2140
+ const pendant = subtitlePendant(params, "commit");
1836
2141
  onUpdate?.({
1837
- content: [{ type: "text", text: `Fetching workflow jobs for PR #${number}...` }],
2142
+ content: [{ type: "text", text: `Watching CI checks for commit ${commit}...` }],
1838
2143
  details: {},
1839
2144
  });
1840
2145
 
1841
- const runsOut = await ghExec(
1842
- ["api", `/repos/${effectiveRepo}/actions/runs?head_sha=${headRefOid}&per_page=100`],
1843
- { cwd: ctx.cwd, signal, input: params },
1844
- );
1845
- const { workflow_runs } = Value.Parse(workflowRunsSchema, JSON.parse(runsOut));
1846
-
1847
- const failedJobs: {
1848
- runId: number;
1849
- runName: string;
1850
- runUrl: string;
1851
- jobId: number;
1852
- jobName: string;
1853
- conclusion: string;
1854
- jobUrl?: string;
1855
- }[] = [];
1856
- let totalJobs = 0;
1857
-
1858
- for (const run of workflow_runs) {
1859
- const jobsOut = await ghExec(
1860
- ["api", `/repos/${effectiveRepo}/actions/runs/${run.id}/jobs?per_page=100`],
1861
- { cwd: ctx.cwd, signal, input: params },
1862
- );
1863
- const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
1864
- totalJobs += jobs.length;
1865
-
1866
- for (const job of jobs) {
1867
- if (!job.conclusion || FAILED_JOB_CONCLUSIONS.has(job.conclusion)) {
1868
- failedJobs.push({
1869
- runId: run.id,
1870
- runName: run.name,
1871
- runUrl: run.html_url,
1872
- jobId: job.id,
1873
- jobName: job.name,
1874
- conclusion: job.conclusion ?? "in_progress",
1875
- jobUrl: job.html_url,
1876
- });
1877
- }
1878
- }
1879
- }
2146
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
2147
+ const { owner, repo: repoName } = splitRepo(effectiveRepo);
1880
2148
 
1881
- if (totalJobs === 0) {
1882
- return {
1883
- content: [
1884
- {
1885
- type: "text",
1886
- text: `## PR #${number} CI Checks\n\nNo workflow runs found for head commit ${headRefOid.slice(0, 7)}.`,
1887
- },
1888
- ],
1889
- details: {
1890
- status: "no-jobs",
1891
- totalJobs: 0,
1892
- input: params,
1893
- ...(pendant && { pendant }),
1894
- },
1895
- };
1896
- }
2149
+ const pollSignal = signal ?? new AbortController().signal;
2150
+ const sha = await githubChecks.headSha(owner, repoName, String(commit), pollSignal);
1897
2151
 
1898
- if (failedJobs.length > 0) {
1899
- const lines = failedJobs.map(
1900
- (j) =>
1901
- `- ${statusIcon(j.conclusion)} **${j.jobName}** (${j.conclusion}) — [job #${j.jobId}](${j.jobUrl ?? j.runUrl})\n` +
1902
- ` - workflow: [${j.runName} (#${j.runId})](${j.runUrl})`,
1903
- );
1904
- return {
1905
- content: [
1906
- {
1907
- type: "text",
1908
- text:
1909
- `## PR #${number} CI Checks - FAILED\n\n` +
1910
- `${failedJobs.length} of ${totalJobs} job(s) did not succeed:\n\n${lines.join("\n")}`,
1911
- },
1912
- ],
1913
- details: {
1914
- status: "failure",
1915
- totalJobs,
1916
- failedJobs,
1917
- input: params,
1918
- ...(pendant && { pendant }),
1919
- },
1920
- };
1921
- }
1922
-
1923
- return {
1924
- content: [
1925
- {
1926
- type: "text",
1927
- text: `## PR #${number} CI Checks - PASSED\n\nAll ${totalJobs} job(s) succeeded.`,
1928
- },
1929
- ],
1930
- details: { status: "success", totalJobs, input: params, ...(pendant && { pendant }) },
1931
- };
2152
+ return waitChecksReport({
2153
+ subject: `commit ${sha.slice(0, 7)}`,
2154
+ owner,
2155
+ repo: repoName,
2156
+ headSha: sha,
2157
+ failFast: fail_fast === true,
2158
+ event,
2159
+ signal,
2160
+ onUpdate,
2161
+ params,
2162
+ pendant,
2163
+ });
1932
2164
  },
1933
2165
  });
1934
2166
 
package/src/lib/github.ts CHANGED
@@ -243,16 +243,18 @@ function describeHttpError(status: number | undefined): string {
243
243
  return `GitHub API error${status === undefined ? "" : ` (HTTP ${status})`}`;
244
244
  }
245
245
 
246
- export interface GithubSearch {
247
- search(kind: SearchKind, params: SearchParams): Promise<SearchHit[]>;
246
+ export interface GithubApi {
247
+ /** Run an octokit request; retries once with a fresh token on 401. */
248
+ call<T>(fn: (octokit: Octokit) => Promise<T>): Promise<T>;
248
249
  }
249
250
 
250
251
  /**
251
- * Create a search client. The octokit instance (and its auth token) is cached
252
- * in the returned closure, so repeated searches reuse the same client without
253
- * module-level state.
252
+ * Create a shared octokit accessor. The client (and its auth token) is cached
253
+ * in the returned closure, so repeated calls reuse the same client without
254
+ * module-level state. A stale cached token can produce 401s; the cache is
255
+ * dropped and the request retried once in that case.
254
256
  */
255
- export function createGithubSearch(): GithubSearch {
257
+ export function createGithubApi(): GithubApi {
256
258
  let client: Octokit | undefined;
257
259
 
258
260
  async function getClient(): Promise<Octokit> {
@@ -261,34 +263,222 @@ export function createGithubSearch(): GithubSearch {
261
263
  }
262
264
 
263
265
  return {
264
- async search(kind, params) {
265
- const limit = Math.min(Math.max(params.limit ?? 30, 1), 100);
266
- const effective = { ...params, limit };
267
-
266
+ async call(fn) {
268
267
  for (let attempt = 0; ; attempt += 1) {
269
268
  try {
270
- const octokit = await getClient();
271
- if (effective.assignee === "@me") {
272
- const { data } = await octokit.rest.users.getAuthenticated();
273
- effective.assignee = data.login;
274
- }
275
- const q = buildSearchQuery(kind, effective);
276
- const { data } = await octokit.rest.search.issuesAndPullRequests({
277
- q,
278
- per_page: limit,
279
- });
280
- return data.items.map((item) => normalize(item as unknown as RawSearchItem));
269
+ return await fn(await getClient());
281
270
  } catch (error) {
282
271
  const status = (error as { status?: number }).status;
283
- // A stale cached token can produce 401s; drop the cache and retry once.
284
272
  if (status === 401 && attempt === 0 && client) {
285
273
  client = undefined;
286
274
  continue;
287
275
  }
288
- const message = (error as { message?: string }).message ?? String(error);
289
- throw new GithubSearchError(`${describeHttpError(status)}: ${message}`, params, status);
276
+ throw error;
290
277
  }
291
278
  }
292
279
  },
293
280
  };
294
281
  }
282
+
283
+ export interface GithubSearch {
284
+ search(kind: SearchKind, params: SearchParams): Promise<SearchHit[]>;
285
+ }
286
+
287
+ /**
288
+ * Create a search client backed by a cached octokit instance.
289
+ */
290
+ export function createGithubSearch(): GithubSearch {
291
+ const api = createGithubApi();
292
+
293
+ return {
294
+ async search(kind, params) {
295
+ const limit = Math.min(Math.max(params.limit ?? 30, 1), 100);
296
+ const effective = { ...params, limit };
297
+
298
+ try {
299
+ const octokit = await api.call(async (client) => {
300
+ if (effective.assignee === "@me") {
301
+ const { data } = await client.rest.users.getAuthenticated();
302
+ effective.assignee = data.login;
303
+ }
304
+ return client;
305
+ });
306
+ const q = buildSearchQuery(kind, effective);
307
+ const { data } = await octokit.rest.search.issuesAndPullRequests({
308
+ q,
309
+ per_page: limit,
310
+ });
311
+ return data.items.map((item) => normalize(item as unknown as RawSearchItem));
312
+ } catch (error) {
313
+ const status = (error as { status?: number }).status;
314
+ const message = (error as { message?: string }).message ?? String(error);
315
+ throw new GithubSearchError(`${describeHttpError(status)}: ${message}`, params, status);
316
+ }
317
+ },
318
+ };
319
+ }
320
+
321
+ /** One entry of the combined status API for a commit (classic commit status). */
322
+ export interface CommitStatus {
323
+ readonly context: string;
324
+ readonly state: string;
325
+ readonly targetUrl: string | null;
326
+ }
327
+
328
+ /** One check run of the check-runs API for a commit (GitHub Actions, GitHub Apps). */
329
+ export interface CheckRun {
330
+ readonly name: string;
331
+ readonly status: string;
332
+ readonly conclusion: string | null;
333
+ readonly startedAt: string | null;
334
+ readonly url: string | null;
335
+ /** Triggering workflow event (push, pull_request, ...); null when unknown. */
336
+ readonly event: string | null;
337
+ }
338
+
339
+ /** One Actions job flattened with its workflow run metadata. */
340
+ export interface ActionJob {
341
+ readonly runId: number;
342
+ readonly runName: string;
343
+ readonly runUrl: string;
344
+ readonly jobId: number;
345
+ readonly jobName: string;
346
+ readonly conclusion: string | null;
347
+ readonly jobUrl?: string;
348
+ }
349
+
350
+ export interface GithubChecksClient {
351
+ statuses(
352
+ owner: string,
353
+ repo: string,
354
+ ref: string,
355
+ signal: AbortSignal,
356
+ ): Promise<readonly CommitStatus[]>;
357
+ checkRuns(
358
+ owner: string,
359
+ repo: string,
360
+ ref: string,
361
+ signal: AbortSignal,
362
+ ): Promise<readonly CheckRun[]>;
363
+ /** All Actions jobs across the workflow runs of one head commit. */
364
+ actionJobs(
365
+ owner: string,
366
+ repo: string,
367
+ headSha: string,
368
+ signal: AbortSignal,
369
+ ): Promise<readonly ActionJob[]>;
370
+ /** Resolve a SHA, branch name, or tag name to the commit's full SHA. */
371
+ headSha(owner: string, repo: string, ref: string, signal: AbortSignal): Promise<string>;
372
+ }
373
+
374
+ /**
375
+ * Create a client for PR CI checks, backed by a cached octokit instance.
376
+ * Covers both check sources GitHub exposes for a commit — classic commit
377
+ * statuses (Azure DevOps, Jenkins, ...) and check runs (GitHub Actions,
378
+ * GitHub Apps) — so external CI is visible to the caller.
379
+ */
380
+ const ACTIONS_RUN_URL_RE = /\/actions\/runs\/(\d+)/;
381
+ export function createGithubChecks(): GithubChecksClient {
382
+ const api = createGithubApi();
383
+
384
+ return {
385
+ async statuses(owner, repo, ref, signal) {
386
+ const { data } = await api.call((octokit) =>
387
+ octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref, request: { signal } }),
388
+ );
389
+ return data.statuses.map((status) => ({
390
+ context: status.context,
391
+ state: status.state,
392
+ targetUrl: status.target_url,
393
+ }));
394
+ },
395
+
396
+ async checkRuns(owner, repo, ref, signal) {
397
+ const runs = await api.call((octokit) =>
398
+ octokit.paginate(octokit.rest.checks.listForRef, {
399
+ owner,
400
+ repo,
401
+ ref,
402
+ per_page: 100,
403
+ request: { signal },
404
+ }),
405
+ );
406
+ // The check run object itself carries no event field. Its details_url
407
+ // contains the workflow run id, and every run of this commit (push and
408
+ // pull_request events alike) shows up under actions/runs?head_sha=, so
409
+ // one extra request resolves run id -> event for the suffix display.
410
+ const runIds = new Set(
411
+ runs
412
+ .map((run) => ACTIONS_RUN_URL_RE.exec(run.details_url ?? "")?.[1])
413
+ .filter((id): id is string => id !== undefined),
414
+ );
415
+ const events = new Map<string, string>();
416
+ if (runIds.size > 0) {
417
+ const { data } = await api.call((octokit) =>
418
+ octokit.rest.actions.listWorkflowRunsForRepo({
419
+ owner,
420
+ repo,
421
+ head_sha: ref,
422
+ per_page: 100,
423
+ request: { signal },
424
+ }),
425
+ );
426
+ for (const run of data.workflow_runs) {
427
+ const id = String(run.id);
428
+ if (runIds.has(id)) events.set(id, run.event);
429
+ }
430
+ }
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
+ }));
439
+ },
440
+
441
+ async actionJobs(owner, repo, headSha, signal) {
442
+ const { data } = await api.call((octokit) =>
443
+ octokit.rest.actions.listWorkflowRunsForRepo({
444
+ owner,
445
+ repo,
446
+ head_sha: headSha,
447
+ per_page: 100,
448
+ request: { signal },
449
+ }),
450
+ );
451
+ const jobs: ActionJob[] = [];
452
+ for (const run of data.workflow_runs) {
453
+ const { data: jobsData } = await api.call((octokit) =>
454
+ octokit.rest.actions.listJobsForWorkflowRun({
455
+ owner,
456
+ repo,
457
+ run_id: run.id,
458
+ per_page: 100,
459
+ request: { signal },
460
+ }),
461
+ );
462
+ for (const job of jobsData.jobs) {
463
+ jobs.push({
464
+ runId: run.id,
465
+ runName: run.name ?? "",
466
+ runUrl: run.html_url,
467
+ jobId: job.id,
468
+ jobName: job.name,
469
+ conclusion: job.conclusion,
470
+ ...(job.html_url && { jobUrl: job.html_url }),
471
+ });
472
+ }
473
+ }
474
+ return jobs;
475
+ },
476
+
477
+ async headSha(owner, repo, ref, signal) {
478
+ const { data } = await api.call((octokit) =>
479
+ octokit.rest.repos.getCommit({ owner, repo, ref, request: { signal } }),
480
+ );
481
+ return data.sha;
482
+ },
483
+ };
484
+ }