willfire 0.1.2 → 0.1.4

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,10 +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);
24
- // entries: [{ workflow, job, status: "run" | "skipped" | "unknown" | "no-dispatch", reason }]
23
+ const { entries, checkNames, skip } = await predict(getOctokit(token), "owner/repo", 123);
24
+ // checkNames: sorted, deduped checkName of every entry with status "run"
25
25
  ```
26
26
 
27
+ `entries` is a union of two variants, both carrying `workflow` and `reason`:
28
+
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"` |
33
+
34
+ `"unknown"` is job-level only: every workflow-level verdict is decidable, so a
35
+ `WorkflowEntry` cannot express one. Narrow with the exported `isWorkflowEntry`
36
+ and `isJobEntry` guards rather than testing the `"*"` sentinel yourself.
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
+
27
53
  Auth is any token with `contents: read`, `actions: read`, and
28
54
  `pull-requests: read` — inside an action, the workflow's `GITHUB_TOKEN`.
29
55
 
@@ -54,6 +80,23 @@ workflow per dispatch rule, and probe PRs exercise each complication
54
80
  non-default branches). The `verify` script diffs predictions against the
55
81
  check entries GitHub actually created. All probe PRs currently pass exactly.
56
82
 
83
+ Check-name resolution is verified the same way, on probe PR #8. `pnpm test`
84
+ replays those probe workflows through the resolver and asserts the exact job
85
+ names the live run produced. Several of the rules it pins are ones the docs do
86
+ not state:
87
+
88
+ - a `name:` containing *any* `${{ }}` expression suppresses the matrix
89
+ parenthetical, even an expression that never reads the matrix — a literal
90
+ `name:` still gets one, so `name: Static Label` over `a: [x, y]` yields
91
+ `Static Label (x)` and `Static Label (y)`;
92
+ - keys an `include` entry merges into an existing combination do not appear in
93
+ the name, but keys of a combination the `include` created from scratch do;
94
+ - object matrix values flatten to their own values, so `{os: linux, arch: x64}`
95
+ renders as `(linux, x64)`;
96
+ - a skipped job is never set up, so its matrix does not expand and its `name:`
97
+ is not interpolated: one check, expression text and all.
98
+
57
99
  Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
58
100
  live events, cross-repo reusable workflows, `branches-ignore`, and diffs far
59
- beyond 301 files are not yet probe-verified.
101
+ beyond 301 files are not yet probe-verified. Reusable-workflow name prefixing
102
+ is probe-verified to three levels; deeper nesting is inferred.
package/dist/predict.d.ts CHANGED
@@ -1,23 +1,107 @@
1
1
  #!/usr/bin/env node
2
2
  import { Octokit } from "@octokit/rest";
3
- export interface Entry {
3
+ interface EntryBase {
4
4
  workflow: string;
5
- job: string;
6
- status: "run" | "skipped" | "unknown" | "no-dispatch";
7
5
  reason: string;
8
6
  }
7
+ /**
8
+ * A job's display name, e.g. `test (18)`.
9
+ *
10
+ * Nominally distinct from `string` for one reason: `Entry` is a closed union
11
+ * and `"*"` is the workflow-level sentinel, so a plain `string` here would
12
+ * structurally admit `{ job: "*", status: "unknown" }` as a `JobEntry` — the
13
+ * exact shape this split exists to forbid. TypeScript cannot spell "string
14
+ * but not `"*"`", so the job side is branded instead. Build one with
15
+ * {@link jobName}; reading one is just a string.
16
+ */
17
+ export type JobName = string & {
18
+ readonly __jobName: true;
19
+ };
20
+ /** Tag a job display name. Rejects the workflow-level sentinel. */
21
+ export declare const jobName: <S extends string>(name: S extends "*" ? never : S) => JobName;
22
+ /**
23
+ * A verdict about the workflow as a whole: it produces no run at all, or it
24
+ * produces a run that expands into no job entries.
25
+ *
26
+ * There is no `"unknown"` here, and that is the point. Every workflow-level
27
+ * verdict is decidable, and this type is what enforces it — the previous
28
+ * single-interface shape let `{ job: "*", status: "unknown" }` typecheck, which
29
+ * is what shipped and what pr-monitor#17 had to grow a `tolerated` bucket for.
30
+ */
31
+ export interface WorkflowEntry extends EntryBase {
32
+ job: "*";
33
+ /** Always null: a workflow-level verdict is about the run, not a named check. */
34
+ checkName: null;
35
+ status: "run" | "skipped" | "no-dispatch";
36
+ }
37
+ /**
38
+ * A verdict about one job entry inside a workflow that does dispatch.
39
+ *
40
+ * These can be genuinely undecidable statically — dynamic matrix, non-local
41
+ * reusable workflow, unresolvable `if`, or `needs` on any of those — so
42
+ * `"unknown"` lives here and only here.
43
+ */
44
+ export interface JobEntry extends EntryBase {
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;
56
+ status: "run" | "skipped" | "unknown" | "no-dispatch";
57
+ }
58
+ export type Entry = WorkflowEntry | JobEntry;
59
+ /** Narrow to the workflow-level variant without inspecting the sentinel. */
60
+ export declare const isWorkflowEntry: (e: Entry) => e is WorkflowEntry;
61
+ /** Narrow to the job-level variant without inspecting the sentinel. */
62
+ export declare const isJobEntry: (e: Entry) => e is JobEntry;
9
63
  export interface Prediction {
10
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[];
11
71
  skip: string | null;
12
72
  }
13
73
  export declare function patternToRegex(pat: string): RegExp;
14
74
  /** Order-sensitive match: last matching pattern wins; ! negates. */
15
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>;
16
82
  type Combo = Record<string, any> | null;
17
83
  /** Return list of matrix combination dicts, or null if dynamic. */
18
84
  export declare function expandMatrix(strategy: any): Combo[] | null;
19
85
  /** Return run|skipped|unknown for a job-level if. */
20
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[]>;
21
105
  export declare function makeOctokit(): Octokit;
22
106
  export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
23
107
  export {};
package/dist/predict.js CHANGED
@@ -6,9 +6,21 @@
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";
14
+ /** Tag a job display name. Rejects the workflow-level sentinel. */
15
+ export const jobName = (name) => name;
16
+ /** Narrow to the workflow-level variant without inspecting the sentinel. */
17
+ export const isWorkflowEntry = (e) => e.job === "*";
18
+ /** Narrow to the job-level variant without inspecting the sentinel. */
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 };
12
24
  // ------------------------------------------------- GitHub filter pattern glob
13
25
  // Grammar per docs: * (any chars except /), ** (any chars), ? (zero or one of
14
26
  // preceding char), + (one or more of preceding char), [ranges], leading ! negates.
@@ -112,8 +124,7 @@ function workflowDispatches(wf, ctx) {
112
124
  }
113
125
  return [true, "trigger matched"];
114
126
  }
115
- /** Return list of matrix combination dicts, or null if dynamic. */
116
- export function expandMatrix(strategy) {
127
+ function expandMatrixDetailed(strategy) {
117
128
  const matrix = strategy?.matrix;
118
129
  if (matrix == null)
119
130
  return [null];
@@ -131,44 +142,134 @@ export function expandMatrix(strategy) {
131
142
  return null;
132
143
  axes[k] = v;
133
144
  }
134
- let combos = [{}];
145
+ const axisKeys = Object.keys(axes);
146
+ let combos = [{ values: {}, displayKeys: axisKeys }];
135
147
  for (const [k, vals] of Object.entries(axes)) {
136
- 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 })));
137
149
  }
138
- if (Object.keys(axes).length === 0)
150
+ if (axisKeys.length === 0)
139
151
  combos = [];
140
- 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)));
141
153
  const extra = [];
142
154
  for (const inc of include) {
143
155
  const overlapping = Object.fromEntries(Object.entries(inc).filter(([k]) => k in axes));
144
- const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c[k] === v));
145
- 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").
146
162
  for (const c of targets)
147
- Object.assign(c, inc);
163
+ Object.assign(c.values, inc);
148
164
  }
149
165
  else {
150
- 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) });
151
169
  }
152
170
  }
153
171
  combos.push(...extra);
154
172
  return combos.length > 0 ? combos : [null];
155
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
+ }
156
224
  function renderName(template, combo) {
157
- return template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
225
+ let resolved = true;
226
+ const text = template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
158
227
  const expr = String(inner).trim();
159
- if (expr.startsWith("matrix.") && combo) {
160
- 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);
161
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;
162
244
  return whole;
163
245
  });
246
+ return { text, resolved };
164
247
  }
248
+ /** The check name for one job/combination. */
165
249
  function jobDisplayName(jobId, job, combo) {
166
- if ("name" in job && job.name != null)
167
- return renderName(String(job.name), combo);
168
- let name = jobId;
169
- if (combo)
170
- name += ` (${Object.values(combo).map(String).join(", ")})`;
171
- 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 };
172
273
  }
173
274
  /** Return run|skipped|unknown for a job-level if. */
174
275
  export function evalIf(cond) {
@@ -188,7 +289,13 @@ export function evalIf(cond) {
188
289
  }
189
290
  return "unknown";
190
291
  }
191
- 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) {
192
299
  const entries = [];
193
300
  const jobs = wf.jobs ?? {};
194
301
  const statuses = {};
@@ -213,44 +320,105 @@ async function expandJobs(wf, ctx, fetchFile, depth = 0, prefix = "") {
213
320
  }
214
321
  }
215
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);
216
337
  if ("uses" in job) {
217
- // 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.
218
341
  const uses = job.uses;
219
- const baseName = prefix + (job.name != null ? String(job.name) : jobId);
220
- if (depth >= 1) {
221
- 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
+ });
222
349
  continue;
223
350
  }
351
+ // Resolve the called workflow once, not once per matrix combination.
352
+ let subWf = null;
353
+ let failure = null;
224
354
  const m = uses.match(/^\.\/(.+)$/);
225
- if (!m) {
226
- entries.push([baseName, "unknown", `non-local reusable: ${uses}`]);
227
- continue;
355
+ if (depth + 1 > MAX_REUSABLE_DEPTH) {
356
+ failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
228
357
  }
229
- const content = await fetchFile(m[1]);
230
- if (content == null) {
231
- entries.push([baseName, "unknown", `cannot fetch ${uses}`]);
232
- continue;
358
+ else if (!m) {
359
+ failure = `non-local reusable: ${uses}`;
233
360
  }
234
- if (status === "skipped") {
235
- entries.push([baseName, "skipped", reason]);
236
- 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)));
237
389
  }
238
- const subWf = parseYaml(content);
239
- const sub = await expandJobs(subWf, ctx, fetchFile, depth + 1, `${baseName} / `);
240
- entries.push(...sub);
241
390
  continue;
242
391
  }
243
- const combos = expandMatrix(job.strategy);
244
392
  if (combos == null) {
245
- 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
+ });
246
399
  continue;
247
400
  }
248
401
  for (const combo of combos) {
249
- 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
+ });
250
410
  }
251
411
  }
252
412
  return entries;
253
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
+ }
254
422
  // ------------------------------------------------------------------- pipeline
255
423
  export function makeOctokit() {
256
424
  const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
@@ -279,7 +447,7 @@ export async function predict(octokit, repo, prNumber) {
279
447
  });
280
448
  const headMsg = headCommit.commit.message;
281
449
  if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
282
- return { entries: [], skip: "head commit message contains a skip instruction" };
450
+ return finalizePrediction([], "head commit message contains a skip instruction");
283
451
  }
284
452
  const fetchFile = async (path) => {
285
453
  try {
@@ -347,11 +515,26 @@ export async function predict(octokit, repo, prNumber) {
347
515
  entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
348
516
  continue;
349
517
  }
350
- for (const [jobName, status, jreason] of await expandJobs(wf, ctx, fetchFile)) {
351
- entries.push({ workflow: path, job: jobName, 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
+ });
352
526
  }
353
527
  }
354
- 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 };
355
538
  }
356
539
  // ------------------------------------------------------------------------ CLI
357
540
  function parseArgs(argv) {
@@ -370,19 +553,21 @@ function parseArgs(argv) {
370
553
  const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
371
554
  if (isMain) {
372
555
  const args = parseArgs(process.argv.slice(2));
373
- const { entries, skip } = await predict(makeOctokit(), args.repo, args.pr);
556
+ const { entries, checkNames, skip } = await predict(makeOctokit(), args.repo, args.pr);
374
557
  if (args.json) {
375
- console.log(JSON.stringify({ entries, skip }, null, 2));
558
+ console.log(JSON.stringify({ entries, checkNames, skip }, null, 2));
376
559
  }
377
560
  else if (skip) {
378
561
  console.log(`# ${skip} -> nothing dispatches`);
379
562
  }
380
563
  else {
381
564
  for (const e of entries) {
382
- if (e.job === "*")
565
+ if (isWorkflowEntry(e))
383
566
  console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
384
- else
385
- 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
+ }
386
571
  }
387
572
  }
388
573
  }
package/dist/verify.js CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Ground truth: workflow runs for the PR head SHA with a pull_request event,
6
6
  // and the job entries inside each run (skipped jobs included).
7
- import { makeOctokit, predict } from "./predict.js";
7
+ import { isJobEntry, makeOctokit, predict } from "./predict.js";
8
8
  async function actualEntries(octokit, repo, prNumber) {
9
9
  const [owner, name] = repo.split("/");
10
10
  const base = { owner, repo: name };
@@ -44,10 +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
+ // 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.
47
50
  const predicted = new Map(predictedRaw
48
- .filter((r) => r.job !== "*")
49
- .map((r) => [`${r.workflow} :: ${r.job}`, r.status]));
50
- const unknownWfs = new Set(predictedRaw.filter((r) => r.status === "unknown").map((r) => r.workflow));
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)));
51
59
  const { entries: actual, incomplete } = await actualEntries(octokit, repo, pr);
52
60
  if (incomplete.length > 0) {
53
61
  console.log(`WARNING: runs still in progress: ${incomplete}`);
@@ -82,10 +90,8 @@ for (const key of keys) {
82
90
  console.log(`DIFF ${key} :: predicted ${p}, actual ${a}`);
83
91
  }
84
92
  }
85
- for (const r of predictedRaw) {
86
- if (r.job === "*" && r.status === "unknown") {
87
- console.log(` ? ${r.workflow} :: workflow-level unknown: ${r.reason}`);
88
- }
93
+ for (const r of unresolved) {
94
+ console.log(` ? ${r.workflow} :: ${r.job} :: name unresolved: ${r.reason}`);
89
95
  }
90
96
  console.log(ok ? "PASS" : "FAIL");
91
97
  process.exit(ok ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
6
  "author": "Kevin Scott <me@thekevinscott.com>",
@@ -36,6 +36,8 @@
36
36
  "scripts": {
37
37
  "build": "tsc -p tsconfig.build.json",
38
38
  "prepublishOnly": "pnpm build",
39
+ "typecheck": "tsc -p tsconfig.json",
40
+ "test": "node --import tsx --test src/*.test.ts",
39
41
  "predict": "tsx src/predict.ts",
40
42
  "verify": "tsx src/verify.ts"
41
43
  },