@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 +1 -1
- package/src/gh-readonly.ts +298 -176
- package/src/lib/github.ts +205 -24
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -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 {
|
|
38
|
+
import { Type } from "typebox";
|
|
39
39
|
import { Value } from "typebox/value";
|
|
40
40
|
|
|
41
|
-
import {
|
|
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
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1259
|
+
owner: string;
|
|
1260
|
+
repo: string;
|
|
1261
|
+
headSha: string;
|
|
1184
1262
|
failFast: boolean;
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* checks
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
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<
|
|
1204
|
-
const { prNumber, repo, failFast,
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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 (
|
|
1225
|
-
|
|
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
|
-
|
|
1327
|
+
await sleepInterruptibly(intervalMs, signal);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1228
1330
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
|
1798
|
-
"
|
|
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
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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
|
|
247
|
-
|
|
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
|
|
252
|
-
* in the returned closure, so repeated
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
}
|