willfire 0.1.14 → 0.1.16

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/dist/execute.js CHANGED
@@ -40,19 +40,22 @@ import { UNKNOWN } from "./expr/val.js";
40
40
  /** `owner/repo:job1,job2` as the CLI spells a grant. */
41
41
  export function parseGrant(spec) {
42
42
  const colon = spec.indexOf(":");
43
- if (colon <= 0)
43
+ if (colon <= 0) {
44
44
  return null;
45
+ }
45
46
  const repo = spec.slice(0, colon);
46
47
  const parts = repo.split("/");
47
- if (parts.length !== 2 || parts.some((p) => p === ""))
48
+ if (parts.length !== 2 || parts.some((p) => p === "")) {
48
49
  return null;
50
+ }
49
51
  const jobs = spec
50
52
  .slice(colon + 1)
51
53
  .split(",")
52
54
  .map((s) => s.trim())
53
55
  .filter((s) => s !== "");
54
- if (jobs.length === 0)
56
+ if (jobs.length === 0) {
55
57
  return null;
58
+ }
56
59
  return { repo, jobs };
57
60
  }
58
61
  const err = (reason) => ({ ok: false, reason });
@@ -77,15 +80,18 @@ function renderTemplate(text, scope) {
77
80
  }
78
81
  /** An `env:` block rendered to concrete strings, every key or nothing. */
79
82
  function renderEnvLayer(layer, scope) {
80
- if (layer == null)
83
+ if (layer == null) {
81
84
  return { ok: true, v: {} };
82
- if (typeof layer !== "object" || Array.isArray(layer))
85
+ }
86
+ if (typeof layer !== "object" || Array.isArray(layer)) {
83
87
  return err("env block is not a map");
88
+ }
84
89
  const out = {};
85
90
  for (const [k, raw] of Object.entries(layer)) {
86
91
  const rendered = renderTemplate(String(raw ?? ""), scope);
87
- if (rendered == null)
92
+ if (rendered == null) {
88
93
  return err(`cannot resolve env '${k}'`);
94
+ }
89
95
  out[k] = rendered;
90
96
  }
91
97
  return { ok: true, v: out };
@@ -103,15 +109,17 @@ export function parseGithubOutput(text) {
103
109
  while (i < lines.length) {
104
110
  const line = lines[i];
105
111
  i++;
106
- if (line === "")
112
+ if (line === "") {
107
113
  continue;
114
+ }
108
115
  const heredoc = /^([^=<]+)<<(.+)$/.exec(line);
109
116
  if (heredoc != null) {
110
117
  const [, name, delim] = heredoc;
111
118
  const buf = [];
112
119
  for (;;) {
113
- if (i >= lines.length)
120
+ if (i >= lines.length) {
114
121
  return null; // unterminated heredoc
122
+ }
115
123
  if (lines[i] === delim) {
116
124
  i++;
117
125
  break;
@@ -123,8 +131,9 @@ export function parseGithubOutput(text) {
123
131
  continue;
124
132
  }
125
133
  const eq = line.indexOf("=");
126
- if (eq <= 0)
134
+ if (eq <= 0) {
127
135
  return null;
136
+ }
128
137
  out[line.slice(0, eq)] = line.slice(eq + 1);
129
138
  }
130
139
  return out;
@@ -136,17 +145,21 @@ export function parseGithubOutput(text) {
136
145
  * lives at the repo root. Expressions and `docker://` images return null.
137
146
  */
138
147
  function parseActionUses(uses) {
139
- if (uses.includes("${{") || uses.startsWith("docker://"))
148
+ if (uses.includes("${{") || uses.startsWith("docker://")) {
140
149
  return null;
150
+ }
141
151
  const at = uses.lastIndexOf("@");
142
- if (at <= 0)
152
+ if (at <= 0) {
143
153
  return null;
154
+ }
144
155
  const ref = uses.slice(at + 1);
145
- if (ref === "")
156
+ if (ref === "") {
146
157
  return null;
158
+ }
147
159
  const [owner, repo, ...rest] = uses.slice(0, at).split("/");
148
- if (!owner || !repo)
160
+ if (!owner || !repo) {
149
161
  return null;
162
+ }
150
163
  return { path: rest.join("/"), source: { owner, repo, ref } };
151
164
  }
152
165
  /** Read `action.yml` (or `.yaml`) from a directory, or null if neither exists. */
@@ -171,8 +184,9 @@ async function readActionManifest(dir) {
171
184
  */
172
185
  function bindActionInputs(action, withBlock, scope) {
173
186
  const bind = (raw) => {
174
- if (raw == null)
187
+ if (raw == null) {
175
188
  return { kind: "value", v: "" };
189
+ }
176
190
  if (typeof raw === "boolean" || typeof raw === "number") {
177
191
  return { kind: "value", v: String(raw) };
178
192
  }
@@ -213,13 +227,15 @@ async function runSteps(steps, scope, ctx) {
213
227
  const stepScope = { ...scope, steps: stepsCtx };
214
228
  if (step.if != null) {
215
229
  const verdict = evaluate(String(step.if), stepScope);
216
- if (verdict == null)
230
+ if (verdict == null) {
217
231
  return err(`cannot decide if: for ${label}`);
232
+ }
218
233
  if (!verdict) {
219
234
  // A skipped step still occupies its id, with no outputs — that is the
220
235
  // empty string every later read gets, and what `||` coalesces past.
221
- if (typeof step.id === "string")
236
+ if (typeof step.id === "string") {
222
237
  stepsCtx[step.id] = { outputs: {} };
238
+ }
223
239
  continue;
224
240
  }
225
241
  }
@@ -233,10 +249,12 @@ async function runSteps(steps, scope, ctx) {
233
249
  else {
234
250
  return err(`${label} has neither uses nor run`);
235
251
  }
236
- if (!res.ok)
252
+ if (!res.ok) {
237
253
  return res;
238
- if (typeof step.id === "string")
254
+ }
255
+ if (typeof step.id === "string") {
239
256
  stepsCtx[step.id] = { outputs: res.v };
257
+ }
240
258
  }
241
259
  return { ok: true, v: stepsCtx };
242
260
  }
@@ -263,12 +281,14 @@ async function runUses(step, label, scope, ctx) {
263
281
  }
264
282
  else {
265
283
  const target = parseActionUses(uses);
266
- if (target == null)
284
+ if (target == null) {
267
285
  return err(`${label}: unresolvable uses: ${uses}`);
286
+ }
268
287
  const { ref } = target.source;
269
288
  const sha = SHA_RE.test(ref) ? ref : await ctx.deps.resolveRef(target.source);
270
- if (sha == null)
289
+ if (sha == null) {
271
290
  return err(`${label}: cannot resolve ref for ${uses}`);
291
+ }
272
292
  const source = { ...target.source, sha };
273
293
  const root = await ctx.deps.provideTree(source);
274
294
  if (root == null) {
@@ -277,8 +297,9 @@ async function runUses(step, label, scope, ctx) {
277
297
  actionDir = target.path === "" ? root : join(root, target.path);
278
298
  }
279
299
  const manifest = await readActionManifest(actionDir);
280
- if (manifest == null)
300
+ if (manifest == null) {
281
301
  return err(`${label}: no action.yml under ${uses}`);
302
+ }
282
303
  let action;
283
304
  try {
284
305
  action = parseYaml(manifest);
@@ -302,19 +323,22 @@ async function runUses(step, label, scope, ctx) {
302
323
  actionPath: actionDir,
303
324
  depth: ctx.depth + 1,
304
325
  });
305
- if (!walked.ok)
326
+ if (!walked.ok) {
306
327
  return err(`${label} (${uses}): ${walked.reason}`);
328
+ }
307
329
  // The action's declared outputs are its whole surface: each `value:` is
308
330
  // evaluated against the child's own steps, and every one must land.
309
331
  const outScope = { ...childScope, steps: walked.v };
310
332
  const outputs = {};
311
333
  for (const [name, decl] of Object.entries(action.outputs ?? {})) {
312
334
  const raw = decl?.["value"];
313
- if (raw == null)
335
+ if (raw == null) {
314
336
  return err(`${label}: output '${name}' of ${uses} has no value`);
337
+ }
315
338
  const rendered = renderTemplate(String(raw), outScope);
316
- if (rendered == null)
339
+ if (rendered == null) {
317
340
  return err(`${label}: cannot resolve output '${name}' of ${uses}`);
341
+ }
318
342
  outputs[name] = rendered;
319
343
  }
320
344
  return { ok: true, v: outputs };
@@ -326,8 +350,9 @@ async function runRun(step, label, scope, ctx) {
326
350
  return err(`${label}: shell '${shell}' is not modelled`);
327
351
  }
328
352
  const script = renderTemplate(String(step.run), scope);
329
- if (script == null)
353
+ if (script == null) {
330
354
  return err(`${label}: cannot resolve \${{ }} in run`);
355
+ }
331
356
  const env = {
332
357
  // The two the runner always provides and scripts assume. Everything else
333
358
  // a step sees, it declared.
@@ -335,19 +360,22 @@ async function runRun(step, label, scope, ctx) {
335
360
  HOME: process.env.HOME ?? "",
336
361
  GITHUB_WORKSPACE: ctx.tree,
337
362
  };
338
- if (ctx.actionPath != null)
363
+ if (ctx.actionPath != null) {
339
364
  env.GITHUB_ACTION_PATH = ctx.actionPath;
365
+ }
340
366
  for (const layer of [...ctx.envLayers, step.env]) {
341
367
  const rendered = renderEnvLayer(layer, scope);
342
- if (!rendered.ok)
368
+ if (!rendered.ok) {
343
369
  return err(`${label}: ${rendered.reason}`);
370
+ }
344
371
  Object.assign(env, rendered.v);
345
372
  }
346
373
  let cwd = ctx.tree;
347
374
  if (step["working-directory"] != null) {
348
375
  const wd = renderTemplate(String(step["working-directory"]), scope);
349
- if (wd == null)
376
+ if (wd == null) {
350
377
  return err(`${label}: cannot resolve working-directory`);
378
+ }
351
379
  cwd = resolve(ctx.tree, wd);
352
380
  }
353
381
  const outDir = await mkdtemp(join(tmpdir(), "willfire-out-"));
@@ -362,8 +390,9 @@ async function runRun(step, label, scope, ctx) {
362
390
  return err(`${label}: exited ${r.code}${tail === "" ? "" : ` (${tail})`}`);
363
391
  }
364
392
  const outputs = parseGithubOutput(await readFile(outFile, "utf8"));
365
- if (outputs == null)
393
+ if (outputs == null) {
366
394
  return err(`${label}: malformed GITHUB_OUTPUT`);
395
+ }
367
396
  return { ok: true, v: outputs };
368
397
  }
369
398
  // ------------------------------------------------------------------ executor
@@ -382,13 +411,15 @@ export function makeExecutor(opts) {
382
411
  // The shapes execution does not model, refused by name rather than run
383
412
  // wrong: a matrix'd job is several executions, and a container changes
384
413
  // what every step means.
385
- if (job.strategy != null)
414
+ if (job.strategy != null) {
386
415
  return fail(`job '${jobId}' has a strategy; not modelled`);
416
+ }
387
417
  if (job.container != null || job.services != null) {
388
418
  return fail(`job '${jobId}' uses a container or services; not modelled`);
389
419
  }
390
- if (!Array.isArray(job.steps))
420
+ if (!Array.isArray(job.steps)) {
391
421
  return fail(`job '${jobId}' has no steps`);
422
+ }
392
423
  const tree = await deps.provideTree(workspace);
393
424
  if (tree == null) {
394
425
  return fail(`cannot materialize workspace ${workspace.owner}/${workspace.repo}@${workspace.sha}`);
@@ -400,8 +431,9 @@ export function makeExecutor(opts) {
400
431
  deps,
401
432
  depth: 0,
402
433
  });
403
- if (!walked.ok)
434
+ if (!walked.ok) {
404
435
  return fail(walked.reason);
436
+ }
405
437
  // The job's `outputs:` map is the whole point of having run anything.
406
438
  // Every declared entry must land; a hole here would hand consumers a
407
439
  // partial map, which the Scope contract calls a lie.
@@ -409,8 +441,9 @@ export function makeExecutor(opts) {
409
441
  const outputs = {};
410
442
  for (const [name, raw] of Object.entries(job.outputs ?? {})) {
411
443
  const rendered = renderTemplate(String(raw), outScope);
412
- if (rendered == null)
444
+ if (rendered == null) {
413
445
  return fail(`cannot resolve job output '${name}'`);
446
+ }
414
447
  outputs[name] = rendered;
415
448
  }
416
449
  return { ok: true, outputs };
@@ -436,8 +469,9 @@ export const runShell = (spec) => new Promise((resolvePromise) => {
436
469
  child.stderr.on("data", (d) => {
437
470
  stderr += String(d);
438
471
  // Keep the tail; a failure reason wants the last line, not a transcript.
439
- if (stderr.length > 4096)
472
+ if (stderr.length > 4096) {
440
473
  stderr = stderr.slice(-4096);
474
+ }
441
475
  });
442
476
  child.on("error", () => resolvePromise({ code: 127, stderr }));
443
477
  child.on("close", (code) => resolvePromise({ code: code ?? 1, stderr }));
@@ -454,8 +488,9 @@ export function makeTreeProvider(download, runCommand) {
454
488
  return (source) => {
455
489
  const key = `${source.owner}/${source.repo}@${source.sha}`;
456
490
  const hit = cache.get(key);
457
- if (hit !== undefined)
491
+ if (hit !== undefined) {
458
492
  return hit;
493
+ }
459
494
  const p = materialize(source, download, runCommand);
460
495
  cache.set(key, p);
461
496
  return p;
@@ -463,8 +498,9 @@ export function makeTreeProvider(download, runCommand) {
463
498
  }
464
499
  async function materialize(source, download, runCommand) {
465
500
  const bytes = await download(source);
466
- if (bytes == null)
501
+ if (bytes == null) {
467
502
  return null;
503
+ }
468
504
  const dir = await mkdtemp(join(tmpdir(), "willfire-tree-"));
469
505
  const archive = join(dir, "tree.tar.gz");
470
506
  await writeFile(archive, bytes);
@@ -480,13 +516,15 @@ async function materialize(source, download, runCommand) {
480
516
  WILLFIRE_DEST: dest,
481
517
  },
482
518
  });
483
- if (r.code !== 0)
519
+ if (r.code !== 0) {
484
520
  return null;
521
+ }
485
522
  const entries = await readdir(dest);
486
523
  if (entries.length === 1) {
487
524
  const sub = join(dest, entries[0]);
488
- if ((await stat(sub)).isDirectory())
525
+ if ((await stat(sub)).isDirectory()) {
489
526
  return sub;
527
+ }
490
528
  }
491
529
  return dest;
492
530
  }
@@ -5,8 +5,9 @@ export function matchFilters(value, patterns) {
5
5
  for (const pat of patterns) {
6
6
  const neg = pat.startsWith("!");
7
7
  const p = neg ? pat.slice(1) : pat;
8
- if (patternToRegex(p).test(value))
8
+ if (patternToRegex(p).test(value)) {
9
9
  matched = !neg;
10
+ }
10
11
  }
11
12
  return matched;
12
13
  }
@@ -9,10 +9,12 @@ import { prScope } from "./prScope.js";
9
9
  * `needs.*`.
10
10
  */
11
11
  export function evalIf(cond, scope = {}) {
12
- if (cond == null)
12
+ if (cond == null) {
13
13
  return "run";
14
+ }
14
15
  const verdict = evaluate(String(cond), prScope(scope));
15
- if (verdict === null)
16
+ if (verdict === null) {
16
17
  return "unknown";
18
+ }
17
19
  return verdict ? "run" : "skipped";
18
20
  }
@@ -16,22 +16,27 @@ import { prScope } from "./prScope.js";
16
16
  * existed; nothing regresses.
17
17
  */
18
18
  function inputLiteral(raw) {
19
- if (raw == null)
19
+ if (raw == null) {
20
20
  return { kind: "value", v: "" };
21
- if (typeof raw === "boolean" || typeof raw === "number")
21
+ }
22
+ if (typeof raw === "boolean" || typeof raw === "number") {
22
23
  return { kind: "value", v: raw };
23
- if (typeof raw === "string")
24
+ }
25
+ if (typeof raw === "string") {
24
26
  return raw.includes("${{") ? UNKNOWN : { kind: "value", v: raw };
27
+ }
25
28
  return UNKNOWN;
26
29
  }
27
30
  /** The `on.workflow_call.inputs` block, tolerating the YAML 1.1 `on` -> true key. */
28
31
  function workflowCallInputs(wf) {
29
32
  const on = wf?.["on"] ?? wf?.["true"];
30
- if (on == null || typeof on !== "object")
33
+ if (on == null || typeof on !== "object") {
31
34
  return {};
35
+ }
32
36
  const call = on["workflow_call"];
33
- if (call == null || typeof call !== "object")
37
+ if (call == null || typeof call !== "object") {
34
38
  return {};
39
+ }
35
40
  const inputs = call["inputs"];
36
41
  return inputs != null && typeof inputs === "object" ? inputs : {};
37
42
  }
@@ -87,11 +92,13 @@ export async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = ""
87
92
  const execFailures = {};
88
93
  if (executor != null) {
89
94
  for (const [jobId, jobRaw] of Object.entries(jobs)) {
90
- if (!executor.granted(source, jobId))
95
+ if (!executor.granted(source, jobId)) {
91
96
  continue;
97
+ }
92
98
  const job = jobRaw ?? {};
93
- if (evalIf(job.if, scoped) !== "run")
99
+ if (evalIf(job.if, scoped) !== "run") {
94
100
  continue;
101
+ }
95
102
  const res = await executor.executeJob(jobId, job, wf, scoped);
96
103
  if (res.ok) {
97
104
  scoped = { ...scoped, needs: { ...scoped.needs, [jobId]: { outputs: res.outputs } } };
@@ -110,8 +117,9 @@ export async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = ""
110
117
  let status = evalIf(job.if, scoped);
111
118
  let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
112
119
  let needs = job.needs ?? [];
113
- if (typeof needs === "string")
120
+ if (typeof needs === "string") {
114
121
  needs = [needs];
122
+ }
115
123
  const cond = String(job.if ?? "");
116
124
  if (status !== "skipped" && !cond.includes("always()")) {
117
125
  for (const n of needs) {
@@ -9,35 +9,43 @@ import { evaluateValue } from "../expr/evaluateValue.js";
9
9
  * the whole job `unknown` rather than a guess at how many checks it creates.
10
10
  */
11
11
  function axisValues(v, scope) {
12
- if (Array.isArray(v))
12
+ if (Array.isArray(v)) {
13
13
  return v;
14
- if (typeof v !== "string")
14
+ }
15
+ if (typeof v !== "string") {
15
16
  return null;
17
+ }
16
18
  const val = evaluateValue(v, scope);
17
- if (val.kind !== "json" || !Array.isArray(val.v))
19
+ if (val.kind !== "json" || !Array.isArray(val.v)) {
18
20
  return null;
21
+ }
19
22
  return val.v;
20
23
  }
21
24
  export function expandMatrixDetailed(strategy, scope = {}) {
22
25
  const matrix = strategy?.matrix;
23
- if (matrix == null)
26
+ if (matrix == null) {
24
27
  return [null];
28
+ }
25
29
  // `matrix: ${{ ... }}` — the whole matrix as one expression, rather than the
26
30
  // per-axis form below. It yields include-style entries, not axes, so it is a
27
31
  // separate expansion and is not modelled.
28
- if (typeof matrix === "string")
32
+ if (typeof matrix === "string") {
29
33
  return null;
34
+ }
30
35
  const include = matrix.include ?? [];
31
36
  const exclude = matrix.exclude ?? [];
32
- if (typeof include === "string" || typeof exclude === "string")
37
+ if (typeof include === "string" || typeof exclude === "string") {
33
38
  return null;
39
+ }
34
40
  const axes = {};
35
41
  for (const [k, v] of Object.entries(matrix)) {
36
- if (k === "include" || k === "exclude")
42
+ if (k === "include" || k === "exclude") {
37
43
  continue;
44
+ }
38
45
  const vals = axisValues(v, scope);
39
- if (vals == null)
46
+ if (vals == null) {
40
47
  return null;
48
+ }
41
49
  axes[k] = vals;
42
50
  }
43
51
  const axisKeys = Object.keys(axes);
@@ -45,8 +53,9 @@ export function expandMatrixDetailed(strategy, scope = {}) {
45
53
  for (const [k, vals] of Object.entries(axes)) {
46
54
  combos = combos.flatMap((c) => vals.map((v) => ({ values: { ...c.values, [k]: v }, displayKeys: axisKeys })));
47
55
  }
48
- if (axisKeys.length === 0)
56
+ if (axisKeys.length === 0) {
49
57
  combos = [];
58
+ }
50
59
  combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c.values[k] === v)));
51
60
  const extra = [];
52
61
  for (const inc of include) {
@@ -57,8 +66,9 @@ export function expandMatrixDetailed(strategy, scope = {}) {
57
66
  // matches every combination, per the docs ("added to each of the matrix
58
67
  // combinations if none of the key:value pairs overwrite any of the
59
68
  // original matrix values").
60
- for (const c of targets)
69
+ for (const c of targets) {
61
70
  Object.assign(c.values, inc);
71
+ }
62
72
  }
63
73
  else {
64
74
  // No combination to attach to: the include entry becomes a combination
@@ -6,11 +6,14 @@
6
6
  * `m-object (linux, x64)`.
7
7
  */
8
8
  export function formatMatrixValue(v) {
9
- if (v == null)
9
+ if (v == null) {
10
10
  return "";
11
- if (Array.isArray(v))
11
+ }
12
+ if (Array.isArray(v)) {
12
13
  return v.map(formatMatrixValue).join(", ");
13
- if (typeof v === "object")
14
+ }
15
+ if (typeof v === "object") {
14
16
  return Object.values(v).map(formatMatrixValue).join(", ");
17
+ }
15
18
  return String(v);
16
19
  }
@@ -2,7 +2,8 @@ import { formatMatrixValue } from "./formatMatrixValue.js";
2
2
  /** The ` (v1, v2)` suffix GitHub appends for a matrix combination. */
3
3
  export function matrixSuffix(combo) {
4
4
  const keys = combo.displayKeys.filter((k) => k in combo.values);
5
- if (keys.length === 0)
5
+ if (keys.length === 0) {
6
6
  return "";
7
+ }
7
8
  return ` (${keys.map((k) => formatMatrixValue(combo.values[k])).join(", ")})`;
8
9
  }
@@ -7,8 +7,9 @@ export function finalizePrediction(entries, skip, sources) {
7
7
  const final = entries.map(finalize);
8
8
  const names = new Set();
9
9
  for (const e of final) {
10
- if (e.status === "run" && e.checkName != null)
10
+ if (e.status === "run" && e.checkName !== null) {
11
11
  names.add(e.checkName);
12
+ }
12
13
  }
13
14
  return {
14
15
  entries: final,
@@ -1,7 +1,8 @@
1
1
  import { Octokit } from "@octokit/rest";
2
2
  export function makeOctokit() {
3
3
  const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
4
- if (!token)
4
+ if (!token) {
5
5
  throw new Error("GH_TOKEN or GITHUB_TOKEN must be set");
6
+ }
6
7
  return new Octokit({ auth: token });
7
8
  }
@@ -30,7 +30,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
30
30
  // is a guess kept only so existing callers keep working.
31
31
  action: opts.action ?? (pr.commits > 1 ? "synchronize" : "opened"),
32
32
  baseRef: pr.base.ref,
33
- ...(stackTarget != null ? { stackTarget } : {}),
33
+ ...(stackTarget !== null ? { stackTarget } : {}),
34
34
  files: files.map((f) => f.filename),
35
35
  };
36
36
  const headSha = pr.head.sha;
@@ -58,8 +58,9 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
58
58
  const resolveRef = async (src) => {
59
59
  const key = sourceKey(src);
60
60
  const hit = refCache.get(key);
61
- if (hit !== undefined)
61
+ if (hit !== undefined) {
62
62
  return hit;
63
+ }
63
64
  let sha;
64
65
  try {
65
66
  const { data } = await octokit.rest.repos.getCommit({
@@ -75,8 +76,9 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
75
76
  sha = null;
76
77
  }
77
78
  refCache.set(key, sha);
78
- if (sha != null)
79
+ if (sha !== null) {
79
80
  sources.set(key, { ...src, sha });
81
+ }
80
82
  return sha;
81
83
  };
82
84
  // One callee is commonly reached from several callers — a fleet repo calls
@@ -89,8 +91,9 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
89
91
  // that moves mid-prediction cannot hand back two different files.
90
92
  const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
91
93
  const hit = cache.get(key);
92
- if (hit !== undefined)
94
+ if (hit !== undefined) {
93
95
  return hit;
96
+ }
94
97
  let content;
95
98
  try {
96
99
  const { data } = await octokit.rest.repos.getContent({
@@ -115,7 +118,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
115
118
  // from the tarball endpoint at the resolved commit, and every subprocess —
116
119
  // `tar` included — goes through the one `runShell` seam.
117
120
  let executor;
118
- if (opts.execute != null && opts.execute.length > 0) {
121
+ if (opts.execute !== undefined && opts.execute.length > 0) {
119
122
  const download = async (src) => {
120
123
  try {
121
124
  const { data } = await octokit.rest.repos.downloadTarballArchive({
@@ -152,32 +155,20 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
152
155
  const prFacts = {
153
156
  github: { repository: `${headSource.owner}/${headSource.repo}` },
154
157
  };
155
- const entries = [];
156
- for (const w of workflows) {
157
- const path = w.path;
158
- if (!path.startsWith(".github/workflows/"))
159
- continue;
160
- if (w.state !== "active") {
161
- entries.push({
162
- workflow: path,
163
- job: "*",
164
- status: "no-dispatch",
165
- reason: `workflow state: ${w.state}`,
166
- });
167
- continue;
158
+ const workflowEntries = async (path, state) => {
159
+ if (state !== "active") {
160
+ return [
161
+ { workflow: path, job: "*", status: "no-dispatch", reason: `workflow state: ${state}` },
162
+ ];
168
163
  }
169
164
  const content = await fetchWorkflow(path, headSource);
170
- if (content == null) {
165
+ if (content === null) {
171
166
  // The Actions API keeps listing a workflow as `active` after its file is
172
167
  // deleted. There is no file at head, so there is nothing to dispatch —
173
168
  // the same verdict as the disabled case above, reached a different way.
174
- entries.push({
175
- workflow: path,
176
- job: "*",
177
- status: "no-dispatch",
178
- reason: "no workflow file at head",
179
- });
180
- continue;
169
+ return [
170
+ { workflow: path, job: "*", status: "no-dispatch", reason: "no workflow file at head" },
171
+ ];
181
172
  }
182
173
  let wf;
183
174
  try {
@@ -187,27 +178,25 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
187
178
  // GitHub creates a run for an unparseable workflow file and concludes it
188
179
  // `startup_failure`. The run exists but has no jobs, so this is a
189
180
  // workflow-level "it dispatches" with nothing to expand.
190
- entries.push({
191
- workflow: path,
192
- job: "*",
193
- status: "run",
194
- reason: `YAML parse error: ${e}`,
195
- });
196
- continue;
181
+ return [{ workflow: path, job: "*", status: "run", reason: `YAML parse error: ${e}` }];
197
182
  }
198
183
  const [dispatches, reason] = workflowDispatches(wf, ctx);
199
184
  if (!dispatches) {
200
- entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
201
- continue;
185
+ return [{ workflow: path, job: "*", status: "no-dispatch", reason }];
202
186
  }
203
- for (const j of await expandJobs(wf, ctx, reader, headSource, 0, "", true, prFacts, executor)) {
204
- entries.push({
205
- workflow: path,
206
- job: jobName(j.job),
207
- checkName: j.checkName,
208
- status: j.status,
209
- reason: j.reason || reason,
210
- });
187
+ const jobs = await expandJobs(wf, ctx, reader, headSource, 0, "", true, prFacts, executor);
188
+ return jobs.map((j) => ({
189
+ workflow: path,
190
+ job: jobName(j.job),
191
+ checkName: j.checkName,
192
+ status: j.status,
193
+ reason: j.reason || reason,
194
+ }));
195
+ };
196
+ const entries = [];
197
+ for (const w of workflows) {
198
+ if (w.path.startsWith(".github/workflows/")) {
199
+ entries.push(...(await workflowEntries(w.path, w.state)));
211
200
  }
212
201
  }
213
202
  return finalizePrediction(entries, null, sources);
@@ -17,24 +17,27 @@ export async function stackTargetRef(octokit, owner, repo, pr) {
17
17
  try {
18
18
  for (let hop = 0; hop < MAX_STACK_DEPTH; hop++) {
19
19
  const mergeSha = cur.merge_commit_sha;
20
- if (mergeSha == null)
20
+ if (mergeSha === null) {
21
21
  break;
22
+ }
22
23
  const { data: preview } = await octokit.rest.repos.getCommit({
23
24
  owner,
24
25
  repo,
25
26
  ref: mergeSha,
26
27
  });
27
28
  const previewParent = preview.parents[0]?.sha;
28
- if (previewParent == null)
29
+ if (previewParent === undefined) {
29
30
  break;
31
+ }
30
32
  const { data: baseTip } = await octokit.rest.repos.getCommit({
31
33
  owner,
32
34
  repo,
33
35
  ref: cur.base.ref,
34
36
  });
35
37
  // Built on the base branch tip: normal mode, the walk is done.
36
- if (previewParent === baseTip.sha)
38
+ if (previewParent === baseTip.sha) {
37
39
  break;
40
+ }
38
41
  // Otherwise only an exact match against an open PR whose head is the
39
42
  // base branch proves stacked mode; a stale preview matches nothing.
40
43
  const { data: candidates } = await octokit.rest.pulls.list({
@@ -45,8 +48,9 @@ export async function stackTargetRef(octokit, owner, repo, pr) {
45
48
  per_page: 100,
46
49
  });
47
50
  const parent = candidates.find((p) => p.merge_commit_sha === previewParent);
48
- if (parent == null)
51
+ if (parent === undefined) {
49
52
  break;
53
+ }
50
54
  target = parent.base.ref;
51
55
  cur = parent;
52
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@10.33.0",