willfire 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -20,20 +20,36 @@ pnpm add willfire
20
20
  import { predict } from "willfire";
21
21
  import { getOctokit } from "@actions/github"; // or new Octokit({ auth: token })
22
22
 
23
- const { entries, skip } = await predict(getOctokit(token), "owner/repo", 123);
23
+ const { entries, checkNames, skip } = await predict(getOctokit(token), "owner/repo", 123);
24
+ // checkNames: sorted, deduped checkName of every entry with status "run"
24
25
  ```
25
26
 
26
27
  `entries` is a union of two variants, both carrying `workflow` and `reason`:
27
28
 
28
- | variant | `job` | `status` |
29
- | --- | --- | --- |
30
- | `WorkflowEntry` | `"*"` | `"run" \| "skipped" \| "no-dispatch"` |
31
- | `JobEntry` | the job's display name | `"run" \| "skipped" \| "unknown" \| "no-dispatch"` |
29
+ | variant | `job` | `checkName` | `status` |
30
+ | --- | --- | --- | --- |
31
+ | `WorkflowEntry` | `"*"` | always `null` | `"run" \| "skipped" \| "no-dispatch"` |
32
+ | `JobEntry` | the job id | the check name, or `null` | `"run" \| "skipped" \| "unknown" \| "no-dispatch"` |
32
33
 
33
34
  `"unknown"` is job-level only: every workflow-level verdict is decidable, so a
34
35
  `WorkflowEntry` cannot express one. Narrow with the exported `isWorkflowEntry`
35
36
  and `isJobEntry` guards rather than testing the `"*"` sentinel yourself.
36
37
 
38
+ `checkName` is the name GitHub actually puts on the check — `name:` override,
39
+ matrix parenthetical, and `<caller> / <callee>` prefixing for reusable
40
+ workflows all applied. That is the unit required status checks key on, so it
41
+ is the one worth comparing against. On a `JobEntry` it is `null` only where no
42
+ single name is knowable ahead of the run:
43
+
44
+ - a matrix computed at runtime (`fromJSON` of another job's output), reported
45
+ as one `unknown` entry for that job and nothing else;
46
+ - a non-local reusable workflow, whose callee jobs we do not fetch;
47
+ - a `name:` interpolating something we cannot evaluate statically.
48
+
49
+ Duplicate names in `checkNames` are not possible (it is a set), but duplicate
50
+ check names *are* — GitHub happily creates two identically named checks when a
51
+ matrix job's `name:` does not vary per combination. `entries` shows them.
52
+
37
53
  Auth is any token with `contents: read`, `actions: read`, and
38
54
  `pull-requests: read` — inside an action, the workflow's `GITHUB_TOKEN`.
39
55
 
@@ -55,15 +71,48 @@ entries, matching how they appear in the checks UI.
55
71
  Things that cannot be known statically — e.g. a matrix computed at runtime
56
72
  from another job's output — are reported as `unknown` rather than guessed.
57
73
 
74
+ ## Development
75
+
76
+ ```sh
77
+ pnpm install
78
+ pnpm typecheck # tsc over src/, tests included
79
+ pnpm test # vitest, once
80
+ pnpm test:coverage # the 100% floor CI enforces
81
+ ```
82
+
83
+ Unit tests are colocated with their source (`foo.ts` ↔ `foo.test.ts`) at 100%
84
+ coverage, per the
85
+ [testing-conventions](https://github.com/thekevinscott/testing-conventions)
86
+ standard. `.github/workflows/conventions.yml` enforces that in CI;
87
+ `testing-conventions.toml` holds the thresholds.
88
+
58
89
  ## Verification
59
90
 
60
- Predictions are verified against real GitHub behavior, not just the docs:
91
+ The unit suite fixes the behavior. What fixes the *expectations* is the probe:
92
+ predictions are verified against real GitHub behavior, not just the docs.
61
93
  [willrun-probe](https://github.com/thekevinbot/willrun-probe) holds one
62
94
  workflow per dispatch rule, and probe PRs exercise each complication
63
95
  (docs-only diffs, negation edges, `[skip ci]`, a 301-file diff, PRs into
64
96
  non-default branches). The `verify` script diffs predictions against the
65
97
  check entries GitHub actually created. All probe PRs currently pass exactly.
66
98
 
99
+ Check-name resolution is verified the same way, on probe PR #8. `pnpm test`
100
+ replays those probe workflows through the resolver and asserts the exact job
101
+ names the live run produced. Several of the rules it pins are ones the docs do
102
+ not state:
103
+
104
+ - a `name:` containing *any* `${{ }}` expression suppresses the matrix
105
+ parenthetical, even an expression that never reads the matrix — a literal
106
+ `name:` still gets one, so `name: Static Label` over `a: [x, y]` yields
107
+ `Static Label (x)` and `Static Label (y)`;
108
+ - keys an `include` entry merges into an existing combination do not appear in
109
+ the name, but keys of a combination the `include` created from scratch do;
110
+ - object matrix values flatten to their own values, so `{os: linux, arch: x64}`
111
+ renders as `(linux, x64)`;
112
+ - a skipped job is never set up, so its matrix does not expand and its `name:`
113
+ is not interpolated: one check, expression text and all.
114
+
67
115
  Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
68
116
  live events, cross-repo reusable workflows, `branches-ignore`, and diffs far
69
- beyond 301 files are not yet probe-verified.
117
+ beyond 301 files are not yet probe-verified. Reusable-workflow name prefixing
118
+ is probe-verified to three levels; deeper nesting is inferred.
package/dist/predict.d.ts CHANGED
@@ -30,6 +30,8 @@ export declare const jobName: <S extends string>(name: S extends "*" ? never : S
30
30
  */
31
31
  export interface WorkflowEntry extends EntryBase {
32
32
  job: "*";
33
+ /** Always null: a workflow-level verdict is about the run, not a named check. */
34
+ checkName: null;
33
35
  status: "run" | "skipped" | "no-dispatch";
34
36
  }
35
37
  /**
@@ -41,6 +43,16 @@ export interface WorkflowEntry extends EntryBase {
41
43
  */
42
44
  export interface JobEntry extends EntryBase {
43
45
  job: JobName;
46
+ /**
47
+ * The check name GitHub will create for this entry, resolved the way the
48
+ * Actions runner names jobs: `name:` override, matrix parenthetical, and
49
+ * `<caller> / <callee>` prefixing for reusable workflows.
50
+ *
51
+ * null when there is no single statically-knowable name: a matrix computed
52
+ * at runtime, a non-local reusable workflow, or a `name:` that interpolates
53
+ * something we cannot evaluate ahead of the run.
54
+ */
55
+ checkName: string | null;
44
56
  status: "run" | "skipped" | "unknown" | "no-dispatch";
45
57
  }
46
58
  export type Entry = WorkflowEntry | JobEntry;
@@ -50,16 +62,46 @@ export declare const isWorkflowEntry: (e: Entry) => e is WorkflowEntry;
50
62
  export declare const isJobEntry: (e: Entry) => e is JobEntry;
51
63
  export interface Prediction {
52
64
  entries: Entry[];
65
+ /**
66
+ * Convenience aggregate: the sorted, deduplicated check names of every
67
+ * entry with status "run" and a resolved name. Entries whose name could
68
+ * not be resolved are absent here — read `entries` to see them.
69
+ */
70
+ checkNames: string[];
53
71
  skip: string | null;
54
72
  }
55
73
  export declare function patternToRegex(pat: string): RegExp;
56
74
  /** Order-sensitive match: last matching pattern wins; ! negates. */
57
75
  export declare function matchFilters(value: string, patterns: string[]): boolean;
76
+ export interface Ctx {
77
+ action: string;
78
+ baseRef: string;
79
+ files: string[];
80
+ }
81
+ type Workflow = Record<string, any>;
58
82
  type Combo = Record<string, any> | null;
59
83
  /** Return list of matrix combination dicts, or null if dynamic. */
60
84
  export declare function expandMatrix(strategy: any): Combo[] | null;
61
85
  /** Return run|skipped|unknown for a job-level if. */
62
86
  export declare function evalIf(cond: any): "run" | "skipped" | "unknown";
87
+ /**
88
+ * One expanded job, before it is paired with its workflow. Named `ExpandedJob`
89
+ * because `JobEntry` is now the exported job-level variant of `Entry`.
90
+ */
91
+ export interface ExpandedJob {
92
+ /** The job id this entry was built from. */
93
+ job: string;
94
+ /** The resolved check name, or null when it could not be settled statically. */
95
+ checkName: string | null;
96
+ status: "run" | "skipped" | "unknown";
97
+ reason: string;
98
+ }
99
+ /**
100
+ * Expand one already-parsed workflow into its job entries. Exported so check
101
+ * names can be tested against recorded GitHub behaviour without a network
102
+ * round-trip; `predict` is the API you want.
103
+ */
104
+ export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, fetchFile: (path: string) => Promise<string | null>): Promise<ExpandedJob[]>;
63
105
  export declare function makeOctokit(): Octokit;
64
106
  export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
65
107
  export {};
package/dist/predict.js CHANGED
@@ -6,7 +6,9 @@
6
6
  // pull-requests read). Inside an action, pass the workflow's GITHUB_TOKEN.
7
7
  //
8
8
  // Faithful port of predict.py, which was verified entry-for-entry against
9
- // live dispatches on thekevinbot/willrun-probe (PRs 1-7).
9
+ // live dispatches on thekevinbot/willrun-probe (PRs 1-7). Check-name
10
+ // resolution was verified the same way on probe PR 8; the rules it turned up
11
+ // are pinned in src/names.test.ts.
10
12
  import { Octokit } from "@octokit/rest";
11
13
  import { parse as parseYaml } from "yaml";
12
14
  /** Tag a job display name. Rejects the workflow-level sentinel. */
@@ -15,6 +17,10 @@ export const jobName = (name) => name;
15
17
  export const isWorkflowEntry = (e) => e.job === "*";
16
18
  /** Narrow to the job-level variant without inspecting the sentinel. */
17
19
  export const isJobEntry = (e) => e.job !== "*";
20
+ const isWorkflowDraft = (e) => e.job === "*";
21
+ const finalize = (e) => isWorkflowDraft(e)
22
+ ? { ...e, checkName: null }
23
+ : { ...e, checkName: e.checkName ?? null };
18
24
  // ------------------------------------------------- GitHub filter pattern glob
19
25
  // Grammar per docs: * (any chars except /), ** (any chars), ? (zero or one of
20
26
  // preceding char), + (one or more of preceding char), [ranges], leading ! negates.
@@ -118,8 +124,7 @@ function workflowDispatches(wf, ctx) {
118
124
  }
119
125
  return [true, "trigger matched"];
120
126
  }
121
- /** Return list of matrix combination dicts, or null if dynamic. */
122
- export function expandMatrix(strategy) {
127
+ function expandMatrixDetailed(strategy) {
123
128
  const matrix = strategy?.matrix;
124
129
  if (matrix == null)
125
130
  return [null];
@@ -137,44 +142,134 @@ export function expandMatrix(strategy) {
137
142
  return null;
138
143
  axes[k] = v;
139
144
  }
140
- let combos = [{}];
145
+ const axisKeys = Object.keys(axes);
146
+ let combos = [{ values: {}, displayKeys: axisKeys }];
141
147
  for (const [k, vals] of Object.entries(axes)) {
142
- combos = combos.flatMap((c) => vals.map((v) => ({ ...c, [k]: v })));
148
+ combos = combos.flatMap((c) => vals.map((v) => ({ values: { ...c.values, [k]: v }, displayKeys: axisKeys })));
143
149
  }
144
- if (Object.keys(axes).length === 0)
150
+ if (axisKeys.length === 0)
145
151
  combos = [];
146
- combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c[k] === v)));
152
+ combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c.values[k] === v)));
147
153
  const extra = [];
148
154
  for (const inc of include) {
149
155
  const overlapping = Object.fromEntries(Object.entries(inc).filter(([k]) => k in axes));
150
- const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c[k] === v));
151
- if (Object.keys(overlapping).length > 0 && targets.length > 0) {
156
+ const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c.values[k] === v));
157
+ if (axisKeys.length > 0 && targets.length > 0) {
158
+ // Merge into the matching combinations. With no overlapping keys this
159
+ // matches every combination, per the docs ("added to each of the matrix
160
+ // combinations if none of the key:value pairs overwrite any of the
161
+ // original matrix values").
152
162
  for (const c of targets)
153
- Object.assign(c, inc);
163
+ Object.assign(c.values, inc);
154
164
  }
155
165
  else {
156
- extra.push({ ...inc });
166
+ // No combination to attach to: the include entry becomes a combination
167
+ // of its own, and every one of its keys shows in the name.
168
+ extra.push({ values: { ...inc }, displayKeys: Object.keys(inc) });
157
169
  }
158
170
  }
159
171
  combos.push(...extra);
160
172
  return combos.length > 0 ? combos : [null];
161
173
  }
174
+ /** Return list of matrix combination dicts, or null if dynamic. */
175
+ export function expandMatrix(strategy) {
176
+ const detailed = expandMatrixDetailed(strategy);
177
+ return detailed == null ? null : detailed.map((c) => (c == null ? null : c.values));
178
+ }
179
+ /**
180
+ * How a single matrix value is rendered inside a check name.
181
+ *
182
+ * Probe-verified: object values are flattened to their own values, so
183
+ * `cfg: {os: linux, arch: x64}` renders as `linux, x64` — the check is
184
+ * `m-object (linux, x64)`.
185
+ */
186
+ function formatMatrixValue(v) {
187
+ if (v == null)
188
+ return "";
189
+ if (Array.isArray(v))
190
+ return v.map(formatMatrixValue).join(", ");
191
+ if (typeof v === "object")
192
+ return Object.values(v).map(formatMatrixValue).join(", ");
193
+ return String(v);
194
+ }
195
+ /** The ` (v1, v2)` suffix GitHub appends for a matrix combination. */
196
+ function matrixSuffix(combo) {
197
+ const keys = combo.displayKeys.filter((k) => k in combo.values);
198
+ if (keys.length === 0)
199
+ return "";
200
+ return ` (${keys.map((k) => formatMatrixValue(combo.values[k])).join(", ")})`;
201
+ }
202
+ /**
203
+ * A `name:` that contains any `${{ }}` expression suppresses the matrix
204
+ * parenthetical; a literal one does not.
205
+ *
206
+ * Probe-verified three ways over `a: [x, y]`: `name: Static Label` yields
207
+ * `Static Label (x)` / `Static Label (y)`, `name: ev ${{ github.event_name }}`
208
+ * yields two checks both called `ev pull_request`, and
209
+ * `name: p ${{ matrix.a }}` over `a: [x], b: ["1", "2"]` yields two checks
210
+ * both called `p x`. So the trigger is the presence of an expression, not
211
+ * whether the expression happens to read the matrix — and duplicate check
212
+ * names are a real outcome GitHub allows.
213
+ */
214
+ const EXPRESSION_RE = /\$\{\{/;
215
+ function lookupPath(obj, path) {
216
+ let cur = obj;
217
+ for (const seg of path.split(".")) {
218
+ if (cur == null || typeof cur !== "object" || !(seg in cur))
219
+ return undefined;
220
+ cur = cur[seg];
221
+ }
222
+ return cur;
223
+ }
162
224
  function renderName(template, combo) {
163
- return template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
225
+ let resolved = true;
226
+ const text = template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
164
227
  const expr = String(inner).trim();
165
- if (expr.startsWith("matrix.") && combo) {
166
- return String(combo[expr.slice("matrix.".length)] ?? "");
228
+ if (expr.startsWith("matrix.")) {
229
+ if (!combo) {
230
+ resolved = false;
231
+ return whole;
232
+ }
233
+ const val = lookupPath(combo, expr.slice("matrix.".length));
234
+ if (val === undefined) {
235
+ resolved = false;
236
+ return whole;
237
+ }
238
+ return formatMatrixValue(val);
167
239
  }
240
+ // We only predict pull_request dispatch, so this one is knowable.
241
+ if (expr === "github.event_name")
242
+ return "pull_request";
243
+ resolved = false;
168
244
  return whole;
169
245
  });
246
+ return { text, resolved };
170
247
  }
248
+ /** The check name for one job/combination. */
171
249
  function jobDisplayName(jobId, job, combo) {
172
- if ("name" in job && job.name != null)
173
- return renderName(String(job.name), combo);
174
- let name = jobId;
175
- if (combo)
176
- name += ` (${Object.values(combo).map(String).join(", ")})`;
177
- return name;
250
+ const raw = job != null && job.name != null ? String(job.name) : null;
251
+ if (raw === null) {
252
+ return { name: jobId + (combo ? matrixSuffix(combo) : ""), resolved: true };
253
+ }
254
+ const { text, resolved } = renderName(raw, combo?.values ?? null);
255
+ const suffix = combo && !EXPRESSION_RE.test(raw) ? matrixSuffix(combo) : "";
256
+ return { name: text + suffix, resolved };
257
+ }
258
+ /**
259
+ * The check name a job gets when it is skipped.
260
+ *
261
+ * A skipped job is never set up, so nothing about it is evaluated: the matrix
262
+ * does not expand and `name:` is not interpolated. Probe-verified twice over:
263
+ * `if: false` with `a: [x, y]` produces the single check `m-skipped`, not
264
+ * `m-skipped (x)` / `m-skipped (y)`; and `name: sk ${{ github.event_name }}`
265
+ * with `if: false` produces a check literally called
266
+ * `sk ${{ github.event_name }}`, expression text and all. The same collapse
267
+ * applies to a skipped reusable-workflow call: one check named after the
268
+ * caller, with no `/ <callee job>` entries.
269
+ */
270
+ function skippedDisplayName(jobId, job) {
271
+ const raw = job != null && job.name != null ? String(job.name) : null;
272
+ return { name: raw ?? jobId, resolved: true };
178
273
  }
179
274
  /** Return run|skipped|unknown for a job-level if. */
180
275
  export function evalIf(cond) {
@@ -194,7 +289,13 @@ export function evalIf(cond) {
194
289
  }
195
290
  return "unknown";
196
291
  }
197
- async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "") {
292
+ /**
293
+ * GitHub allows a reusable-workflow call chain four levels deep. Past that the
294
+ * run itself fails, so anything deeper is not a name we could predict anyway.
295
+ * Probe-verified to three levels: `call-nested / Mid Call / inner`.
296
+ */
297
+ const MAX_REUSABLE_DEPTH = 4;
298
+ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "", prefixResolved = true) {
198
299
  const entries = [];
199
300
  const jobs = wf.jobs ?? {};
200
301
  const statuses = {};
@@ -219,44 +320,105 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "") {
219
320
  }
220
321
  }
221
322
  statuses[jobId] = status;
323
+ // A skipped job never expands its matrix and never dispatches a called
324
+ // workflow: it collapses to a single check under the bare job name.
325
+ if (status === "skipped") {
326
+ const disp = skippedDisplayName(jobId, job);
327
+ const name = prefix + disp.name;
328
+ entries.push({
329
+ job: name,
330
+ checkName: prefixResolved && disp.resolved ? name : null,
331
+ status,
332
+ reason,
333
+ });
334
+ continue;
335
+ }
336
+ const combos = expandMatrixDetailed(job.strategy);
222
337
  if ("uses" in job) {
223
- // reusable workflow call
338
+ // Reusable workflow call. The calling job produces no check of its own;
339
+ // each called job becomes `<calling job name> / <called job name>`, and
340
+ // a matrix on the *caller* multiplies the whole callee set.
224
341
  const uses = job.uses;
225
- const baseName = prefix + (job.name != null ? String(job.name) : jobId);
226
- if (depth >= 1) {
227
- entries.push([baseName, "unknown", "nested reusable workflow"]);
342
+ if (combos == null) {
343
+ entries.push({
344
+ job: prefix + jobId,
345
+ checkName: null,
346
+ status: "unknown",
347
+ reason: "dynamic matrix on reusable workflow call",
348
+ });
228
349
  continue;
229
350
  }
351
+ // Resolve the called workflow once, not once per matrix combination.
352
+ let subWf = null;
353
+ let failure = null;
230
354
  const m = uses.match(/^\.\/(.+)$/);
231
- if (!m) {
232
- entries.push([baseName, "unknown", `non-local reusable: ${uses}`]);
233
- continue;
355
+ if (depth + 1 > MAX_REUSABLE_DEPTH) {
356
+ failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
234
357
  }
235
- const content = await fetchFile(m[1]);
236
- if (content == null) {
237
- entries.push([baseName, "unknown", `cannot fetch ${uses}`]);
238
- continue;
358
+ else if (!m) {
359
+ failure = `non-local reusable: ${uses}`;
239
360
  }
240
- if (status === "skipped") {
241
- entries.push([baseName, "skipped", reason]);
242
- continue;
361
+ else {
362
+ const content = await fetchFile(m[1]);
363
+ if (content == null) {
364
+ failure = `cannot fetch ${uses}`;
365
+ }
366
+ else {
367
+ try {
368
+ subWf = parseYaml(content);
369
+ }
370
+ catch (e) {
371
+ failure = `YAML parse error in ${uses}: ${e}`;
372
+ }
373
+ }
374
+ }
375
+ for (const combo of combos) {
376
+ const disp = jobDisplayName(jobId, job, combo);
377
+ const baseName = prefix + disp.name;
378
+ const nameResolved = prefixResolved && disp.resolved;
379
+ if (failure != null || subWf == null) {
380
+ entries.push({
381
+ job: baseName,
382
+ checkName: null,
383
+ status: "unknown",
384
+ reason: failure ?? `cannot resolve ${uses}`,
385
+ });
386
+ continue;
387
+ }
388
+ entries.push(...(await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `, nameResolved)));
243
389
  }
244
- const subWf = parseYaml(content);
245
- const sub = await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `);
246
- entries.push(...sub);
247
390
  continue;
248
391
  }
249
- const combos = expandMatrix(job.strategy);
250
392
  if (combos == null) {
251
- entries.push([prefix + jobId, "unknown", "dynamic matrix"]);
393
+ entries.push({
394
+ job: prefix + jobId,
395
+ checkName: null,
396
+ status: "unknown",
397
+ reason: "dynamic matrix",
398
+ });
252
399
  continue;
253
400
  }
254
401
  for (const combo of combos) {
255
- entries.push([prefix + jobDisplayName(jobId, job, combo), status, reason]);
402
+ const disp = jobDisplayName(jobId, job, combo);
403
+ const name = prefix + disp.name;
404
+ entries.push({
405
+ job: name,
406
+ checkName: prefixResolved && disp.resolved ? name : null,
407
+ status,
408
+ reason,
409
+ });
256
410
  }
257
411
  }
258
412
  return entries;
259
413
  }
414
+ /**
415
+ * Expand one already-parsed workflow into its job entries. Exported so check
416
+ * names can be tested against recorded GitHub behaviour without a network
417
+ * round-trip; `predict` is the API you want.
418
+ */
419
+ export function expandWorkflowJobs(wf, ctx, fetchFile) {
420
+ return expandJobs(wf, ctx, fetchFile);
421
+ }
260
422
  // ------------------------------------------------------------------- pipeline
261
423
  export function makeOctokit() {
262
424
  const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
@@ -285,7 +447,7 @@ export async function predict(octokit, repo, prNumber) {
285
447
  });
286
448
  const headMsg = headCommit.commit.message;
287
449
  if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
288
- return { entries: [], skip: "head commit message contains a skip instruction" };
450
+ return finalizePrediction([], "head commit message contains a skip instruction");
289
451
  }
290
452
  const fetchFile = async (path) => {
291
453
  try {
@@ -353,11 +515,26 @@ export async function predict(octokit, repo, prNumber) {
353
515
  entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
354
516
  continue;
355
517
  }
356
- for (const [name, status, jreason] of await expandJobs(wf, ctx, fetchFile)) {
357
- entries.push({ workflow: path, job: jobName(name), status, reason: jreason || reason });
518
+ for (const j of await expandJobs(wf, ctx, fetchFile)) {
519
+ entries.push({
520
+ workflow: path,
521
+ job: jobName(j.job),
522
+ checkName: j.checkName,
523
+ status: j.status,
524
+ reason: j.reason || reason,
525
+ });
358
526
  }
359
527
  }
360
- return { entries, skip: null };
528
+ return finalizePrediction(entries, null);
529
+ }
530
+ function finalizePrediction(entries, skip) {
531
+ const final = entries.map(finalize);
532
+ const names = new Set();
533
+ for (const e of final) {
534
+ if (e.status === "run" && e.checkName != null)
535
+ names.add(e.checkName);
536
+ }
537
+ return { entries: final, checkNames: [...names].sort(), skip };
361
538
  }
362
539
  // ------------------------------------------------------------------------ CLI
363
540
  function parseArgs(argv) {
@@ -376,9 +553,9 @@ function parseArgs(argv) {
376
553
  const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
377
554
  if (isMain) {
378
555
  const args = parseArgs(process.argv.slice(2));
379
- const { entries, skip } = await predict(makeOctokit(), args.repo, args.pr);
556
+ const { entries, checkNames, skip } = await predict(makeOctokit(), args.repo, args.pr);
380
557
  if (args.json) {
381
- console.log(JSON.stringify({ entries, skip }, null, 2));
558
+ console.log(JSON.stringify({ entries, checkNames, skip }, null, 2));
382
559
  }
383
560
  else if (skip) {
384
561
  console.log(`# ${skip} -> nothing dispatches`);
@@ -387,8 +564,10 @@ if (isMain) {
387
564
  for (const e of entries) {
388
565
  if (isWorkflowEntry(e))
389
566
  console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
390
- else
391
- console.log(`${e.workflow} :: ${e.job} :: ${e.status}`);
567
+ else {
568
+ const name = e.checkName ?? `${e.job} (name unresolved)`;
569
+ console.log(`${e.workflow} :: ${name} :: ${e.status}`);
570
+ }
392
571
  }
393
572
  }
394
573
  }
package/dist/verify.js CHANGED
@@ -44,8 +44,18 @@ if (!repo || !prArg) {
44
44
  const pr = Number(prArg);
45
45
  const octokit = makeOctokit();
46
46
  const { entries: predictedRaw } = await predict(octokit, repo, pr);
47
- const predicted = new Map(predictedRaw.filter(isJobEntry).map((r) => [`${r.workflow} :: ${r.job}`, r.status]));
48
- const unknownWfs = new Set(predictedRaw.filter((r) => r.status === "unknown").map((r) => r.workflow));
47
+ // Compare on the resolved check name that is the string GitHub actually
48
+ // puts on the job. Entries whose name could not be resolved statically have
49
+ // no key to compare and are reported separately below.
50
+ const predicted = new Map(predictedRaw
51
+ .filter(isJobEntry)
52
+ .filter((r) => r.checkName != null)
53
+ .map((r) => [`${r.workflow} :: ${r.checkName}`, r.status]));
54
+ const unresolved = predictedRaw.filter(isJobEntry).filter((r) => r.checkName == null);
55
+ const unknownWfs = new Set(predictedRaw
56
+ .filter((r) => r.status === "unknown")
57
+ .map((r) => r.workflow)
58
+ .concat(unresolved.map((r) => r.workflow)));
49
59
  const { entries: actual, incomplete } = await actualEntries(octokit, repo, pr);
50
60
  if (incomplete.length > 0) {
51
61
  console.log(`WARNING: runs still in progress: ${incomplete}`);
@@ -80,5 +90,8 @@ for (const key of keys) {
80
90
  console.log(`DIFF ${key} :: predicted ${p}, actual ${a}`);
81
91
  }
82
92
  }
93
+ for (const r of unresolved) {
94
+ console.log(` ? ${r.workflow} :: ${r.job} :: name unresolved: ${r.reason}`);
95
+ }
83
96
  console.log(ok ? "PASS" : "FAIL");
84
97
  process.exit(ok ? 0 : 1);
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
+ "packageManager": "pnpm@10.33.0",
6
7
  "author": "Kevin Scott <me@thekevinscott.com>",
7
8
  "repository": {
8
9
  "type": "git",
@@ -35,8 +36,11 @@
35
36
  },
36
37
  "scripts": {
37
38
  "build": "tsc -p tsconfig.build.json",
38
- "prepublishOnly": "pnpm build",
39
39
  "predict": "tsx src/predict.ts",
40
+ "prepare": "pnpm build",
41
+ "test": "vitest run",
42
+ "test:coverage": "vitest run --coverage",
43
+ "typecheck": "tsc -p tsconfig.json",
40
44
  "verify": "tsx src/verify.ts"
41
45
  },
42
46
  "dependencies": {
@@ -45,7 +49,10 @@
45
49
  },
46
50
  "devDependencies": {
47
51
  "@types/node": "^22.0.0",
52
+ "@vitest/coverage-v8": "^4.1.10",
53
+ "testing-conventions": "^0.0.91",
48
54
  "tsx": "^4.19.0",
49
- "typescript": "^5.6.0"
55
+ "typescript": "^5.6.0",
56
+ "vitest": "^4.1.10"
50
57
  }
51
58
  }