willfire 0.1.15 → 0.1.17

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.
@@ -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.15",
3
+ "version": "0.1.17",
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",
@@ -40,6 +40,7 @@
40
40
  "prepare": "pnpm build",
41
41
  "test": "vitest run",
42
42
  "test:coverage": "vitest run --coverage",
43
+ "test:e2e": "vitest run --config tests/e2e/vitest.config.mts",
43
44
  "typecheck": "tsc -p tsconfig.json",
44
45
  "verify": "tsx src/verify.ts"
45
46
  },