cuelo 0.10.111 → 0.10.113

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 (73) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +36 -36
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  25. package/.next/server/app/api/subagents/eta/route.js +1 -1
  26. package/.next/server/app/api/updates/apply/route.js +2 -2
  27. package/.next/server/app/api/updates/route.js +1 -1
  28. package/.next/server/app/index.html +1 -1
  29. package/.next/server/app/index.rsc +4 -4
  30. package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  31. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  32. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  33. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  34. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  35. package/.next/server/app/page.js +4 -4
  36. package/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/.next/server/app/recover/page_client-reference-manifest.js +1 -1
  38. package/.next/server/app/recover.html +1 -1
  39. package/.next/server/app/recover.rsc +2 -2
  40. package/.next/server/app/recover.segments/_full.segment.rsc +2 -2
  41. package/.next/server/app/recover.segments/_head.segment.rsc +1 -1
  42. package/.next/server/app/recover.segments/_index.segment.rsc +2 -2
  43. package/.next/server/app/recover.segments/_tree.segment.rsc +1 -1
  44. package/.next/server/app/recover.segments/recover/__PAGE__.segment.rsc +1 -1
  45. package/.next/server/app/recover.segments/recover.segment.rsc +1 -1
  46. package/.next/server/app-paths-manifest.json +36 -36
  47. package/.next/server/chunks/205.js +1 -1
  48. package/.next/server/chunks/4975.js +1 -1
  49. package/.next/server/middleware-build-manifest.js +1 -1
  50. package/.next/server/pages/404.html +1 -1
  51. package/.next/server/pages/500.html +1 -1
  52. package/.next/server/server-reference-manifest.json +1 -1
  53. package/.next/static/chunks/app/{layout-45d8ab951d45db18.js → layout-f65c6505ac7e4f7b.js} +1 -1
  54. package/.next/static/chunks/app/{page-193c9b104bf28e1f.js → page-20cef200caf67724.js} +2 -2
  55. package/.next/trace +108 -108
  56. package/.next/trace-build +1 -1
  57. package/Tools/CUELO_Setup/files/native-runtime-patch.js +1 -1
  58. package/Tools/OMP_Global_Config/agent/AGENTS.md +1 -13
  59. package/Tools/OMP_Global_Config/agent/RULES.md +2 -3
  60. package/Tools/OMP_Global_Config/agent/extensions/command-guard/task-guard.ts +2 -2
  61. package/Tools/OMP_Global_Config/agent/extensions/draft.ts +4 -5
  62. package/Tools/OMP_Global_Config/agent/extensions/lib/cross-review.ts +6 -3
  63. package/Tools/OMP_Global_Config/agent/extensions/skim.ts +12 -5
  64. package/Tools/OMP_Global_Config/agent/extensions/slop-gate/review.ts +4 -2
  65. package/Tools/OMP_Global_Config/agent/prompt-copies/AGENTS.en.md +2 -14
  66. package/Tools/OMP_Global_Config/agent/prompt-copies/RULES.en.md +3 -4
  67. package/Tools/OMP_Global_Config/agent/rules/deploy-ops.md +1 -2
  68. package/Tools/OMP_Global_Config/agent/rules/omp-harness.md +2 -0
  69. package/package.json +1 -1
  70. package/Tools/OMP_Global_Config/agent/tools/ci-chain/index.ts +0 -1085
  71. package/Tools/OMP_Global_Config/agent/tools/ci-chain/paths-filter.ts +0 -130
  72. /package/.next/static/{fau9HLrjzBk2--ldl3rHA → -U-IFRIKLIBm-XAbrl-sF}/_buildManifest.js +0 -0
  73. /package/.next/static/{fau9HLrjzBk2--ldl3rHA → -U-IFRIKLIBm-XAbrl-sF}/_ssgManifest.js +0 -0
@@ -1,1085 +0,0 @@
1
- import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
2
- import { parsePushPathsFilter, pushStartsWorkflow } from "./paths-filter";
3
-
4
- export type CiChainLegState =
5
- | "completed"
6
- | "in_progress"
7
- | "pending"
8
- | "not-found-within-bound"
9
- | "skipped-no-public-change"
10
- | "skipped-paths-ignored"
11
- | "error";
12
-
13
- export interface CiChainMirrorParams {
14
- repo: string;
15
- workflows: string[];
16
- title?: string;
17
- }
18
-
19
- export interface CiChainParams {
20
- repo: string;
21
- sha: string;
22
- workflows: string[];
23
- mirror?: CiChainMirrorParams;
24
- waitSeconds?: number;
25
- pollSeconds?: number;
26
- }
27
-
28
- export interface CiChainLegResult {
29
- repo: string;
30
- workflow: string;
31
- runId: number | null;
32
- status: string | null;
33
- conclusion: string | null;
34
- url: string | null;
35
- matchedBy: "headSha" | "title";
36
- failedJobs?: string[];
37
- logTail?: string[];
38
- state: CiChainLegState;
39
- error?: string;
40
- }
41
-
42
- export interface CiChainResult {
43
- ok: boolean;
44
- repo: string;
45
- sha: string;
46
- mirrorTitle?: string | null;
47
- verdict: string;
48
- legs: CiChainLegResult[];
49
- polls: number;
50
- elapsedMs: number;
51
- }
52
-
53
- export interface ExecResult {
54
- code: number;
55
- stdout: string;
56
- stderr?: string;
57
- killed?: boolean;
58
- }
59
-
60
- export type Exec = (
61
- command: string,
62
- args: string[],
63
- options: { cwd: string; timeout?: number; signal?: AbortSignal },
64
- ) => Promise<ExecResult>;
65
-
66
- export interface CiChainDeps {
67
- exec: Exec;
68
- cwd: string;
69
- now: () => number;
70
- sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
71
- signal?: AbortSignal;
72
- }
73
-
74
- interface NormalizedRun {
75
- runId: number | null;
76
- workflow: string;
77
- status: string | null;
78
- conclusion: string | null;
79
- url: string | null;
80
- displayTitle: string;
81
- headSha: string;
82
- createdAt: string;
83
- updatedAt: string;
84
- rawJobs?: unknown[];
85
- rawFailedJobs?: string[];
86
- }
87
-
88
- const DEFAULT_WAIT_SECONDS = 0;
89
- const MAX_WAIT_SECONDS = 1800;
90
- const DEFAULT_POLL_SECONDS = 15;
91
- const MAX_POLL_SECONDS = 300;
92
- const LOG_TAIL_LINES = 40;
93
- const GH_TIMEOUT_MS = 120_000;
94
- const RUN_LIST_LIMIT = 100;
95
- const EXCLUDED_WORKFLOW = "Publish CUELO web release";
96
- const PUBLISH_MIRROR_WORKFLOW = "Publish public mirror";
97
- const NON_FAILED_CONCLUSIONS: Record<string, true> = {
98
- success: true,
99
- skipped: true,
100
- neutral: true,
101
- cancelled: true,
102
- };
103
- const RUN_LIST_FIELDS =
104
- "databaseId,workflowName,name,status,conclusion,url,displayTitle,headSha,createdAt,updatedAt";
105
-
106
- export function isFailedConclusion(conclusion: string | null | undefined): boolean {
107
- if (!conclusion) return false;
108
- return !NON_FAILED_CONCLUSIONS[conclusion.toLowerCase()];
109
- }
110
-
111
- /** GitHub cuts a run's displayTitle to 69 characters plus "…" (all 130 truncated public CI titles observed on 2026-10-27 are 70 characters long). */
112
- const TRUNCATED_TITLE_LENGTH = 70;
113
-
114
- /**
115
- * Matches a mirror workflow run's `displayTitle` against the private commit's subject line.
116
- * Only GitHub's own truncation counts as a prefix match; an ellipsis written inside a commit subject is literal text.
117
- */
118
- export function matchesRunTitle(displayTitle: string, expectedTitle: string): boolean {
119
- const actual = displayTitle.trim();
120
- const expected = expectedTitle.trim();
121
- if (!actual || !expected) return false;
122
- if (actual === expected) return true;
123
- // Count code points, not UTF-16 units, so an emoji in the subject does not change the measured cut.
124
- const actualLength = [...actual].length;
125
- return (
126
- actualLength === TRUNCATED_TITLE_LENGTH &&
127
- actual.endsWith("…") &&
128
- [...expected].length >= TRUNCATED_TITLE_LENGTH &&
129
- expected.startsWith(actual.slice(0, -1))
130
- );
131
- }
132
-
133
- function matchesSha(runSha: string, targetSha: string): boolean {
134
- const a = runSha.trim().toLowerCase();
135
- const b = targetSha.trim().toLowerCase();
136
- if (!a || !b) return false;
137
- return a === b || a.startsWith(b) || b.startsWith(a);
138
- }
139
-
140
- function normalizeRun(raw: Record<string, unknown>, repo: string): NormalizedRun {
141
- const rawId = raw.databaseId ?? raw.id ?? raw.runId;
142
- const runId = typeof rawId === "number" && Number.isFinite(rawId)
143
- ? rawId
144
- : typeof rawId === "string" && /^\d+$/.test(rawId)
145
- ? Number(rawId)
146
- : null;
147
- const workflow = String(raw.workflowName || raw.name || raw.workflow || "").trim();
148
- const status = typeof raw.status === "string" && raw.status.trim() ? raw.status.trim() : null;
149
- const conclusion = typeof raw.conclusion === "string" && raw.conclusion.trim() ? raw.conclusion.trim() : null;
150
- const rawUrl = typeof raw.url === "string" && raw.url.trim()
151
- ? raw.url.trim()
152
- : typeof raw.html_url === "string" && raw.html_url.trim()
153
- ? raw.html_url.trim()
154
- : null;
155
- const url = rawUrl ?? (runId !== null ? `https://github.com/${repo}/actions/runs/${runId}` : null);
156
- const displayTitle = String(raw.displayTitle ?? raw.title ?? raw.display_title ?? "").trim();
157
- const headSha = String(raw.headSha ?? raw.head_sha ?? raw.sha ?? "").trim();
158
- const createdAt = String(raw.createdAt ?? raw.created_at ?? "").trim();
159
- const updatedAt = String(raw.updatedAt ?? raw.updated_at ?? "").trim();
160
- const rawJobs = Array.isArray(raw.jobs) ? raw.jobs : undefined;
161
- const rawFailedJobs = Array.isArray(raw.failedJobs)
162
- ? raw.failedJobs.filter((j): j is string => typeof j === "string" && j.trim().length > 0)
163
- : undefined;
164
- return { runId, workflow, status, conclusion, url, displayTitle, headSha, createdAt, updatedAt, rawJobs, rawFailedJobs };
165
- }
166
-
167
- function pickNewestRun(runs: NormalizedRun[]): NormalizedRun | undefined {
168
- if (runs.length <= 1) return runs[0];
169
- const indexed = runs.map((run, index) => ({
170
- run,
171
- index,
172
- time: run.createdAt ? Date.parse(run.createdAt) : Number.NaN,
173
- }));
174
- indexed.sort((a, b) => {
175
- const aValid = !Number.isNaN(a.time);
176
- const bValid = !Number.isNaN(b.time);
177
- if (aValid && bValid && a.time !== b.time) return b.time - a.time;
178
- if (a.run.runId !== null && b.run.runId !== null && a.run.runId !== b.run.runId) {
179
- return b.run.runId - a.run.runId;
180
- }
181
- return a.index - b.index;
182
- });
183
- return indexed[0]?.run;
184
- }
185
-
186
- function splitLogLines(text: string): string[] {
187
- const cleaned = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
188
- if (!cleaned.trim()) return [];
189
- const lines = cleaned.split("\n");
190
- if (lines.at(-1) === "") lines.pop();
191
- return lines;
192
- }
193
-
194
- function extractTabJobNames(lines: string[]): string[] {
195
- const seen = new Set<string>();
196
- for (const line of lines) {
197
- const tabIdx = line.indexOf("\t");
198
- if (tabIdx > 0) {
199
- const jobName = line.slice(0, tabIdx).trim();
200
- if (jobName) seen.add(jobName);
201
- }
202
- }
203
- return [...seen];
204
- }
205
-
206
- function filterRequestedWorkflows(workflows: string[]): string[] {
207
- return workflows
208
- .map((w) => w.trim())
209
- .filter((w) => w.length > 0 && w !== EXCLUDED_WORKFLOW);
210
- }
211
-
212
- async function execGhJson<T>(
213
- deps: CiChainDeps,
214
- args: string[],
215
- label: string,
216
- ): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
217
- let res: ExecResult;
218
- try {
219
- res = await deps.exec("gh", args, { cwd: deps.cwd, signal: deps.signal });
220
- } catch (err) {
221
- return { ok: false, error: `${label}: exec failed (${err instanceof Error ? err.message : String(err)})` };
222
- }
223
- if (res.killed || res.code !== 0) {
224
- const detail = (res.stderr || res.stdout || "").trim() || "no output";
225
- return { ok: false, error: `${label}: gh ${res.killed ? "was killed (timeout)" : `exited ${res.code}`} (${detail})` };
226
- }
227
- const stdout = res.stdout.trim();
228
- if (!stdout) {
229
- return { ok: false, error: `${label}: gh returned empty stdout` };
230
- }
231
- try {
232
- return { ok: true, data: JSON.parse(stdout) as T };
233
- } catch (err) {
234
- return {
235
- ok: false,
236
- error: `${label}: unparsable JSON (${err instanceof Error ? err.message : String(err)})`,
237
- };
238
- }
239
- }
240
-
241
- async function fetchRunList(
242
- deps: CiChainDeps,
243
- repo: string,
244
- filter: { sha?: string; workflow?: string; createdSince?: string },
245
- ): Promise<{ ok: true; runs: NormalizedRun[]; pageFull: boolean } | { ok: false; error: string }> {
246
- const args = ["run", "list", "--repo", repo];
247
- if (filter.sha) args.push("--commit", filter.sha);
248
- if (filter.workflow) args.push("--workflow", filter.workflow);
249
- if (filter.createdSince) args.push("--created", `>=${filter.createdSince}`);
250
- args.push("--limit", String(RUN_LIST_LIMIT), "--json", RUN_LIST_FIELDS);
251
- const label = `gh run list (${repo}${filter.workflow ? `, ${filter.workflow}` : ""})`;
252
- const res = await execGhJson<unknown>(deps, args, label);
253
- if (!res.ok) return res;
254
- if (!Array.isArray(res.data)) {
255
- return { ok: false, error: `${label}: expected JSON array` };
256
- }
257
- const runs: NormalizedRun[] = [];
258
- for (const item of res.data) {
259
- if (typeof item !== "object" || item === null) return { ok: false, error: `${label}: run record is not an object` };
260
- const record = item as Record<string, unknown>;
261
- const run = normalizeRun(record, repo);
262
- // A record without identity, creation time or (for a commit query) head SHA cannot be matched; never guess with it.
263
- // An empty workflowName is real data (GitHub `startup_failure` runs carry none, e.g. on 53d26eb); such a run matches no workflow.
264
- if (typeof record.databaseId !== "number" || Number.isNaN(Date.parse(run.createdAt)) || (filter.sha && !run.headSha)) {
265
- return { ok: false, error: `${label}: run record lacks databaseId, createdAt or headSha` };
266
- }
267
- if (run.workflow && run.workflow !== EXCLUDED_WORKFLOW) runs.push(run);
268
- }
269
- return { ok: true, runs, pageFull: res.data.length >= RUN_LIST_LIMIT };
270
- }
271
-
272
- async function fetchCommitInfo(
273
- deps: CiChainDeps,
274
- repo: string,
275
- sha: string,
276
- ): Promise<{ ok: true; subject: string; fullSha?: string } | { ok: false; error: string }> {
277
- const res = await execGhJson<unknown>(
278
- deps,
279
- ["api", `repos/${repo}/commits/${sha}`],
280
- `gh api repos/${repo}/commits/${sha}`,
281
- );
282
- if (!res.ok) return res;
283
- const data = res.data as Record<string, unknown> | string | null;
284
- let message = "";
285
- let fullSha: string | undefined;
286
- if (typeof data === "string") {
287
- message = data;
288
- } else if (data && typeof data === "object") {
289
- const commitObj = data.commit as Record<string, unknown> | undefined;
290
- if (typeof commitObj?.message === "string") message = commitObj.message;
291
- else if (typeof data.message === "string") message = data.message;
292
- else if (typeof data.title === "string") message = data.title;
293
- if (typeof data.sha === "string" && /^[0-9a-f]{40}$/i.test(data.sha)) {
294
- fullSha = data.sha;
295
- }
296
- }
297
- const subject = message.split(/\r?\n/)[0]?.trim() ?? "";
298
- if (!subject) {
299
- return { ok: false, error: `gh api repos/${repo}/commits/${sha}: commit subject missing in response` };
300
- }
301
- return { ok: true, subject, fullSha };
302
- }
303
-
304
- type CommitFacts = {
305
- /** Files the commit changed (renames list both names), or null when they cannot stand for its diff: no files, or 300 or more. */
306
- paths: string[] | null;
307
- /** The only parent, or null for a root commit or a merge commit. */
308
- parent: string | null;
309
- };
310
-
311
- type PathsIgnoredCache = {
312
- /** `repo workflow sha` -> true once the commit was found to have been skipped by the workflow's filter. */
313
- decisions: Map<string, boolean>;
314
- /** `repo sha` -> what the commits API said; only successful lookups are kept. */
315
- commits: Map<string, CommitFacts>;
316
- /** `repo` -> workflow display name -> workflow file path, and whether the listing held every workflow of the repository. */
317
- workflowFiles?: Map<string, { byName: Map<string, string>; complete: boolean }>;
318
- /** `repo path ref` -> workflow file text; a file at a commit never changes, so a poll loop reads it once. */
319
- workflowTexts?: Map<string, string>;
320
- };
321
-
322
- /** GitHub lists at most 300 files per commit. */
323
- const COMMIT_FILE_LIMIT = 300;
324
-
325
- async function fetchCommitFacts(deps: CiChainDeps, repo: string, sha: string, cache: PathsIgnoredCache): Promise<CommitFacts | null> {
326
- const key = `${repo} ${sha}`;
327
- const known = cache.commits.get(key);
328
- if (known) return known;
329
- const res = await execGhJson<unknown>(deps, ["api", `repos/${repo}/commits/${sha}`], `gh api repos/${repo}/commits/${sha}`);
330
- if (!res.ok || typeof res.data !== "object" || res.data === null) return null;
331
- const parents: unknown = Reflect.get(res.data, "parents");
332
- const files: unknown = Reflect.get(res.data, "files");
333
- const onlyParent: unknown = Array.isArray(parents) && parents.length === 1 && typeof parents[0] === "object" && parents[0] !== null ? Reflect.get(parents[0], "sha") : undefined;
334
- const facts: CommitFacts = { paths: null, parent: typeof onlyParent === "string" && /^[0-9a-f]{40}$/i.test(onlyParent) ? onlyParent : null };
335
- if (Array.isArray(files) && files.length > 0 && files.length < COMMIT_FILE_LIMIT) {
336
- const collected: string[] = [];
337
- for (const file of files) {
338
- const name: unknown = typeof file === "object" && file !== null ? Reflect.get(file, "filename") : undefined;
339
- const previous: unknown = typeof file === "object" && file !== null ? Reflect.get(file, "previous_filename") : undefined;
340
- if (typeof name !== "string") {
341
- collected.length = 0;
342
- break;
343
- }
344
- collected.push(name);
345
- if (typeof previous === "string") collected.push(previous);
346
- }
347
- if (collected.length > 0) facts.paths = collected;
348
- }
349
- cache.commits.set(key, facts);
350
- return facts;
351
- }
352
-
353
- /** The repository's workflows by display name, or null when the listing could not be read. */
354
- async function fetchWorkflowList(deps: CiChainDeps, repo: string, cache: PathsIgnoredCache): Promise<{ byName: Map<string, string>; complete: boolean } | null> {
355
- const files = (cache.workflowFiles ??= new Map());
356
- const known = files.get(repo);
357
- if (known) return known;
358
- const list = await execGhJson<unknown>(deps, ["api", `repos/${repo}/actions/workflows?per_page=100`], `gh api repos/${repo}/actions/workflows`);
359
- const data: unknown = list.ok ? list.data : undefined;
360
- const workflows: unknown = typeof data === "object" && data !== null ? Reflect.get(data, "workflows") : undefined;
361
- // A failed or malformed listing is not cached, so the next poll asks again.
362
- if (typeof data !== "object" || data === null || !Array.isArray(workflows)) return null;
363
- const byName = new Map<string, string>();
364
- let valid = 0;
365
- for (const item of workflows) {
366
- const name: unknown = typeof item === "object" && item !== null ? Reflect.get(item, "name") : undefined;
367
- const path: unknown = typeof item === "object" && item !== null ? Reflect.get(item, "path") : undefined;
368
- if (typeof name === "string" && typeof path === "string") {
369
- byName.set(name, path);
370
- valid++;
371
- }
372
- }
373
- // Only a listing whose stated total equals the entries it holds, none of them unreadable, can prove a name absent.
374
- const total: unknown = Reflect.get(data, "total_count");
375
- const entry = { byName, complete: total === workflows.length && valid === workflows.length };
376
- files.set(repo, entry);
377
- return entry;
378
- }
379
-
380
- /**
381
- * An error message when the repository has no workflow by this display name, so no run can ever appear; undefined when it
382
- * has one or the listing is unreadable or partial (more than one page), in which case the leg is left to the usual checks.
383
- */
384
- async function unknownWorkflowError(deps: CiChainDeps, repo: string, workflow: string, cache: PathsIgnoredCache): Promise<string | undefined> {
385
- const list = await fetchWorkflowList(deps, repo, cache);
386
- if (!list || !list.complete || list.byName.has(workflow)) return undefined;
387
- return `${repo} has no workflow named "${workflow}" (a job or step name is not a workflow name); its workflows are: ${[...list.byName.keys()].join(", ")}`;
388
- }
389
-
390
- async function fetchWorkflowFile(deps: CiChainDeps, repo: string, workflow: string, sha: string, cache: PathsIgnoredCache): Promise<string | null> {
391
- const path = (await fetchWorkflowList(deps, repo, cache))?.byName.get(workflow);
392
- if (!path) return null;
393
- // The workflow file as of the pushed commit is the one GitHub evaluated.
394
- const textKey = `${repo} ${path} ${sha}`;
395
- const texts = (cache.workflowTexts ??= new Map());
396
- const cached = texts.get(textKey);
397
- if (cached !== undefined) return cached;
398
- const res = await execGhJson<unknown>(deps, ["api", `repos/${repo}/contents/${path}?ref=${sha}`], `gh api repos/${repo}/contents/${path}`);
399
- if (!res.ok || typeof res.data !== "object" || res.data === null) return null;
400
- const content: unknown = Reflect.get(res.data, "content");
401
- const encoding: unknown = Reflect.get(res.data, "encoding");
402
- if (typeof content !== "string" || encoding !== "base64") return null;
403
- const text = Buffer.from(content.replace(/\s/g, ""), "base64").toString("utf8");
404
- texts.set(textKey, text);
405
- return text;
406
- }
407
-
408
- /** How many consecutive commits without a run one verdict may explain; a longer unrun chain stays pending. */
409
- const MAX_UNRUN_COMMITS = 10;
410
-
411
- /**
412
- * True only when the workflow has a push path filter and GitHub never created a run for this commit because every file it
413
- * changed is filtered out. The commit's own diff is not the push's diff: a push of several commits is one diff over all of
414
- * them, so a doc-only tip can sit on top of a code commit that did start a run. The walk therefore goes back through parents
415
- * that have no run of this workflow, each of which must also be fully filtered, until it reaches a commit that has such a run
416
- * (an earlier push's tip, so the pushes are separate). A commit on the way that the filter lets through, a merge commit, an
417
- * oversized diff, an unreadable or unsupported filter, a failed lookup or a chain longer than MAX_UNRUN_COMMITS all end the
418
- * walk with false: the leg then stays pending, exactly as before, and is never reported as skipped on a guess.
419
- */
420
- async function isPushPathsIgnored(deps: CiChainDeps, repo: string, workflow: string, sha: string, cache: PathsIgnoredCache): Promise<boolean> {
421
- const key = `${repo} ${workflow} ${sha}`;
422
- if (cache.decisions.get(key)) return true;
423
- let ignored = false;
424
- try {
425
- const text = await fetchWorkflowFile(deps, repo, workflow, sha, cache);
426
- const filter = text === null ? undefined : parsePushPathsFilter(text);
427
- if (filter && (filter.paths || filter.pathsIgnore)) {
428
- let current = sha;
429
- for (let depth = 0; depth < MAX_UNRUN_COMMITS; depth++) {
430
- const facts = await fetchCommitFacts(deps, repo, current, cache);
431
- if (!facts || !facts.paths || !facts.parent) break;
432
- if (pushStartsWorkflow(filter, facts.paths)) break;
433
- const parentRuns = await fetchRunList(deps, repo, { sha: facts.parent });
434
- if (!parentRuns.ok) break;
435
- if (parentRuns.runs.some((run) => run.workflow === workflow && matchesSha(run.headSha, facts.parent!))) {
436
- ignored = true;
437
- break;
438
- }
439
- current = facts.parent;
440
- }
441
- }
442
- } catch {
443
- ignored = false;
444
- }
445
- // Only a verdict of "ignored" is kept; a "not ignored" may be a failed lookup and is asked again on the next poll.
446
- if (ignored) cache.decisions.set(key, true);
447
- return ignored;
448
- }
449
-
450
- type FailureDetails = { failedJobs: string[]; logTail: string[]; error?: string };
451
-
452
- async function fetchFailureDetails(
453
- deps: CiChainDeps,
454
- repo: string,
455
- run: NormalizedRun,
456
- cache: Map<string, FailureDetails>,
457
- ): Promise<FailureDetails> {
458
- // updatedAt changes when a run is re-attempted under the same databaseId, so a rerun is not served from an older attempt.
459
- const key = `${repo}#${run.runId}#${run.updatedAt}`;
460
- const cached = cache.get(key);
461
- if (cached) return cached;
462
-
463
- const failedJobNames = new Set<string>();
464
- const failedJobIds: number[] = [];
465
- const errors: string[] = [];
466
-
467
- const jobs = await execGhJson<{ jobs?: unknown }>(
468
- deps,
469
- ["run", "view", String(run.runId), "--repo", repo, "--json", "jobs"],
470
- `gh run view ${run.runId} --json jobs`,
471
- );
472
- if (!jobs.ok) errors.push(jobs.error);
473
- else if (!Array.isArray(jobs.data?.jobs)) errors.push(`gh run view ${run.runId} --json jobs: jobs is not an array`);
474
- else {
475
- for (const item of jobs.data.jobs) {
476
- const job = (item ?? {}) as Record<string, unknown>;
477
- if (typeof job.conclusion !== "string" || !isFailedConclusion(job.conclusion)) continue;
478
- if (typeof job.name === "string" && job.name.trim()) failedJobNames.add(job.name.trim());
479
- if (typeof job.databaseId === "number") failedJobIds.push(job.databaseId);
480
- }
481
- }
482
-
483
- let logTail: string[] = [];
484
- try {
485
- const logRes = await deps.exec("gh", ["run", "view", String(run.runId), "--repo", repo, "--log-failed"], { cwd: deps.cwd, signal: deps.signal });
486
- if (!logRes.killed && logRes.code === 0 && logRes.stdout.trim()) {
487
- const lines = splitLogLines(logRes.stdout);
488
- logTail = lines.slice(-LOG_TAIL_LINES);
489
- if (failedJobNames.size === 0) {
490
- for (const name of extractTabJobNames(lines)) failedJobNames.add(name);
491
- }
492
- } else if (logRes.killed || logRes.code !== 0) {
493
- errors.push(`gh run view ${run.runId} --log-failed ${logRes.killed ? "was killed (timeout)" : `exit ${logRes.code}`}: ${logRes.stderr?.trim() || "no stderr"}`);
494
- } else if (failedJobIds.length === 0) {
495
- errors.push(`gh run view ${run.runId} --log-failed printed nothing and no failed job id is known for the job-log fallback`);
496
- } else {
497
- // Runs older than about a day: --log-failed prints nothing, but each job's log is still served by the jobs API.
498
- const lines: string[] = [];
499
- for (const jobId of failedJobIds) {
500
- const jobLog = await readJobLog(deps, repo, jobId);
501
- if ("text" in jobLog) lines.push(...splitLogLines(jobLog.text));
502
- else errors.push(`log fallback: ${jobLog.error}`);
503
- }
504
- logTail = lines.slice(-LOG_TAIL_LINES);
505
- }
506
- } catch (error) {
507
- if (deps.signal?.aborted) throw error;
508
- errors.push(`failed log lookup for run ${run.runId}: ${error instanceof Error ? error.message : String(error)}`);
509
- }
510
-
511
- const details: FailureDetails = { failedJobs: [...failedJobNames], logTail, ...(errors.length ? { error: errors.join("; ") } : {}) };
512
- cache.set(key, details);
513
- return details;
514
- }
515
-
516
- /**
517
- * Detects whether a `Publish public mirror` run log printed "nothing to publish".
518
- * GitHub Actions prints each `run:` script between `##[group]Run …` and `##[endgroup]` before running it, so lines in that
519
- * block are script source (echo, printf or anything else), not output, and are skipped.
520
- */
521
- export function hasNothingToPublish(logText: string): boolean {
522
- let inScriptSource = false;
523
- for (const line of logText.split(/\r?\n/)) {
524
- if (line.includes("##[group]Run ")) {
525
- inScriptSource = true;
526
- continue;
527
- }
528
- if (inScriptSource) {
529
- if (line.includes("##[endgroup]")) inScriptSource = false;
530
- continue;
531
- }
532
- if (/nothing to publish/i.test(line)) return true;
533
- }
534
- return false;
535
- }
536
-
537
- type PublishLogCheck = { skipped: boolean } | { error: string };
538
-
539
- /**
540
- * Reads the publish run's log for the "nothing to publish" line. `gh run view --log` prints nothing for runs older than about a day,
541
- * so an empty log falls back to each job's log through the jobs API; a log that still cannot be read is an error, not "changed".
542
- */
543
- async function checkNothingToPublish(
544
- deps: CiChainDeps,
545
- repo: string,
546
- publishRun: NormalizedRun & { runId: number },
547
- cache: Map<string, PublishLogCheck>,
548
- ): Promise<PublishLogCheck> {
549
- const publishRunId = publishRun.runId;
550
- const key = `${repo}#${publishRunId}#${publishRun.updatedAt}`;
551
- const cached = cache.get(key);
552
- if (cached !== undefined) return cached;
553
-
554
- let result: PublishLogCheck;
555
- try {
556
- const res = await deps.exec(
557
- "gh",
558
- ["run", "view", String(publishRunId), "--repo", repo, "--log"],
559
- { cwd: deps.cwd, signal: deps.signal },
560
- );
561
- if (res.killed || res.code !== 0) {
562
- result = { error: `gh run view ${publishRunId} --log ${res.killed ? "was killed (timeout)" : `exit ${res.code}`}: ${res.stderr?.trim() || "no stderr"}` };
563
- } else if (res.stdout.trim()) {
564
- result = { skipped: hasNothingToPublish(res.stdout) };
565
- } else {
566
- result = await checkNothingToPublishByJobLogs(deps, repo, publishRunId);
567
- }
568
- } catch (error) {
569
- if (deps.signal?.aborted) throw error;
570
- result = { error: `publish log lookup for run ${publishRunId} failed: ${error instanceof Error ? error.message : String(error)}` };
571
- }
572
- cache.set(key, result);
573
- return result;
574
- }
575
-
576
- async function checkNothingToPublishByJobLogs(deps: CiChainDeps, repo: string, publishRunId: number): Promise<PublishLogCheck> {
577
- const label = `publish run ${publishRunId} (--log printed nothing)`;
578
- const jobs = await execGhJson<{ jobs?: Array<{ databaseId?: unknown }> }>(
579
- deps,
580
- ["run", "view", String(publishRunId), "--repo", repo, "--json", "jobs"],
581
- `${label} gh run view --json jobs`,
582
- );
583
- if (!jobs.ok) return { error: jobs.error };
584
- const jobIds = (Array.isArray(jobs.data?.jobs) ? jobs.data.jobs : [])
585
- .map((job) => job?.databaseId)
586
- .filter((id): id is number => typeof id === "number");
587
- if (jobIds.length === 0) return { error: `${label}: the run has no jobs` };
588
- // Read every job: one unreadable job must not hide another job's "nothing to publish", and is an error only if none says so.
589
- const unreadable: string[] = [];
590
- for (const jobId of jobIds) {
591
- const jobLog = await readJobLog(deps, repo, jobId);
592
- if ("error" in jobLog) {
593
- unreadable.push(jobLog.error);
594
- continue;
595
- }
596
- if (hasNothingToPublish(jobLog.text)) return { skipped: true };
597
- }
598
- return unreadable.length ? { error: `${label}: ${unreadable.join("; ")}` } : { skipped: false };
599
- }
600
-
601
- /** Reads one job's log through the jobs API; a failure of this job (exit, empty, kill or a thrown exec) never stops the caller's loop. */
602
- async function readJobLog(deps: CiChainDeps, repo: string, jobId: number): Promise<{ text: string } | { error: string }> {
603
- try {
604
- const res = await deps.exec("gh", ["api", `repos/${repo}/actions/jobs/${jobId}/logs`], { cwd: deps.cwd, signal: deps.signal });
605
- if (!res.killed && res.code === 0 && res.stdout.trim()) return { text: res.stdout };
606
- return { error: `job ${jobId} log ${res.killed ? "was killed (timeout)" : res.code === 0 ? "printed nothing" : `exit ${res.code}`}` };
607
- } catch (error) {
608
- if (deps.signal?.aborted) throw error;
609
- return { error: `job ${jobId} log lookup failed: ${error instanceof Error ? error.message : String(error)}` };
610
- }
611
- }
612
-
613
- async function buildLegFromRun(
614
- deps: CiChainDeps,
615
- repo: string,
616
- workflow: string,
617
- matchedBy: "headSha" | "title",
618
- run: NormalizedRun | undefined,
619
- failureCache: Map<string, FailureDetails>,
620
- ): Promise<CiChainLegResult> {
621
- if (!run) {
622
- return {
623
- repo,
624
- workflow,
625
- runId: null,
626
- status: null,
627
- conclusion: null,
628
- url: null,
629
- matchedBy,
630
- state: "pending",
631
- };
632
- }
633
- const completed = run.status === "completed";
634
- const state: CiChainLegState = completed ? "completed" : "in_progress";
635
- const leg: CiChainLegResult = {
636
- repo,
637
- workflow,
638
- runId: run.runId,
639
- status: run.status,
640
- conclusion: run.conclusion,
641
- url: run.url,
642
- matchedBy,
643
- state,
644
- };
645
- if (completed && isFailedConclusion(run.conclusion)) {
646
- const { failedJobs, logTail, error } = await fetchFailureDetails(deps, repo, run, failureCache);
647
- leg.failedJobs = failedJobs;
648
- leg.logTail = logTail;
649
- if (error) leg.error = error;
650
- }
651
- return leg;
652
- }
653
-
654
- function isLegTerminal(leg: CiChainLegResult): boolean {
655
- return (
656
- leg.state === "completed" ||
657
- leg.state === "skipped-no-public-change" ||
658
- leg.state === "skipped-paths-ignored" ||
659
- leg.state === "error" ||
660
- leg.state === "not-found-within-bound"
661
- );
662
- }
663
-
664
- function isLegOk(leg: CiChainLegResult): boolean {
665
- if (leg.state === "skipped-no-public-change" || leg.state === "skipped-paths-ignored") return true;
666
- if (leg.state !== "completed") return false;
667
- return leg.conclusion === "success" || leg.conclusion === "skipped" || leg.conclusion === "neutral";
668
- }
669
-
670
- export function buildVerdict(repo: string, sha: string, legs: CiChainLegResult[]): string {
671
- const shortSha = sha.slice(0, 7);
672
- const allOk = legs.length > 0 && legs.every(isLegOk);
673
- const summary = legs
674
- .map((leg) => {
675
- const prefix = leg.repo === repo ? leg.workflow : `${leg.repo}:${leg.workflow}`;
676
- if (leg.state === "completed") {
677
- const idPart = leg.runId !== null ? `(${leg.runId})` : "";
678
- const failPart = leg.failedJobs?.length ? ` [failed: ${leg.failedJobs.join(", ")}]` : "";
679
- return `${prefix}=${leg.conclusion ?? "completed"}${idPart}${failPart}`;
680
- }
681
- if (leg.state === "in_progress") {
682
- const idPart = leg.runId !== null ? `(${leg.runId})` : "";
683
- return `${prefix}=in_progress${idPart}`;
684
- }
685
- if (leg.state === "error" && leg.error) {
686
- return `${prefix}=error(${leg.error})`;
687
- }
688
- return `${prefix}=${leg.state}`;
689
- })
690
- .join(", ");
691
- return `ci_chain ${allOk ? "ok" : "incomplete/failed"} (${repo}@${shortSha}): ${summary || "no workflows"}`;
692
- }
693
-
694
- export function formatCiChainResult(result: CiChainResult): string {
695
- return `${result.verdict}\n${JSON.stringify(result, null, 2)}`;
696
- }
697
-
698
- const defaultDeps = (cwd: string, exec: Exec, signal?: AbortSignal): CiChainDeps => ({
699
- exec,
700
- cwd,
701
- signal,
702
- now: () => Date.now(),
703
- sleep: (ms, sig) => {
704
- if (ms <= 0 || sig?.aborted) return Promise.resolve();
705
- const { promise, resolve } = Promise.withResolvers<void>();
706
- const onAbort = () => {
707
- clearTimeout(timer);
708
- resolve();
709
- };
710
- const timer = setTimeout(() => {
711
- sig?.removeEventListener("abort", onAbort);
712
- resolve();
713
- }, ms);
714
- sig?.addEventListener("abort", onAbort, { once: true });
715
- return promise;
716
- },
717
- });
718
-
719
- export async function runCiChain(
720
- params: CiChainParams,
721
- depsInput: Partial<CiChainDeps> & { exec: Exec },
722
- ): Promise<CiChainResult> {
723
- const base: CiChainDeps = {
724
- ...defaultDeps(depsInput.cwd ?? process.cwd(), depsInput.exec, depsInput.signal),
725
- ...depsInput,
726
- };
727
- // A hung gh call must not hold the tool past its bound: every call gets a timeout unless the caller set one.
728
- const deps: CiChainDeps = { ...base, exec: (command, args, options) => base.exec(command, args, { timeout: GH_TIMEOUT_MS, ...options }) };
729
-
730
- const repo = (params.repo ?? "").trim();
731
- const sha = (params.sha ?? "").trim();
732
- const workflows = filterRequestedWorkflows(params.workflows ?? []);
733
- const mirror = params.mirror
734
- ? {
735
- repo: params.mirror.repo.trim(),
736
- workflows: filterRequestedWorkflows(params.mirror.workflows ?? []),
737
- title: params.mirror.title?.trim() || undefined,
738
- }
739
- : undefined;
740
-
741
- const waitSeconds = Math.max(
742
- 0,
743
- Math.min(MAX_WAIT_SECONDS, Number.isFinite(params.waitSeconds) ? Number(params.waitSeconds) : DEFAULT_WAIT_SECONDS),
744
- );
745
- const pollSeconds =
746
- params.pollSeconds !== undefined && Number.isFinite(params.pollSeconds) && params.pollSeconds > 0
747
- ? Math.min(MAX_POLL_SECONDS, Number(params.pollSeconds))
748
- : DEFAULT_POLL_SECONDS;
749
-
750
- const startMs = deps.now();
751
- const waitBoundMs = waitSeconds * 1000;
752
- let virtualElapsedMs = 0;
753
- let polls = 0;
754
- let effectiveSha = sha;
755
- let cachedMirrorTitle: string | undefined = mirror?.title;
756
- const failureCache = new Map<string, FailureDetails>();
757
- const publishLogCache = new Map<string, PublishLogCheck>();
758
- const pathsCache: PathsIgnoredCache = { decisions: new Map(), commits: new Map() };
759
-
760
- let legs: CiChainLegResult[] = [];
761
-
762
- while (true) {
763
- polls++;
764
- const currentLegs: CiChainLegResult[] = [];
765
-
766
- // 1. Query private / source repository runs for SHA
767
- let privateList = await fetchRunList(deps, repo, { sha: effectiveSha });
768
- if (
769
- privateList.ok &&
770
- privateList.runs.length === 0 &&
771
- effectiveSha.length < 40 &&
772
- /^[0-9a-f]{7,39}$/i.test(effectiveSha)
773
- ) {
774
- // `gh run list --commit` does not match an abbreviated SHA, so it must be resolved; a failed lookup is an error, not "no runs".
775
- const commitInfo = await fetchCommitInfo(deps, repo, effectiveSha);
776
- if (!commitInfo.ok) {
777
- privateList = { ok: false, error: commitInfo.error };
778
- } else {
779
- if (!cachedMirrorTitle) cachedMirrorTitle = commitInfo.subject;
780
- if (commitInfo.fullSha && commitInfo.fullSha.toLowerCase() !== effectiveSha.toLowerCase()) {
781
- effectiveSha = commitInfo.fullSha;
782
- privateList = await fetchRunList(deps, repo, { sha: effectiveSha });
783
- }
784
- }
785
- }
786
-
787
- if (!privateList.ok) {
788
- for (const wf of workflows) {
789
- currentLegs.push({
790
- repo,
791
- workflow: wf,
792
- runId: null,
793
- status: null,
794
- conclusion: null,
795
- url: null,
796
- matchedBy: "headSha",
797
- state: "error",
798
- error: privateList.error,
799
- });
800
- }
801
- } else {
802
- const shaRuns = privateList.runs.filter(
803
- (r) => matchesSha(r.headSha, effectiveSha) || matchesSha(r.headSha, sha),
804
- );
805
- for (const wf of workflows) {
806
- const wfRuns = shaRuns.filter((r) => r.workflow === wf);
807
- const chosen = pickNewestRun(wfRuns);
808
- const unknown = chosen ? undefined : await unknownWorkflowError(deps, repo, wf, pathsCache);
809
- if (unknown) {
810
- currentLegs.push({ repo, workflow: wf, runId: null, status: null, conclusion: null, url: null, matchedBy: "headSha", state: "error", error: unknown });
811
- } else if (!chosen && (await isPushPathsIgnored(deps, repo, wf, effectiveSha, pathsCache))) {
812
- currentLegs.push({ repo, workflow: wf, runId: null, status: null, conclusion: null, url: null, matchedBy: "headSha", state: "skipped-paths-ignored" });
813
- } else {
814
- currentLegs.push(await buildLegFromRun(deps, repo, wf, "headSha", chosen, failureCache));
815
- }
816
- }
817
- }
818
-
819
- // 2. Query mirror repository runs if configured
820
- if (mirror && mirror.workflows.length > 0) {
821
- if (!privateList.ok) {
822
- for (const wf of mirror.workflows) {
823
- currentLegs.push({
824
- repo: mirror.repo,
825
- workflow: wf,
826
- runId: null,
827
- status: null,
828
- conclusion: null,
829
- url: null,
830
- matchedBy: "title",
831
- state: "error",
832
- error: privateList.error,
833
- });
834
- }
835
- } else {
836
- const shaRuns = privateList.runs.filter(
837
- (r) => matchesSha(r.headSha, effectiveSha) || matchesSha(r.headSha, sha),
838
- );
839
- // The publish run is the anchor for mirror matching: without it a same-title mirror run of another commit could be taken.
840
- const publishRun = pickNewestRun(shaRuns.filter((r) => r.workflow === PUBLISH_MIRROR_WORKFLOW));
841
-
842
- let skippedNoPublicChange = false;
843
- let skippedPathsIgnored = false;
844
- let canQueryMirror = true;
845
- // A mirror run can never follow a publish run that did not succeed; finish those legs instead of waiting out the bound.
846
- let mirrorBlockedError: string | undefined;
847
-
848
- if (!publishRun || publishRun.runId === null) {
849
- canQueryMirror = false;
850
- // No publish run because the push touched only files the publish workflow ignores: nothing reached the mirror.
851
- skippedPathsIgnored = !publishRun && (await isPushPathsIgnored(deps, repo, PUBLISH_MIRROR_WORKFLOW, effectiveSha, pathsCache));
852
- } else if (publishRun.status !== "completed") {
853
- canQueryMirror = false;
854
- } else if (publishRun.conclusion !== "success") {
855
- canQueryMirror = false;
856
- mirrorBlockedError = `publish run ${publishRun.runId} concluded ${publishRun.conclusion ?? "without a conclusion"}; no mirror run will follow`;
857
- } else {
858
- const logCheck = await checkNothingToPublish(deps, repo, { ...publishRun, runId: publishRun.runId }, publishLogCache);
859
- if ("error" in logCheck) {
860
- canQueryMirror = false;
861
- mirrorBlockedError = logCheck.error;
862
- } else if (logCheck.skipped) {
863
- skippedNoPublicChange = true;
864
- canQueryMirror = false;
865
- }
866
- }
867
-
868
- if (skippedNoPublicChange || skippedPathsIgnored) {
869
- for (const wf of mirror.workflows) {
870
- currentLegs.push({
871
- repo: mirror.repo,
872
- workflow: wf,
873
- runId: null,
874
- status: null,
875
- conclusion: null,
876
- url: null,
877
- matchedBy: "title",
878
- state: skippedNoPublicChange ? "skipped-no-public-change" : "skipped-paths-ignored",
879
- });
880
- }
881
- } else if (mirrorBlockedError) {
882
- for (const wf of mirror.workflows) {
883
- currentLegs.push({
884
- repo: mirror.repo,
885
- workflow: wf,
886
- runId: null,
887
- status: null,
888
- conclusion: null,
889
- url: null,
890
- matchedBy: "title",
891
- state: "error",
892
- error: mirrorBlockedError,
893
- });
894
- }
895
- } else if (!canQueryMirror) {
896
- for (const wf of mirror.workflows) {
897
- currentLegs.push({
898
- repo: mirror.repo,
899
- workflow: wf,
900
- runId: null,
901
- status: null,
902
- conclusion: null,
903
- url: null,
904
- matchedBy: "title",
905
- state: "pending",
906
- });
907
- }
908
- } else {
909
- let titleError: string | undefined;
910
- if (!cachedMirrorTitle) {
911
- const commitInfo = await fetchCommitInfo(deps, repo, effectiveSha);
912
- if (!commitInfo.ok) {
913
- titleError = commitInfo.error;
914
- } else {
915
- cachedMirrorTitle = commitInfo.subject;
916
- }
917
- }
918
-
919
- if (titleError || !cachedMirrorTitle) {
920
- for (const wf of mirror.workflows) {
921
- currentLegs.push({
922
- repo: mirror.repo,
923
- workflow: wf,
924
- runId: null,
925
- status: null,
926
- conclusion: null,
927
- url: null,
928
- matchedBy: "title",
929
- state: "error",
930
- error: titleError ?? "Unable to determine mirror commit title",
931
- });
932
- }
933
- } else {
934
- // Anchor on the publish run's createdAt. Its run-level startedAt equals createdAt on every one of the last 60 publish runs, and on a
935
- // re-run it would move past the first attempt's mirror push, so it is no better bound.
936
- const anchorTime = publishRun!.createdAt;
937
- const baselineTime = Date.parse(anchorTime);
938
- for (const wf of mirror.workflows) {
939
- // Query each workflow from the publish time on, so older or unrelated runs cannot push the match out of the page.
940
- const mirrorList = await fetchRunList(deps, mirror.repo, { workflow: wf, createdSince: anchorTime });
941
- if (!mirrorList.ok) {
942
- currentLegs.push({
943
- repo: mirror.repo,
944
- workflow: wf,
945
- runId: null,
946
- status: null,
947
- conclusion: null,
948
- url: null,
949
- matchedBy: "title",
950
- state: "error",
951
- error: mirrorList.error,
952
- });
953
- continue;
954
- }
955
- // The earliest same-title run at or after the publish run is this commit's; a later commit with the same subject comes after it.
956
- const chosen = mirrorList.runs
957
- .filter((r) => r.workflow === wf && matchesRunTitle(r.displayTitle, cachedMirrorTitle!) && Date.parse(r.createdAt) >= baselineTime)
958
- .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || a.runId! - b.runId!)[0];
959
- // The list is newest first: on a full page the oldest runs, where this commit's run sits, may be cut off, so no pick is safe.
960
- if (mirrorList.pageFull) {
961
- currentLegs.push({
962
- repo: mirror.repo,
963
- workflow: wf,
964
- runId: null,
965
- status: null,
966
- conclusion: null,
967
- url: null,
968
- matchedBy: "title",
969
- state: "error",
970
- error: `${mirror.repo} ${wf}: ${RUN_LIST_LIMIT} or more runs since the publish run; the earliest match may be past the page`,
971
- });
972
- continue;
973
- }
974
- currentLegs.push(await buildLegFromRun(deps, mirror.repo, wf, "title", chosen, failureCache));
975
- }
976
- }
977
- }
978
- }
979
- }
980
-
981
- legs = currentLegs;
982
-
983
- if (legs.every(isLegTerminal)) {
984
- break;
985
- }
986
-
987
- if (waitSeconds <= 0) {
988
- break;
989
- }
990
-
991
- const realElapsedMs = Math.max(0, deps.now() - startMs);
992
- const effectiveElapsedMs = Math.max(realElapsedMs, virtualElapsedMs);
993
- const remainingMs = waitBoundMs - effectiveElapsedMs;
994
-
995
- if (remainingMs <= 0 || deps.signal?.aborted) {
996
- if (remainingMs <= 0) {
997
- legs = legs.map((leg) =>
998
- leg.state === "pending" ? { ...leg, state: "not-found-within-bound" } : leg,
999
- );
1000
- }
1001
- break;
1002
- }
1003
-
1004
- const sleepMs = Math.min(pollSeconds * 1000, remainingMs);
1005
- const beforeSleep = deps.now();
1006
- await deps.sleep(sleepMs, deps.signal);
1007
- const afterSleep = deps.now();
1008
- virtualElapsedMs += Math.max(0, afterSleep - beforeSleep, sleepMs);
1009
-
1010
- if (deps.signal?.aborted) {
1011
- break;
1012
- }
1013
- }
1014
-
1015
- const elapsedMs = Math.max(0, deps.now() - startMs, virtualElapsedMs);
1016
- const ok = legs.length > 0 && legs.every(isLegOk);
1017
- const verdict = buildVerdict(repo, sha, legs);
1018
-
1019
- return {
1020
- ok,
1021
- repo,
1022
- sha,
1023
- ...(mirror ? { mirrorTitle: cachedMirrorTitle ?? null } : {}),
1024
- verdict,
1025
- legs,
1026
- polls,
1027
- elapsedMs,
1028
- };
1029
- }
1030
-
1031
- const factory: CustomToolFactory = (pi) => ({
1032
- name: "ci_chain",
1033
- label: "CI Chain",
1034
- loadMode: "essential",
1035
- description:
1036
- "Read-only GitHub Actions CI chain status checker for a commit SHA and its optional public mirror repository. "
1037
- + "Matches source repository runs by headSha for an explicit workflow list (never waits on 'Publish CUELO web release'), "
1038
- + "detects 'nothing to publish' in 'Publish public mirror' logs (reporting mirror legs as skipped-no-public-change), "
1039
- + "reports a workflow with no run for the commit as skipped-paths-ignored when every file the commit changed matches the workflow's push paths-ignore/paths filter, "
1040
- + "ends a source workflow name the repository does not have (a job or step name is not one) as an error listing its real workflows instead of waiting, "
1041
- + "and matches mirror repository runs by commit title created at or after the private publish run. "
1042
- + "On failure, reports failed job names and the last 40 lines of --log-failed.",
1043
- parameters: pi.zod.object({
1044
- repo: pi.zod.string().describe("Source GitHub repository (owner/name), e.g. gim47656-ship-it/CUELO-private"),
1045
- sha: pi.zod.string().describe("Commit SHA in the source repository"),
1046
- workflows: pi.zod
1047
- .array(pi.zod.string())
1048
- .describe("Explicit workflow names to inspect on the source repository, e.g. [\"CI\", \"Publish public mirror\"]"),
1049
- mirror: pi.zod
1050
- .object({
1051
- repo: pi.zod.string().describe("Public mirror GitHub repository (owner/name), e.g. gim47656-ship-it/CUELO"),
1052
- workflows: pi.zod.array(pi.zod.string()).describe("Explicit workflow names on the mirror repository, e.g. [\"CI\"]"),
1053
- title: pi.zod
1054
- .string()
1055
- .optional()
1056
- .describe("Commit subject line to match mirror displayTitle; derived via gh api repos/<repo>/commits/<sha> when omitted"),
1057
- })
1058
- .optional()
1059
- .describe("Optional public mirror repository configuration"),
1060
- waitSeconds: pi.zod
1061
- .number()
1062
- .optional()
1063
- .describe("Maximum seconds to poll until all legs finish (default 0 = single snapshot, max 1800)"),
1064
- pollSeconds: pi.zod
1065
- .number()
1066
- .optional()
1067
- .describe("Polling interval in seconds when waitSeconds > 0 (default 15)"),
1068
- }),
1069
-
1070
- async execute(_toolCallId, params, onUpdate, _ctx, signal) {
1071
- const exec: Exec = (command, args, options) =>
1072
- pi.exec(command, args, { cwd: options.cwd, timeout: options.timeout, signal: options.signal ?? signal });
1073
- onUpdate?.({
1074
- content: [{ type: "text", text: `Checking CI chain for ${params.repo}@${String(params.sha).slice(0, 7)}...` }],
1075
- details: { phase: "poll", repo: params.repo, sha: params.sha },
1076
- });
1077
- const result = await runCiChain(params as CiChainParams, defaultDeps(pi.cwd, exec, signal));
1078
- return {
1079
- content: [{ type: "text", text: formatCiChainResult(result) }],
1080
- details: result,
1081
- };
1082
- },
1083
- });
1084
-
1085
- export default factory;