willfire 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +86 -5
  2. package/dist/cli/index.d.ts +1 -0
  3. package/dist/cli/index.js +1 -0
  4. package/dist/cli/parseArgs.d.ts +9 -0
  5. package/dist/cli/parseArgs.js +42 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +41 -0
  8. package/dist/entries/index.d.ts +3 -0
  9. package/dist/entries/index.js +3 -0
  10. package/dist/entries/isJobEntry.d.ts +3 -0
  11. package/dist/entries/isJobEntry.js +2 -0
  12. package/dist/entries/isWorkflowEntry.d.ts +3 -0
  13. package/dist/entries/isWorkflowEntry.js +2 -0
  14. package/dist/entries/jobName.d.ts +3 -0
  15. package/dist/entries/jobName.js +2 -0
  16. package/dist/execute.d.ts +123 -0
  17. package/dist/execute.js +490 -0
  18. package/dist/expr.d.ts +51 -3
  19. package/dist/expr.js +85 -18
  20. package/dist/filters/index.d.ts +2 -0
  21. package/dist/filters/index.js +2 -0
  22. package/dist/filters/matchFilters.d.ts +2 -0
  23. package/dist/filters/matchFilters.js +12 -0
  24. package/dist/filters/patternToRegex.d.ts +1 -0
  25. package/dist/filters/patternToRegex.js +35 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.js +6 -0
  28. package/dist/jobs/evalIf.d.ts +10 -0
  29. package/dist/jobs/evalIf.js +18 -0
  30. package/dist/jobs/expandJobs.d.ts +4 -0
  31. package/dist/jobs/expandJobs.js +247 -0
  32. package/dist/jobs/expandWorkflowJobs.d.ts +14 -0
  33. package/dist/jobs/expandWorkflowJobs.js +14 -0
  34. package/dist/jobs/index.d.ts +4 -0
  35. package/dist/jobs/index.js +4 -0
  36. package/dist/jobs/prScope.d.ts +10 -0
  37. package/dist/jobs/prScope.js +19 -0
  38. package/dist/matrix/expandMatrix.d.ts +4 -0
  39. package/dist/matrix/expandMatrix.js +6 -0
  40. package/dist/matrix/expandMatrixDetailed.d.ts +3 -0
  41. package/dist/matrix/expandMatrixDetailed.js +74 -0
  42. package/dist/matrix/formatMatrixValue.d.ts +8 -0
  43. package/dist/matrix/formatMatrixValue.js +16 -0
  44. package/dist/matrix/index.d.ts +4 -0
  45. package/dist/matrix/index.js +4 -0
  46. package/dist/matrix/matrixSuffix.d.ts +3 -0
  47. package/dist/matrix/matrixSuffix.js +8 -0
  48. package/dist/names/index.d.ts +4 -0
  49. package/dist/names/index.js +4 -0
  50. package/dist/names/jobDisplayName.d.ts +16 -0
  51. package/dist/names/jobDisplayName.js +25 -0
  52. package/dist/names/lookupPath.d.ts +1 -0
  53. package/dist/names/lookupPath.js +9 -0
  54. package/dist/names/renderName.d.ts +2 -0
  55. package/dist/names/renderName.js +26 -0
  56. package/dist/names/skippedDisplayName.d.ts +14 -0
  57. package/dist/names/skippedDisplayName.js +16 -0
  58. package/dist/predict/finalizePrediction.d.ts +2 -0
  59. package/dist/predict/finalizePrediction.js +19 -0
  60. package/dist/predict/index.d.ts +5 -0
  61. package/dist/predict/index.js +5 -0
  62. package/dist/predict/makeOctokit.d.ts +2 -0
  63. package/dist/predict/makeOctokit.js +7 -0
  64. package/dist/predict/predict.d.ts +3 -0
  65. package/dist/predict/predict.js +214 -0
  66. package/dist/predict/sourceKey.d.ts +3 -0
  67. package/dist/predict/sourceKey.js +2 -0
  68. package/dist/predict/stackTargetRef.d.ts +14 -0
  69. package/dist/predict/stackTargetRef.js +58 -0
  70. package/dist/triggers/getPrTrigger.d.ts +3 -0
  71. package/dist/triggers/getPrTrigger.js +18 -0
  72. package/dist/triggers/index.d.ts +2 -0
  73. package/dist/triggers/index.js +2 -0
  74. package/dist/triggers/workflowDispatches.d.ts +2 -0
  75. package/dist/triggers/workflowDispatches.js +45 -0
  76. package/dist/{predict.d.ts → types.d.ts} +75 -46
  77. package/dist/types.js +1 -0
  78. package/dist/uses/index.d.ts +1 -0
  79. package/dist/uses/index.js +1 -0
  80. package/dist/uses/parseUses.d.ts +15 -0
  81. package/dist/uses/parseUses.js +33 -0
  82. package/dist/verify.js +1 -1
  83. package/package.json +7 -7
  84. package/dist/predict.js +0 -785
@@ -0,0 +1,45 @@
1
+ import { matchFilters } from "../filters/matchFilters.js";
2
+ import { getPrTrigger, MISSING } from "./getPrTrigger.js";
3
+ const DEFAULT_TYPES = ["opened", "synchronize", "reopened"];
4
+ // A predicate: does this workflow produce a run for the PR? Every workflow-level
5
+ // verdict is decidable, so there is no third answer to express. Only job
6
+ // expansion can be genuinely undecidable (dynamic matrix, an unreadable
7
+ // reusable workflow, unresolvable `if`), and that is a per-entry status.
8
+ export function workflowDispatches(wf, ctx) {
9
+ const trig = getPrTrigger(wf);
10
+ if (trig === MISSING)
11
+ return [false, "no pull_request trigger"];
12
+ const types = trig["types"] ?? DEFAULT_TYPES;
13
+ if (!types.includes(ctx.action)) {
14
+ return [false, `action '${ctx.action}' not in types [${types}]`];
15
+ }
16
+ // Setting a filter and its -ignore twin on one trigger is invalid config.
17
+ // GitHub does not fall back to "no filter" or skip the workflow: it creates
18
+ // the run and concludes `startup_failure`. The run exists, so it dispatches.
19
+ if ("branches" in trig && "branches-ignore" in trig) {
20
+ return [true, "both branches and branches-ignore set: startup failure"];
21
+ }
22
+ const branchRef = ctx.stackTarget ?? ctx.baseRef;
23
+ if ("branches" in trig && !matchFilters(branchRef, trig["branches"])) {
24
+ const label = ctx.stackTarget == null ? "base branch" : "stack target";
25
+ return [false, `${label} '${branchRef}' not in branches`];
26
+ }
27
+ if ("branches-ignore" in trig && matchFilters(branchRef, trig["branches-ignore"])) {
28
+ return [
29
+ false,
30
+ ctx.stackTarget == null
31
+ ? "base branch in branches-ignore"
32
+ : `stack target '${branchRef}' in branches-ignore`,
33
+ ];
34
+ }
35
+ if ("paths" in trig && "paths-ignore" in trig) {
36
+ return [true, "both paths and paths-ignore set: startup failure"];
37
+ }
38
+ if ("paths" in trig && !ctx.files.some((f) => matchFilters(f, trig["paths"]))) {
39
+ return [false, "no changed file matches paths"];
40
+ }
41
+ if ("paths-ignore" in trig && ctx.files.every((f) => matchFilters(f, trig["paths-ignore"]))) {
42
+ return [false, "all changed files match paths-ignore"];
43
+ }
44
+ return [true, "trigger matched"];
45
+ }
@@ -1,7 +1,5 @@
1
- #!/usr/bin/env node
2
- import { Octokit } from "@octokit/rest";
3
- import { type Scope } from "./expr.js";
4
- interface EntryBase {
1
+ import type { ExecutionGrant } from "./execute.js";
2
+ export interface EntryBase {
5
3
  workflow: string;
6
4
  reason: string;
7
5
  }
@@ -13,13 +11,11 @@ interface EntryBase {
13
11
  * structurally admit `{ job: "*", status: "unknown" }` as a `JobEntry` — the
14
12
  * exact shape this split exists to forbid. TypeScript cannot spell "string
15
13
  * but not `"*"`", so the job side is branded instead. Build one with
16
- * {@link jobName}; reading one is just a string.
14
+ * `jobName`; reading one is just a string.
17
15
  */
18
16
  export type JobName = string & {
19
17
  readonly __jobName: true;
20
18
  };
21
- /** Tag a job display name. Rejects the workflow-level sentinel. */
22
- export declare const jobName: <S extends string>(name: S extends "*" ? never : S) => JobName;
23
19
  /**
24
20
  * A verdict about the workflow as a whole: it produces no run at all, or it
25
21
  * produces a run that expands into no job entries.
@@ -62,10 +58,6 @@ export interface JobEntry extends EntryBase {
62
58
  status: "run" | "skipped" | "unknown";
63
59
  }
64
60
  export type Entry = WorkflowEntry | JobEntry;
65
- /** Narrow to the workflow-level variant without inspecting the sentinel. */
66
- export declare const isWorkflowEntry: (e: Entry) => e is WorkflowEntry;
67
- /** Narrow to the job-level variant without inspecting the sentinel. */
68
- export declare const isJobEntry: (e: Entry) => e is JobEntry;
69
61
  export interface Prediction {
70
62
  entries: Entry[];
71
63
  /**
@@ -85,9 +77,22 @@ export interface Prediction {
85
77
  */
86
78
  sources: WorkflowSource[];
87
79
  }
88
- export declare function patternToRegex(pat: string): RegExp;
89
- /** Order-sensitive match: last matching pattern wins; ! negates. */
90
- export declare function matchFilters(value: string, patterns: string[]): boolean;
80
+ /**
81
+ * Internal entry shapes. `checkName` is optional while entries are being
82
+ * accumulated so that workflow-level pushes stay one-liners; `finalize`
83
+ * settles the omitted ones to null.
84
+ *
85
+ * Deliberately a union of two Omits rather than `Omit<Entry, "checkName">`.
86
+ * `Omit` over a union collapses to its common keys, which would re-widen
87
+ * `job`/`status` back into the single flat shape that let
88
+ * `{ job: "*", status: "unknown" }` typecheck. The push sites are exactly
89
+ * where that has to stay illegal, so the draft type keeps the variants apart.
90
+ */
91
+ export type DraftWorkflowEntry = Omit<WorkflowEntry, "checkName">;
92
+ export type DraftJobEntry = Omit<JobEntry, "checkName"> & {
93
+ checkName?: string | null;
94
+ };
95
+ export type DraftEntry = DraftWorkflowEntry | DraftJobEntry;
91
96
  /**
92
97
  * The `pull_request` actions a caller can name.
93
98
  *
@@ -111,24 +116,64 @@ export interface PredictOptions {
111
116
  * to a workflow narrowing `types:`, where it matters completely.
112
117
  */
113
118
  action?: PrEventAction;
119
+ /**
120
+ * Jobs willfire may *execute* to resolve what reading cannot — the fleet's
121
+ * `detect` job, whose outputs feed every dynamic matrix downstream of it.
122
+ *
123
+ * Off by default, and mechanism only: willfire has no opinion about which
124
+ * jobs are safe to run. The caller that knows names them, one repo and job
125
+ * id at a time (see {@link ExecutionGrant}), and an execution that fails
126
+ * for any reason leaves the dependent entries exactly as unresolved as
127
+ * they were — with the failure spelled into their reasons.
128
+ */
129
+ execute?: ExecutionGrant[];
114
130
  }
115
131
  export interface Ctx {
116
132
  action: string;
117
133
  baseRef: string;
134
+ /**
135
+ * The branch the PR's stack ultimately targets, set only when GitHub's
136
+ * stacked-PR machinery is engaged (see `stackTargetRef`). Branch
137
+ * filters match against this instead of `baseRef` (#30).
138
+ */
139
+ stackTarget?: string;
118
140
  files: string[];
119
141
  }
120
- type Workflow = Record<string, any>;
121
- type Combo = Record<string, any> | null;
122
- /** Return list of matrix combination dicts, or null if dynamic. */
123
- export declare function expandMatrix(strategy: any): Combo[] | null;
142
+ export type Workflow = Record<string, any>;
143
+ export type Combo = Record<string, any> | null;
124
144
  /**
125
- * Return run|skipped|unknown for a job-level `if:`.
145
+ * A matrix combination plus the keys that appear in its check-name
146
+ * parenthetical. The two differ: a key contributed by an `include` entry that
147
+ * merged into an existing axis-product combination is readable as
148
+ * `${{ matrix.key }}` but is NOT shown in the name.
126
149
  *
127
- * `scope` carries the inputs the calling workflow passed down. Without it a
128
- * reusable workflow's guards are all unknown, because every one of them is
129
- * written against `inputs.*`.
150
+ * Probe-verified with `a: [x]` plus `include: [{a: x, extra: e1},
151
+ * {a: z, extra: e2}]` -> checks `m-include2 (x)` and `m-include2 (z, e2)`.
152
+ * The merged combination shows the axis key only; the combination the include
153
+ * created from scratch shows all of its own keys.
130
154
  */
131
- export declare function evalIf(cond: any, scope?: Scope): "run" | "skipped" | "unknown";
155
+ export interface DetailedCombo {
156
+ values: Record<string, any>;
157
+ displayKeys: string[];
158
+ }
159
+ /**
160
+ * null element = no matrix at all (a single, unsuffixed job).
161
+ *
162
+ * An *empty* array is different, and different again from a null return: it
163
+ * means the matrix is present and expanded to zero combinations, so the job
164
+ * schedules nothing. `[null]` would claim one check under the bare job name,
165
+ * which is a name GitHub never creates for a matrix job.
166
+ */
167
+ export type DetailedCombos = Array<DetailedCombo | null> | null;
168
+ export interface Rendered {
169
+ text: string;
170
+ /** false when an expression was left in place because we cannot evaluate it. */
171
+ resolved: boolean;
172
+ }
173
+ export interface DisplayName {
174
+ name: string;
175
+ resolved: boolean;
176
+ }
132
177
  /**
133
178
  * One expanded job, before it is paired with its workflow. Named `ExpandedJob`
134
179
  * because `JobEntry` is now the exported job-level variant of `Entry`.
@@ -199,26 +244,10 @@ export interface UsesTarget {
199
244
  */
200
245
  source: SourceRef | null;
201
246
  }
202
- /**
203
- * Split a job-level `uses:` into the file it names and the repo it lives in.
204
- *
205
- * Two spellings are legal:
206
- *
207
- * `./.github/workflows/x.yml` -> the caller's repo, same commit
208
- * `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
209
- *
210
- * The ref is taken from the last `@` so a branch containing a slash
211
- * (`@feature/foo`) survives. Returns null for anything else — including a
212
- * reference built from an expression, which we cannot evaluate and so must not
213
- * guess a fetch target for.
214
- */
215
- export declare function parseUses(uses: string): UsesTarget | null;
216
- /**
217
- * Expand one already-parsed workflow into its job entries. Exported so check
218
- * names can be tested against recorded GitHub behaviour without a network
219
- * round-trip; `predict` is the API you want.
220
- */
221
- export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource): Promise<ExpandedJob[]>;
222
- export declare function makeOctokit(): Octokit;
223
- export declare function predict(octokit: Octokit, repo: string, prNumber: number, opts?: PredictOptions): Promise<Prediction>;
224
- export {};
247
+ /** The two fields the stack walk reads off a PR, whichever route listed it. */
248
+ export interface StackNode {
249
+ base: {
250
+ ref: string;
251
+ };
252
+ merge_commit_sha: string | null;
253
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export { parseUses } from "./parseUses.js";
@@ -0,0 +1 @@
1
+ export { parseUses } from "./parseUses.js";
@@ -0,0 +1,15 @@
1
+ import type { UsesTarget } from "../types.js";
2
+ /**
3
+ * Split a job-level `uses:` into the file it names and the repo it lives in.
4
+ *
5
+ * Two spellings are legal:
6
+ *
7
+ * `./.github/workflows/x.yml` -> the caller's repo, same commit
8
+ * `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
9
+ *
10
+ * The ref is taken from the last `@` so a branch containing a slash
11
+ * (`@feature/foo`) survives. Returns null for anything else — including a
12
+ * reference built from an expression, which we cannot evaluate and so must not
13
+ * guess a fetch target for.
14
+ */
15
+ export declare function parseUses(uses: string): UsesTarget | null;
@@ -0,0 +1,33 @@
1
+ import { EXPRESSION_RE } from "../names/jobDisplayName.js";
2
+ /**
3
+ * Split a job-level `uses:` into the file it names and the repo it lives in.
4
+ *
5
+ * Two spellings are legal:
6
+ *
7
+ * `./.github/workflows/x.yml` -> the caller's repo, same commit
8
+ * `owner/repo/.github/workflows/x.yml@ref` -> another repo, at `ref`
9
+ *
10
+ * The ref is taken from the last `@` so a branch containing a slash
11
+ * (`@feature/foo`) survives. Returns null for anything else — including a
12
+ * reference built from an expression, which we cannot evaluate and so must not
13
+ * guess a fetch target for.
14
+ */
15
+ export function parseUses(uses) {
16
+ if (EXPRESSION_RE.test(uses))
17
+ return null;
18
+ if (uses.startsWith("./")) {
19
+ const path = uses.slice(2);
20
+ return path === "" ? null : { path, source: null };
21
+ }
22
+ const at = uses.lastIndexOf("@");
23
+ if (at <= 0)
24
+ return null;
25
+ const ref = uses.slice(at + 1);
26
+ if (ref === "")
27
+ return null;
28
+ const [owner, repo, ...rest] = uses.slice(0, at).split("/");
29
+ const path = rest.join("/");
30
+ if (!owner || !repo || path === "")
31
+ return null;
32
+ return { path, source: { owner, repo, ref } };
33
+ }
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 { isJobEntry, makeOctokit, predict } from "./predict.js";
7
+ import { isJobEntry, makeOctokit, predict } from "./index.js";
8
8
  async function actualEntries(octokit, repo, prNumber) {
9
9
  const [owner, name] = repo.split("/");
10
10
  const base = { owner, repo: name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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",
@@ -17,16 +17,16 @@
17
17
  "prediction"
18
18
  ],
19
19
  "type": "module",
20
- "main": "dist/predict.js",
21
- "types": "dist/predict.d.ts",
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
22
  "exports": {
23
23
  ".": {
24
- "types": "./dist/predict.d.ts",
25
- "default": "./dist/predict.js"
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
26
  }
27
27
  },
28
28
  "bin": {
29
- "willfire": "dist/predict.js"
29
+ "willfire": "dist/cli.js"
30
30
  },
31
31
  "files": [
32
32
  "dist"
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.build.json",
39
- "predict": "tsx src/predict.ts",
39
+ "predict": "tsx src/cli.ts",
40
40
  "prepare": "pnpm build",
41
41
  "test": "vitest run",
42
42
  "test:coverage": "vitest run --coverage",