@trim21/personal-pi-extensions 0.1.520 → 0.1.521

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.521",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -35,10 +35,19 @@ import { homedir } from "node:os";
35
35
  import { delimiter, dirname, join, resolve } from "node:path";
36
36
 
37
37
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
- import { type Static, Type } from "typebox";
38
+ import { Type } from "typebox";
39
39
  import { Value } from "typebox/value";
40
40
 
41
- import { createGithubSearch, type GithubSearch, renderHits } from "./lib/github.js";
41
+ import {
42
+ type ActionJob,
43
+ type CheckRun,
44
+ type CommitStatus,
45
+ createGithubChecks,
46
+ createGithubSearch,
47
+ type GithubChecksClient,
48
+ type GithubSearch,
49
+ renderHits,
50
+ } from "./lib/github.js";
42
51
  import { type ToolPendant } from "./lib/pendant.js";
43
52
  import { createSeqState } from "./lib/seq-state.js";
44
53
 
@@ -258,31 +267,6 @@ const jobsResponseSchema = Type.Object({ jobs: Type.Array(jobRunSchema) });
258
267
 
259
268
  const prHeadSchema = Type.Object({ headRefOid: Type.String() });
260
269
 
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
270
  function truncate(
287
271
  text: string,
288
272
  maxLines = 2000,
@@ -1130,20 +1114,102 @@ export async function writeLogFile(
1130
1114
 
1131
1115
  // ── pr checks watch (pure rendering + poll loop) ────────────────────────────
1132
1116
 
1133
- const PR_CHECKS_JSON_FIELDS = "name,state,bucket,startedAt,completedAt,link,workflow";
1134
1117
  const CHECKS_POLL_INTERVAL_MS = 30_000;
1135
1118
  const CHECKS_WATCH_DEADLINE_MS = 600_000;
1136
1119
 
1120
+ export type CheckBucket = "pass" | "skipped" | "fail" | "pending";
1121
+
1137
1122
  /**
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.
1123
+ * One judged CI check of a commit: a single commit status or check run, kept
1124
+ * distinct same-named checks from different sources (push vs pull_request
1125
+ * events, status vs check run channels) stay separate entries, like the
1126
+ * GitHub checks UI.
1127
+ */
1128
+ export interface MergedCheck {
1129
+ readonly name: string;
1130
+ readonly bucket: CheckBucket;
1131
+ readonly startedAt: string | null;
1132
+ readonly link: string | null;
1133
+ /** Triggering workflow event (push, pull_request, ...); null when unknown. */
1134
+ readonly event: string | null;
1135
+ }
1136
+
1137
+ function statusBucket(state: string): CheckBucket {
1138
+ if (state === "success") return "pass";
1139
+ if (state === "failure" || state === "error") return "fail";
1140
+ // pending, expected, and anything unknown must not end the wait
1141
+ return "pending";
1142
+ }
1143
+
1144
+ function checkRunBucket(run: CheckRun): CheckBucket {
1145
+ if (run.status !== "completed" || run.conclusion === null) return "pending";
1146
+ switch (run.conclusion) {
1147
+ case "success": {
1148
+ return "pass";
1149
+ }
1150
+ case "skipped":
1151
+ case "neutral":
1152
+ case "stale":
1153
+ case "action_required": {
1154
+ // awaiting maintainer approval: it will never run, so waiting for it is
1155
+ // meaningless — treat like skipped
1156
+ return "skipped";
1157
+ }
1158
+ case "failure":
1159
+ case "timed_out":
1160
+ case "cancelled":
1161
+ case "startup_failure": {
1162
+ return "fail";
1163
+ }
1164
+ default: {
1165
+ return "pending";
1166
+ }
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Judge the commit's statuses and check runs into individual checks, keeping
1172
+ * same-named entries distinct so the wait verdict (any fail / all
1173
+ * pass-or-skipped across every entry) can never lose a failure. Pure — no
1174
+ * network.
1175
+ */
1176
+ export function mergeChecks(
1177
+ statuses: readonly CommitStatus[],
1178
+ checkRuns: readonly CheckRun[],
1179
+ ): MergedCheck[] {
1180
+ return [
1181
+ ...statuses.map((status) => ({
1182
+ name: status.context,
1183
+ bucket: statusBucket(status.state),
1184
+ startedAt: null,
1185
+ link: status.targetUrl,
1186
+ event: null,
1187
+ })),
1188
+ ...checkRuns.map((run) => ({
1189
+ name: run.name,
1190
+ bucket: checkRunBucket(run),
1191
+ startedAt: run.startedAt,
1192
+ link: run.url,
1193
+ event: run.event,
1194
+ })),
1195
+ ];
1196
+ }
1197
+
1198
+ /** Display name of a check; the trigger event is labelled like the GitHub UI (`build (pull_request)`). */
1199
+ export function checkDisplayName(check: MergedCheck): string {
1200
+ return check.event ? `${check.name} (${check.event})` : check.name;
1201
+ }
1202
+
1203
+ /**
1204
+ * Render one polling round as a compact bullet list of the checks still in
1205
+ * flight: running ones first (`- [>]`), queued ones after (`- [ ]`). Completed
1206
+ * checks are hidden — the header already reports the completion count.
1207
+ * Pure — no network.
1142
1208
  */
1143
1209
  export function renderPrChecksList(options: {
1144
1210
  prNumber: number | string;
1145
1211
  round: number;
1146
- checks: readonly PrCheck[];
1212
+ checks: readonly MergedCheck[];
1147
1213
  }): string {
1148
1214
  const { prNumber, round, checks } = options;
1149
1215
  const completed = checks.filter((c) => c.bucket !== "pending").length;
@@ -1151,13 +1217,15 @@ export function renderPrChecksList(options: {
1151
1217
  const pending = checks.filter((c) => c.bucket === "pending");
1152
1218
  const ordered = [...pending.filter((c) => c.startedAt), ...pending.filter((c) => !c.startedAt)];
1153
1219
  const lines = ordered.map((check) => {
1154
- const name = check.link ? `[${check.name}](${check.link})` : check.name;
1220
+ const name = check.link
1221
+ ? `[${checkDisplayName(check)}](${check.link})`
1222
+ : checkDisplayName(check);
1155
1223
  return `- [${check.startedAt ? ">" : " "}] ${name}`;
1156
1224
  });
1157
1225
  const body =
1158
1226
  checks.length === 0 ? "- _no checks reported_" : lines.length > 0 ? lines.join("\n") : "";
1159
1227
 
1160
- return `### PR #${prNumber} checks — round ${round}: ${completed}/${checks.length} complete${body ? `\n\n${body}` : ""}`;
1228
+ return `PR #${prNumber} checks — round ${round}: ${completed}/${checks.length} complete${body ? `\n\n${body}` : ""}`;
1161
1229
  }
1162
1230
 
1163
1231
  function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promise<void> {
@@ -1178,12 +1246,23 @@ function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promis
1178
1246
  });
1179
1247
  }
1180
1248
 
1249
+ export type ChecksPollOutcome = "completed" | "fail_fast" | "timeout";
1250
+
1251
+ export interface ChecksPollResult {
1252
+ readonly outcome: ChecksPollOutcome;
1253
+ readonly checks: readonly MergedCheck[];
1254
+ readonly elapsedMs: number;
1255
+ }
1256
+
1181
1257
  export interface PollPrChecksOptions {
1182
1258
  prNumber: number | string;
1183
- repo?: string;
1259
+ owner: string;
1260
+ repo: string;
1261
+ headSha: string;
1184
1262
  failFast: boolean;
1185
- cwd?: string;
1186
- signal?: AbortSignal;
1263
+ checks: GithubChecksClient;
1264
+ /** Owned by the caller; the poll loop observes it but never aborts it. */
1265
+ signal: AbortSignal;
1187
1266
  /** Test overrides. */
1188
1267
  intervalMs?: number;
1189
1268
  deadlineMs?: number;
@@ -1191,52 +1270,154 @@ export interface PollPrChecksOptions {
1191
1270
  }
1192
1271
 
1193
1272
  /**
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.
1273
+ * Poll the commit's combined-status and check-runs APIs until the wait
1274
+ * semantics are met: return on any failure (immediately under fail-fast) or
1275
+ * when every check is complete (pass/skipped). Emits a compact list of
1276
+ * in-flight checks via `onUpdate` each round.
1277
+ *
1278
+ * A failed round (network, auth) does not end the wait — the error is kept
1279
+ * and polling continues, so a transient blip or a CI system that has not
1280
+ * reported anything yet cannot be mistaken for a completed check set. Only
1281
+ * when no round ever succeeded by the deadline is the last error thrown.
1202
1282
  */
1203
- export async function pollPrChecks(options: PollPrChecksOptions): Promise<void> {
1204
- const { prNumber, repo, failFast, cwd, signal, onUpdate } = options;
1283
+ export async function pollPrChecks(options: PollPrChecksOptions): Promise<ChecksPollResult> {
1284
+ const { prNumber, owner, repo, headSha, failFast, checks, signal, onUpdate } = options;
1205
1285
  const intervalMs = options.intervalMs ?? CHECKS_POLL_INTERVAL_MS;
1206
1286
  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
1287
 
1216
1288
  const watchStart = Date.now();
1289
+ let lastChecks: readonly MergedCheck[] = [];
1290
+ let lastError: unknown;
1291
+ let everSucceeded = false;
1292
+
1217
1293
  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
- );
1294
+ if (signal.aborted) throw new Error("PR checks polling was aborted");
1295
+ try {
1296
+ const [statuses, runs] = await Promise.all([
1297
+ checks.statuses(owner, repo, headSha, signal),
1298
+ checks.checkRuns(owner, repo, headSha, signal),
1299
+ ]);
1300
+ everSucceeded = true;
1301
+ lastChecks = mergeChecks(statuses, runs);
1302
+ onUpdate?.({
1303
+ content: [
1304
+ { type: "text", text: renderPrChecksList({ prNumber, round, checks: lastChecks }) },
1305
+ ],
1306
+ details: {},
1307
+ });
1308
+ if (lastChecks.every((c) => c.bucket !== "pending")) {
1309
+ return { outcome: "completed", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1310
+ }
1311
+ if (failFast && lastChecks.some((c) => c.bucket === "fail")) {
1312
+ return { outcome: "fail_fast", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1313
+ }
1314
+ } catch (error) {
1315
+ // 不用 if (signal.aborted):循环顶部的同名字段检查把它收窄成 false,
1316
+ // TS 会在 catch 里维持这个收窄。
1317
+ signal.throwIfAborted();
1318
+ lastError = error;
1223
1319
  }
1224
- if (result.code !== 0) {
1225
- return;
1320
+ if (Date.now() - watchStart >= deadlineMs) {
1321
+ if (!everSucceeded) {
1322
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
1323
+ throw new Error(`PR checks polling failed before any round succeeded: ${message}`);
1324
+ }
1325
+ return { outcome: "timeout", checks: lastChecks, elapsedMs: Date.now() - watchStart };
1226
1326
  }
1227
- const checks: PrCheck[] = Value.Parse(Type.Array(prCheckSchema), JSON.parse(result.stdout));
1327
+ await sleepInterruptibly(intervalMs, signal);
1328
+ }
1329
+ }
1228
1330
 
1229
- onUpdate?.({
1230
- content: [{ type: "text", text: renderPrChecksList({ prNumber, round, checks }) }],
1231
- details: {},
1232
- });
1331
+ /** One Actions job that did not succeed, for the FAILED report details. */
1332
+ export interface FailedActionJob {
1333
+ readonly runId: number;
1334
+ readonly runName: string;
1335
+ readonly runUrl: string;
1336
+ readonly jobId: number;
1337
+ readonly jobName: string;
1338
+ readonly conclusion: string;
1339
+ readonly jobUrl?: string;
1340
+ }
1233
1341
 
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);
1342
+ export interface ChecksVerdict {
1343
+ readonly status: "success" | "failure" | "pending";
1344
+ readonly text: string;
1345
+ readonly failedJobs: readonly FailedActionJob[];
1346
+ }
1347
+
1348
+ /**
1349
+ * Turn a poll result into the final report. Verdict comes from the checks
1350
+ * buckets alone (so external CI such as Azure counts); Actions jobs are
1351
+ * display-only enrichment. Pure — no network.
1352
+ */
1353
+ export function renderChecksVerdict(options: {
1354
+ prNumber: number | string;
1355
+ poll: ChecksPollResult;
1356
+ /** All Actions jobs of the head commit; failed/incomplete ones are listed. */
1357
+ actionJobs?: readonly ActionJob[];
1358
+ /** Set when the Actions job fetch failed; the verdict stays untouched. */
1359
+ enrichmentError?: string;
1360
+ }): ChecksVerdict {
1361
+ const { prNumber, poll, actionJobs, enrichmentError } = options;
1362
+ const totalChecks = poll.checks.length;
1363
+ const failed = poll.checks.filter((c) => c.bucket === "fail");
1364
+ const pending = poll.checks.filter((c) => c.bucket === "pending");
1365
+
1366
+ if (failed.length === 0 && poll.outcome === "completed") {
1367
+ return {
1368
+ status: "success",
1369
+ text: `## PR #${prNumber} CI Checks - PASSED\n\nAll ${totalChecks} check(s) passed.`,
1370
+ failedJobs: [],
1371
+ };
1239
1372
  }
1373
+
1374
+ if (failed.length > 0) {
1375
+ const failedJobs: FailedActionJob[] = (actionJobs ?? [])
1376
+ .filter((j) => !j.conclusion || FAILED_JOB_CONCLUSIONS.has(j.conclusion))
1377
+ .map((j) => ({
1378
+ runId: j.runId,
1379
+ runName: j.runName,
1380
+ runUrl: j.runUrl,
1381
+ jobId: j.jobId,
1382
+ jobName: j.jobName,
1383
+ conclusion: j.conclusion ?? "in_progress",
1384
+ ...(j.jobUrl && { jobUrl: j.jobUrl }),
1385
+ }));
1386
+
1387
+ const lines = failed.map(
1388
+ (c) =>
1389
+ `- ${statusIcon("failure")} **${checkDisplayName(c)}**${c.link ? ` — [view check](${c.link})` : ""}`,
1390
+ );
1391
+ for (const j of failedJobs) {
1392
+ lines.push(
1393
+ ` - ${statusIcon(j.conclusion)} job **${j.jobName}** (${j.conclusion}) — [job #${j.jobId}](${j.jobUrl ?? j.runUrl})`,
1394
+ ` - workflow: [${j.runName} (#${j.runId})](${j.runUrl})`,
1395
+ );
1396
+ }
1397
+ if (enrichmentError) lines.push(` - _Actions job details unavailable: ${enrichmentError}_`);
1398
+ if (pending.length > 0) lines.push(`\n_${pending.length} other check(s) still in flight._`);
1399
+
1400
+ return {
1401
+ status: "failure",
1402
+ text:
1403
+ `## PR #${prNumber} CI Checks - FAILED\n\n` +
1404
+ `${failed.length} of ${totalChecks} check(s) failed:\n\n${lines.join("\n")}`,
1405
+ failedJobs,
1406
+ };
1407
+ }
1408
+
1409
+ const waitedMinutes = Math.max(1, Math.round(poll.elapsedMs / 60_000));
1410
+ const pendingLines = pending.map(
1411
+ (c) => `- [${c.startedAt ? ">" : " "}] ${c.link ? `[${c.name}](${c.link})` : c.name}`,
1412
+ );
1413
+ return {
1414
+ status: "pending",
1415
+ text:
1416
+ `## PR #${prNumber} CI Checks - STILL IN FLIGHT\n\n` +
1417
+ `${pending.length} of ${totalChecks} check(s) still incomplete after ~${waitedMinutes}m:\n\n` +
1418
+ (pendingLines.length > 0 ? pendingLines.join("\n") : "- _no checks reported_"),
1419
+ failedJobs: [],
1420
+ };
1240
1421
  }
1241
1422
 
1242
1423
  // ── tools ────────────────────────────────────────────────────────────────────
@@ -1265,6 +1446,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1265
1446
  }
1266
1447
 
1267
1448
  const githubSearch = createGithubSearch();
1449
+ const githubChecks = createGithubChecks();
1268
1450
 
1269
1451
  // ── read-github-issue ──────────────────────────────────────────────────────
1270
1452
  pi.registerTool({
@@ -1794,8 +1976,10 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1794
1976
  name: "wait-github-pr-checks",
1795
1977
  label: "Watch GitHub PR Checks",
1796
1978
  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. " +
1979
+ "Watch CI status checks for a PR until they complete. Blocks until all checks pass (or are skipped) or one fails. " +
1980
+ "Covers both commit statuses (Azure DevOps, Jenkins, ...) and GitHub Actions check runs. " +
1981
+ "Each polling round streams a compact bullet list of the checks still in flight via onUpdate; " +
1982
+ "on timeout the still-in-flight snapshot is returned instead of a verdict. " +
1799
1983
  "Use this when you need to wait for CI to complete and see the final result.",
1800
1984
  promptSnippet: "Watch and wait for GitHub PR CI checks to complete",
1801
1985
  parameters: Type.Object({
@@ -1814,120 +1998,58 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1814
1998
  details: {},
1815
1999
  });
1816
2000
 
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
2001
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
2002
+ const slash = effectiveRepo.indexOf("/");
2003
+ if (slash <= 0 || slash === effectiveRepo.length - 1) {
2004
+ throw new Error(`invalid repository: ${effectiveRepo} (expected OWNER/REPO)`);
2005
+ }
2006
+ const owner = effectiveRepo.slice(0, slash);
2007
+ const repoName = effectiveRepo.slice(slash + 1);
2008
+
1830
2009
  const prOut = await ghExec(
1831
2010
  ["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
1832
2011
  { cwd: ctx.cwd, signal, input: params },
1833
2012
  );
1834
2013
  const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
1835
2014
 
1836
- onUpdate?.({
1837
- content: [{ type: "text", text: `Fetching workflow jobs for PR #${number}...` }],
1838
- details: {},
2015
+ // 轮询层要求非空 signal;框架可能不给时构造一个占位的(从不取消)。
2016
+ const pollSignal = signal ?? new AbortController().signal;
2017
+
2018
+ const poll = await pollPrChecks({
2019
+ prNumber: number,
2020
+ owner,
2021
+ repo: repoName,
2022
+ headSha: headRefOid,
2023
+ failFast: fail_fast === true,
2024
+ checks: githubChecks,
2025
+ signal: pollSignal,
2026
+ onUpdate,
1839
2027
  });
1840
2028
 
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
- }
2029
+ // Actions job 详情只做展示补充,不影响判定(判定来自 checks bucket,
2030
+ // 覆盖 Azure 等外部 CI)。抓取失败时降级为提示,不推翻结论。
2031
+ let actionJobs: readonly ActionJob[] | undefined;
2032
+ let enrichmentError: string | undefined;
2033
+ if (poll.checks.some((c) => c.bucket === "fail")) {
2034
+ try {
2035
+ actionJobs = await githubChecks.actionJobs(owner, repoName, headRefOid, pollSignal);
2036
+ } catch (error) {
2037
+ enrichmentError =
2038
+ error instanceof Error ? error.message : "Actions job details unavailable";
1878
2039
  }
1879
2040
  }
1880
2041
 
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
- }
1897
-
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
-
2042
+ const verdict = renderChecksVerdict({ prNumber: number, poll, actionJobs, enrichmentError });
1923
2043
  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 }) },
2044
+ content: [{ type: "text", text: verdict.text }],
2045
+ details: {
2046
+ status: verdict.status,
2047
+ totalChecks: poll.checks.length,
2048
+ checks: poll.checks,
2049
+ failedJobs: verdict.failedJobs,
2050
+ input: params,
2051
+ ...(pendant && { pendant }),
2052
+ },
1931
2053
  };
1932
2054
  },
1933
2055
  });
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,213 @@ 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;
277
+ }
278
+ }
279
+ },
280
+ };
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
+ }
371
+
372
+ /**
373
+ * Create a client for PR CI checks, backed by a cached octokit instance.
374
+ * Covers both check sources GitHub exposes for a commit — classic commit
375
+ * statuses (Azure DevOps, Jenkins, ...) and check runs (GitHub Actions,
376
+ * GitHub Apps) — so external CI is visible to the caller.
377
+ */
378
+ const ACTIONS_RUN_URL_RE = /\/actions\/runs\/(\d+)/;
379
+ export function createGithubChecks(): GithubChecksClient {
380
+ const api = createGithubApi();
381
+
382
+ return {
383
+ async statuses(owner, repo, ref, signal) {
384
+ const { data } = await api.call((octokit) =>
385
+ octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref, request: { signal } }),
386
+ );
387
+ return data.statuses.map((status) => ({
388
+ context: status.context,
389
+ state: status.state,
390
+ targetUrl: status.target_url,
391
+ }));
392
+ },
393
+
394
+ async checkRuns(owner, repo, ref, signal) {
395
+ const runs = await api.call((octokit) =>
396
+ octokit.paginate(octokit.rest.checks.listForRef, {
397
+ owner,
398
+ repo,
399
+ ref,
400
+ per_page: 100,
401
+ request: { signal },
402
+ }),
403
+ );
404
+ // The check run object itself carries no event field. Its details_url
405
+ // contains the workflow run id, and every run of this commit (push and
406
+ // pull_request events alike) shows up under actions/runs?head_sha=, so
407
+ // one extra request resolves run id -> event for the suffix display.
408
+ const runIds = new Set(
409
+ runs
410
+ .map((run) => ACTIONS_RUN_URL_RE.exec(run.details_url ?? "")?.[1])
411
+ .filter((id): id is string => id !== undefined),
412
+ );
413
+ const events = new Map<string, string>();
414
+ if (runIds.size > 0) {
415
+ const { data } = await api.call((octokit) =>
416
+ octokit.rest.actions.listWorkflowRunsForRepo({
417
+ owner,
418
+ repo,
419
+ head_sha: ref,
420
+ per_page: 100,
421
+ request: { signal },
422
+ }),
423
+ );
424
+ for (const run of data.workflow_runs) {
425
+ const id = String(run.id);
426
+ if (runIds.has(id)) events.set(id, run.event);
427
+ }
428
+ }
429
+ return runs.map((run) => ({
430
+ name: run.name,
431
+ status: run.status,
432
+ conclusion: run.conclusion,
433
+ startedAt: run.started_at,
434
+ url: run.html_url ?? run.details_url ?? null,
435
+ event: events.get(ACTIONS_RUN_URL_RE.exec(run.details_url ?? "")?.[1] ?? "") ?? null,
436
+ }));
437
+ },
438
+
439
+ async actionJobs(owner, repo, headSha, signal) {
440
+ const { data } = await api.call((octokit) =>
441
+ octokit.rest.actions.listWorkflowRunsForRepo({
442
+ owner,
443
+ repo,
444
+ head_sha: headSha,
445
+ per_page: 100,
446
+ request: { signal },
447
+ }),
448
+ );
449
+ const jobs: ActionJob[] = [];
450
+ for (const run of data.workflow_runs) {
451
+ const { data: jobsData } = await api.call((octokit) =>
452
+ octokit.rest.actions.listJobsForWorkflowRun({
453
+ owner,
454
+ repo,
455
+ run_id: run.id,
456
+ per_page: 100,
457
+ request: { signal },
458
+ }),
459
+ );
460
+ for (const job of jobsData.jobs) {
461
+ jobs.push({
462
+ runId: run.id,
463
+ runName: run.name ?? "",
464
+ runUrl: run.html_url,
465
+ jobId: job.id,
466
+ jobName: job.name,
467
+ conclusion: job.conclusion,
468
+ ...(job.html_url && { jobUrl: job.html_url }),
469
+ });
290
470
  }
291
471
  }
472
+ return jobs;
292
473
  },
293
474
  };
294
475
  }