@trim21/personal-pi-extensions 0.1.514 → 0.1.516

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/gh-readonly.ts +197 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.514",
3
+ "version": "0.1.516",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -35,7 +35,7 @@ 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 } from "typebox";
38
+ import { type Static, Type } from "typebox";
39
39
  import { Value } from "typebox/value";
40
40
 
41
41
  import { createGithubSearch, type GithubSearch, renderHits } from "./lib/github.js";
@@ -266,6 +266,23 @@ const workflowRunSchema = Type.Object({
266
266
 
267
267
  const workflowRunsSchema = Type.Object({ workflow_runs: Type.Array(workflowRunSchema) });
268
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
+
269
286
  function truncate(
270
287
  text: string,
271
288
  maxLines = 2000,
@@ -1111,6 +1128,174 @@ export async function writeLogFile(
1111
1128
  };
1112
1129
  }
1113
1130
 
1131
+ // ── pr checks watch (pure rendering + poll loop) ────────────────────────────
1132
+
1133
+ const PR_CHECKS_JSON_FIELDS = "name,state,bucket,startedAt,completedAt,link,workflow";
1134
+ const CHECKS_POLL_INTERVAL_MS = 30_000;
1135
+ const CHECKS_WATCH_DEADLINE_MS = 600_000;
1136
+
1137
+ function bucketIcon(bucket: string): string {
1138
+ switch (bucket) {
1139
+ case "pass": {
1140
+ return "✅";
1141
+ }
1142
+ case "fail": {
1143
+ return "❌";
1144
+ }
1145
+ case "skipping": {
1146
+ return "⏭️";
1147
+ }
1148
+ case "cancel": {
1149
+ return "🚫";
1150
+ }
1151
+ default: {
1152
+ return "🔄";
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ function formatClock(ms: number): string {
1158
+ const date = new Date(ms);
1159
+ return [date.getHours(), date.getMinutes(), date.getSeconds()]
1160
+ .map((n) => String(n).padStart(2, "0"))
1161
+ .join(":");
1162
+ }
1163
+
1164
+ function pad2(n: number): string {
1165
+ return String(n).padStart(2, "0");
1166
+ }
1167
+
1168
+ function formatDuration(totalSeconds: number): string {
1169
+ const seconds = Math.floor(totalSeconds);
1170
+ const h = Math.floor(seconds / 3600);
1171
+ const m = Math.floor((seconds % 3600) / 60);
1172
+ const s = seconds % 60;
1173
+ if (h > 0) return `${h}h${pad2(m)}m${pad2(s)}s`;
1174
+ if (m > 0) return `${m}m${pad2(s)}s`;
1175
+ return `${s}s`;
1176
+ }
1177
+
1178
+ /**
1179
+ * Render one polling round of `gh pr checks` like the GitHub web UI: check
1180
+ * name, workflow, status, start time and elapsed (total duration when the
1181
+ * check completed, time-so-far while still pending). Pure — no network.
1182
+ */
1183
+ export function renderPrChecksTable(options: {
1184
+ prNumber: number | string;
1185
+ round: number;
1186
+ checks: readonly PrCheck[];
1187
+ now: number;
1188
+ }): string {
1189
+ const { prNumber, round, checks, now } = options;
1190
+ const completed = checks.filter((c) => c.bucket !== "pending").length;
1191
+
1192
+ const rows = checks.map((check) => {
1193
+ const name = check.link ? `[${check.name}](${check.link})` : check.name;
1194
+ let elapsed = "—";
1195
+ if (check.startedAt) {
1196
+ const startMs = Date.parse(check.startedAt);
1197
+ const endMs = check.completedAt ? Date.parse(check.completedAt) : now;
1198
+ elapsed = formatDuration(Math.max(0, (endMs - startMs) / 1000));
1199
+ }
1200
+ const cells = [
1201
+ `${bucketIcon(check.bucket)} ${name}`,
1202
+ check.workflow ?? "—",
1203
+ check.bucket,
1204
+ check.startedAt ? formatClock(Date.parse(check.startedAt)) : "—",
1205
+ elapsed,
1206
+ ];
1207
+ return `| ${cells.join(" | ")} |`;
1208
+ });
1209
+ const body = rows.length > 0 ? rows.join("\n") : "| _no checks reported_ | — | — | — | — |";
1210
+
1211
+ return (
1212
+ `### PR #${prNumber} checks — round ${round}: ${completed}/${checks.length} complete\n\n` +
1213
+ `| Check | Workflow | Status | Started | Elapsed |\n` +
1214
+ `|---|---|---|---|---|\n` +
1215
+ body
1216
+ );
1217
+ }
1218
+
1219
+ function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promise<void> {
1220
+ return new Promise((resolve, reject) => {
1221
+ const onAbort = () => {
1222
+ clearTimeout(timer);
1223
+ reject(new Error("aborted while waiting for the next checks poll"));
1224
+ };
1225
+ const timer = setTimeout(() => {
1226
+ signal?.removeEventListener("abort", onAbort);
1227
+ resolve();
1228
+ }, ms);
1229
+ if (signal?.aborted) {
1230
+ onAbort();
1231
+ return;
1232
+ }
1233
+ signal?.addEventListener("abort", onAbort, { once: true });
1234
+ });
1235
+ }
1236
+
1237
+ export interface PollPrChecksOptions {
1238
+ prNumber: number | string;
1239
+ repo?: string;
1240
+ failFast: boolean;
1241
+ cwd?: string;
1242
+ signal?: AbortSignal;
1243
+ /** Test overrides. */
1244
+ intervalMs?: number;
1245
+ deadlineMs?: number;
1246
+ onUpdate?: (msg: CiLogsResult) => void;
1247
+ }
1248
+
1249
+ /**
1250
+ * Poll `gh pr checks --json` until no check is pending (or a fail-fast
1251
+ * failure, or the deadline), emitting a GitHub-UI-like table via `onUpdate`
1252
+ * each round. In JSON mode gh exits 0 whenever it could fetch the checks —
1253
+ * completion is judged from the `bucket` field, not the exit code. A non-zero
1254
+ * exit (no checks reported, auth, network) is not fatal here: the caller
1255
+ * proceeds to the Actions API verification, which either produces the final
1256
+ * report or surfaces the error.
1257
+ */
1258
+ export async function pollPrChecks(options: PollPrChecksOptions): Promise<void> {
1259
+ const { prNumber, repo, failFast, cwd, signal, onUpdate } = options;
1260
+ const intervalMs = options.intervalMs ?? CHECKS_POLL_INTERVAL_MS;
1261
+ const deadlineMs = options.deadlineMs ?? CHECKS_WATCH_DEADLINE_MS;
1262
+ const args = [
1263
+ "pr",
1264
+ "checks",
1265
+ String(prNumber),
1266
+ ...repoArgs(repo),
1267
+ "--json",
1268
+ PR_CHECKS_JSON_FIELDS,
1269
+ ];
1270
+
1271
+ const watchStart = Date.now();
1272
+ for (let round = 1; ; round++) {
1273
+ const result = await runGh(args, { cwd, signal });
1274
+ if (result.killed) {
1275
+ throw new Error(
1276
+ result.reason === "timeout" ? "gh pr checks poll timed out" : "gh pr checks was aborted",
1277
+ );
1278
+ }
1279
+ if (result.code !== 0) {
1280
+ return;
1281
+ }
1282
+ const checks: PrCheck[] = Value.Parse(Type.Array(prCheckSchema), JSON.parse(result.stdout));
1283
+
1284
+ onUpdate?.({
1285
+ content: [
1286
+ { type: "text", text: renderPrChecksTable({ prNumber, round, checks, now: Date.now() }) },
1287
+ ],
1288
+ details: {},
1289
+ });
1290
+
1291
+ const hasPending = checks.some((c) => c.bucket === "pending");
1292
+ if (!hasPending) return;
1293
+ if (failFast && checks.some((c) => c.bucket === "fail")) return;
1294
+ if (Date.now() - watchStart >= deadlineMs) return;
1295
+ await sleepInterruptibly(intervalMs, signal);
1296
+ }
1297
+ }
1298
+
1114
1299
  // ── tools ────────────────────────────────────────────────────────────────────
1115
1300
 
1116
1301
  export default function ghReadonlyTools(pi: ExtensionAPI) {
@@ -1667,6 +1852,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1667
1852
  label: "Watch GitHub PR Checks",
1668
1853
  description:
1669
1854
  "Watch CI status checks for a PR until they complete. Blocks until all checks finish or one fails. " +
1855
+ "Each polling round streams a GitHub-UI-like check table (name, workflow, status, started, elapsed) via onUpdate. " +
1670
1856
  "Use this when you need to wait for CI to complete and see the final result.",
1671
1857
  promptSnippet: "Watch and wait for GitHub PR CI checks to complete",
1672
1858
  parameters: Type.Object({
@@ -1685,21 +1871,17 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1685
1871
  details: {},
1686
1872
  });
1687
1873
 
1688
- const args = ["pr", "checks", String(number), ...repoArgs(repo), "--watch"];
1689
- if (fail_fast) args.push("--fail-fast");
1690
-
1691
- // gh 的退出码语义不可靠:checks 失败时返回 exit 1(SilentError),挂起时
1692
- // 返回 exit 8(PendingError),且失败详情只在非结构化的 stdout 表格里。
1693
- // 因此 watch 退出后直接用 Actions API 抓取该 PR head 提交关联的所有
1874
+ // 轮询 `gh pr checks --json`(每轮经 onUpdate 流式输出 GitHub Web UI 风格
1875
+ // 的检查表),结束后再用 Actions API 抓取该 PR head 提交关联的所有
1694
1876
  // workflow job,以 job 的真实 conclusion 为准判断成功/失败。
1695
- const result = await runGh(args, { cwd: ctx.cwd, signal, timeout: 600_000 });
1696
- if (result.killed) {
1697
- throw new Error(
1698
- result.reason === "timeout"
1699
- ? "gh pr checks --watch timed out after 10 minutes"
1700
- : "gh pr checks --watch was aborted",
1701
- );
1702
- }
1877
+ await pollPrChecks({
1878
+ prNumber: number,
1879
+ repo,
1880
+ failFast: fail_fast === true,
1881
+ cwd: ctx.cwd,
1882
+ signal,
1883
+ onUpdate,
1884
+ });
1703
1885
 
1704
1886
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1705
1887
  const prOut = await ghExec(