@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 +1 -1
- package/src/gh-readonly.ts +412 -180
- package/src/lib/github.ts +214 -24
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -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 {
|
|
39
|
+
import { Type } from "typebox";
|
|
39
40
|
import { Value } from "typebox/value";
|
|
40
41
|
|
|
41
|
-
import {
|
|
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
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
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
|
-
|
|
1220
|
+
/** Report subject, e.g. `PR #7` or `commit 5a7c407`. */
|
|
1221
|
+
subject: string;
|
|
1145
1222
|
round: number;
|
|
1146
|
-
checks: readonly
|
|
1223
|
+
checks: readonly MergedCheck[];
|
|
1147
1224
|
}): string {
|
|
1148
|
-
const {
|
|
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
|
|
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
|
|
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
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
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
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* checks
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
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<
|
|
1204
|
-
const {
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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 (
|
|
1225
|
-
|
|
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
|
-
|
|
1348
|
+
await sleepInterruptibly(intervalMs, signal);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1228
1351
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
|
1798
|
-
"
|
|
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: `
|
|
2142
|
+
content: [{ type: "text", text: `Watching CI checks for commit ${commit}...` }],
|
|
1838
2143
|
details: {},
|
|
1839
2144
|
});
|
|
1840
2145
|
|
|
1841
|
-
const
|
|
1842
|
-
|
|
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
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
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
|
|
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,222 @@ 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
|
-
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
|
+
}
|