willfire 0.1.8 → 0.1.10
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 +36 -5
- package/dist/predict.d.ts +78 -6
- package/dist/predict.js +125 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,10 +20,23 @@ 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, checkNames, skip } = await predict(
|
|
23
|
+
const { entries, checkNames, skip, sources } = await predict(
|
|
24
|
+
getOctokit(token),
|
|
25
|
+
"owner/repo",
|
|
26
|
+
123,
|
|
27
|
+
{ action: context.payload.action }, // "opened" | "synchronize" | "reopened"
|
|
28
|
+
);
|
|
24
29
|
// checkNames: sorted, deduped checkName of every entry with status "run"
|
|
30
|
+
// sources: every repo read, and the commit each ref resolved to
|
|
25
31
|
```
|
|
26
32
|
|
|
33
|
+
`action` is optional but worth passing. Omitted, the event action is inferred
|
|
34
|
+
from the PR's commit count, which is wrong in both directions — a PR opened
|
|
35
|
+
from a branch with several commits looks like `synchronize`, a force-push down
|
|
36
|
+
to one commit looks like `opened` — and can never produce `reopened`. That only
|
|
37
|
+
matters to a workflow narrowing `types:`, where it decides whether the workflow
|
|
38
|
+
dispatches at all.
|
|
39
|
+
|
|
27
40
|
`entries` is a union of two variants, both carrying `workflow` and `reason`:
|
|
28
41
|
|
|
29
42
|
| variant | `job` | `checkName` | `status` |
|
|
@@ -48,6 +61,17 @@ single name is knowable ahead of the run:
|
|
|
48
61
|
four-level limit;
|
|
49
62
|
- a `name:` interpolating something we cannot evaluate statically.
|
|
50
63
|
|
|
64
|
+
`sources` is the provenance of the answer: the PR's own repo at the head
|
|
65
|
+
commit, then one entry per cross-repo `uses:` that was read, each carrying both
|
|
66
|
+
the `ref` the workflow wrote and the `sha` it resolved to. A ref is resolved
|
|
67
|
+
before the file behind it is read, so the commit named is the commit used. `v0`
|
|
68
|
+
is a tag someone moves; without the sha, "willfire predicted these checks" is
|
|
69
|
+
not a claim that can be checked against the run afterwards.
|
|
70
|
+
|
|
71
|
+
A ref that will not resolve is not a source. Its callee is never read, the jobs
|
|
72
|
+
behind it come back `unknown`, and nothing falls back to reading the mutable
|
|
73
|
+
ref.
|
|
74
|
+
|
|
51
75
|
Duplicate names in `checkNames` are not possible (it is a set), but duplicate
|
|
52
76
|
check names *are* — GitHub happily creates two identically named checks when a
|
|
53
77
|
matrix job's `name:` does not vary per combination. `entries` shows them.
|
|
@@ -58,9 +82,13 @@ Auth is any token with `contents: read`, `actions: read`, and
|
|
|
58
82
|
## CLI
|
|
59
83
|
|
|
60
84
|
```sh
|
|
61
|
-
GH_TOKEN=... willfire --repo owner/repo --pr 123
|
|
85
|
+
GH_TOKEN=... willfire --repo owner/repo --pr 123 \
|
|
86
|
+
[--action opened|synchronize|reopened] [--json]
|
|
62
87
|
```
|
|
63
88
|
|
|
89
|
+
Plain-text output is one line per entry, then a `# read owner/repo@ref -> sha`
|
|
90
|
+
line per source. `--json` prints the whole `Prediction`, `sources` included.
|
|
91
|
+
|
|
64
92
|
## What it handles
|
|
65
93
|
|
|
66
94
|
Path filters (`paths`, `paths-ignore`, order-sensitive `!` negation), branch
|
|
@@ -68,8 +96,8 @@ filters, event `types`, combined filters, `[skip ci]` and friends, disabled
|
|
|
68
96
|
workflows, multi-job workflows, static matrix expansion (including
|
|
69
97
|
`exclude`/`include`), `needs` skip-propagation, job-level `if`, and reusable
|
|
70
98
|
workflows — both the local `./.github/workflows/x.yml` form and the cross-repo
|
|
71
|
-
`owner/repo/.github/workflows/x.yml@ref` form, whose
|
|
72
|
-
|
|
99
|
+
`owner/repo/.github/workflows/x.yml@ref` form, whose ref is resolved to a
|
|
100
|
+
commit and the callee then read at that commit. Jobs whose `if` is false are
|
|
73
101
|
predicted as `skipped` entries, matching how they appear in the checks UI.
|
|
74
102
|
|
|
75
103
|
Things that cannot be known statically — e.g. a matrix computed at runtime
|
|
@@ -124,7 +152,10 @@ the docs do not state:
|
|
|
124
152
|
|
|
125
153
|
Scope notes: validated on `opened` pull_request events; `synchronize`/`labeled`
|
|
126
154
|
live events, `branches-ignore`, and diffs far beyond 301 files are not yet
|
|
127
|
-
probe-verified
|
|
155
|
+
probe-verified — passing `action` explicitly is what makes probing the other
|
|
156
|
+
two possible. `action` accepts the three default `types:` only; `pull_request`
|
|
157
|
+
fires on around twenty actions, and a workflow narrowing to `ready_for_review`
|
|
158
|
+
or `edited` is out of scope either way. Reusable-workflow name prefixing is probe-verified to three
|
|
128
159
|
levels; deeper nesting is inferred. The cross-repo probe calls back into the
|
|
129
160
|
probe repo itself by full `owner/repo@ref` reference, so it pins ref
|
|
130
161
|
resolution but not the owner/repo half of the address.
|
package/dist/predict.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface WorkflowEntry extends EntryBase {
|
|
|
41
41
|
* These can be genuinely undecidable statically — dynamic matrix, a reusable
|
|
42
42
|
* workflow we cannot read, unresolvable `if`, or `needs` on any of those — so
|
|
43
43
|
* `"unknown"` lives here and only here.
|
|
44
|
+
*
|
|
45
|
+
* `"no-dispatch"` is the mirror image, and lives on {@link WorkflowEntry} and
|
|
46
|
+
* only there. It is a verdict about whether the run happens at all, which is
|
|
47
|
+
* settled before any job is looked at — so by the time there is a job entry to
|
|
48
|
+
* label, the answer is already yes.
|
|
44
49
|
*/
|
|
45
50
|
export interface JobEntry extends EntryBase {
|
|
46
51
|
job: JobName;
|
|
@@ -54,7 +59,7 @@ export interface JobEntry extends EntryBase {
|
|
|
54
59
|
* interpolates something we cannot evaluate ahead of the run.
|
|
55
60
|
*/
|
|
56
61
|
checkName: string | null;
|
|
57
|
-
status: "run" | "skipped" | "unknown"
|
|
62
|
+
status: "run" | "skipped" | "unknown";
|
|
58
63
|
}
|
|
59
64
|
export type Entry = WorkflowEntry | JobEntry;
|
|
60
65
|
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
@@ -70,10 +75,43 @@ export interface Prediction {
|
|
|
70
75
|
*/
|
|
71
76
|
checkNames: string[];
|
|
72
77
|
skip: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Every repo this prediction read, and the commit each ref resolved to —
|
|
80
|
+
* the PR's own head first, then any cross-repo `uses:` reached from it.
|
|
81
|
+
*
|
|
82
|
+
* Provenance, not input. `v0` is a moving tag, so "willfire said these
|
|
83
|
+
* checks" is only reconcilable against a run if it also says which commits
|
|
84
|
+
* it read to say it. Sorted by `owner/repo@ref`.
|
|
85
|
+
*/
|
|
86
|
+
sources: WorkflowSource[];
|
|
73
87
|
}
|
|
74
88
|
export declare function patternToRegex(pat: string): RegExp;
|
|
75
89
|
/** Order-sensitive match: last matching pattern wins; ! negates. */
|
|
76
90
|
export declare function matchFilters(value: string, patterns: string[]): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The `pull_request` actions a caller can name.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately the three default types and no more. `pull_request` fires on
|
|
95
|
+
* around twenty actions — `ready_for_review`, `edited`, `labeled` — and a
|
|
96
|
+
* workflow that narrows `types:` to one of those is not predictable today
|
|
97
|
+
* regardless of what is passed here. Widening this union later is a
|
|
98
|
+
* non-breaking change; narrowing it would not be, so it starts narrow.
|
|
99
|
+
*/
|
|
100
|
+
export type PrEventAction = "opened" | "synchronize" | "reopened";
|
|
101
|
+
/** Options every caller may omit. */
|
|
102
|
+
export interface PredictOptions {
|
|
103
|
+
/**
|
|
104
|
+
* The literal event action the run was triggered by — `github.event.action`
|
|
105
|
+
* inside an Action, or `--action` on the CLI.
|
|
106
|
+
*
|
|
107
|
+
* Omitting it falls back to inferring from the commit count, which is a
|
|
108
|
+
* guess and is wrong in both directions: a PR opened from a branch with
|
|
109
|
+
* several commits looks like `synchronize`, a force-push down to one commit
|
|
110
|
+
* looks like `opened`, and `reopened` is never produced. That only matters
|
|
111
|
+
* to a workflow narrowing `types:`, where it matters completely.
|
|
112
|
+
*/
|
|
113
|
+
action?: PrEventAction;
|
|
114
|
+
}
|
|
77
115
|
export interface Ctx {
|
|
78
116
|
action: string;
|
|
79
117
|
baseRef: string;
|
|
@@ -112,20 +150,54 @@ export interface ExpandedJob {
|
|
|
112
150
|
* `owner/repo/path@ref` resolves against *that* repo at *that* ref — probe
|
|
113
151
|
* verified, see `src/names.test.ts`.
|
|
114
152
|
*/
|
|
115
|
-
export interface
|
|
153
|
+
export interface SourceRef {
|
|
116
154
|
owner: string;
|
|
117
155
|
repo: string;
|
|
118
156
|
/** Tag, branch, or SHA — whatever `@` was pinned to. */
|
|
119
157
|
ref: string;
|
|
120
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* A source whose ref has been resolved to the commit it names.
|
|
161
|
+
*
|
|
162
|
+
* Expansion walks these and never a bare {@link SourceRef}: `v0` is a moving
|
|
163
|
+
* tag, so two reads an hour apart can be two different programs, and a
|
|
164
|
+
* prediction that cannot name the commit it read cannot be reconciled against
|
|
165
|
+
* the run afterwards. Making the SHA required is what stops an unresolved ref
|
|
166
|
+
* being expanded against by accident.
|
|
167
|
+
*/
|
|
168
|
+
export interface WorkflowSource extends SourceRef {
|
|
169
|
+
/** The commit `ref` names. Equal to `ref` when it was already a SHA. */
|
|
170
|
+
sha: string;
|
|
171
|
+
}
|
|
121
172
|
/** Read one workflow file, or null if it is not reachable. Must not throw. */
|
|
122
173
|
export type FetchWorkflow = (path: string, source: WorkflowSource) => Promise<string | null>;
|
|
174
|
+
/**
|
|
175
|
+
* Resolve a tag, branch, or SHA to the commit it names, or null when it cannot
|
|
176
|
+
* be resolved. Must not throw.
|
|
177
|
+
*
|
|
178
|
+
* Null is not a cue to fall back to the mutable ref. It leaves every entry
|
|
179
|
+
* behind that source unresolved, which turns the gate red — reading a ref we
|
|
180
|
+
* cannot name is the thing this exists to stop.
|
|
181
|
+
*/
|
|
182
|
+
export type ResolveRef = (source: SourceRef) => Promise<string | null>;
|
|
183
|
+
/**
|
|
184
|
+
* The two reads expansion needs from the outside world, bundled so the recursion
|
|
185
|
+
* carries one parameter instead of two.
|
|
186
|
+
*/
|
|
187
|
+
export interface WorkflowReader {
|
|
188
|
+
fetchWorkflow: FetchWorkflow;
|
|
189
|
+
resolveRef: ResolveRef;
|
|
190
|
+
}
|
|
123
191
|
/** A `uses:` that named a workflow file we know how to go and get. */
|
|
124
192
|
export interface UsesTarget {
|
|
125
193
|
/** Path inside the target repo, e.g. `.github/workflows/x.yml`. */
|
|
126
194
|
path: string;
|
|
127
|
-
/**
|
|
128
|
-
|
|
195
|
+
/**
|
|
196
|
+
* null for a local `./` call: the caller's own repo and ref, already
|
|
197
|
+
* resolved. Non-null sources arrive unresolved — the ref is whatever the
|
|
198
|
+
* `uses:` string spelled.
|
|
199
|
+
*/
|
|
200
|
+
source: SourceRef | null;
|
|
129
201
|
}
|
|
130
202
|
/**
|
|
131
203
|
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
@@ -146,7 +218,7 @@ export declare function parseUses(uses: string): UsesTarget | null;
|
|
|
146
218
|
* names can be tested against recorded GitHub behaviour without a network
|
|
147
219
|
* round-trip; `predict` is the API you want.
|
|
148
220
|
*/
|
|
149
|
-
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx,
|
|
221
|
+
export declare function expandWorkflowJobs(wf: Workflow, ctx: Ctx, reader: WorkflowReader, source: WorkflowSource): Promise<ExpandedJob[]>;
|
|
150
222
|
export declare function makeOctokit(): Octokit;
|
|
151
|
-
export declare function predict(octokit: Octokit, repo: string, prNumber: number): Promise<Prediction>;
|
|
223
|
+
export declare function predict(octokit: Octokit, repo: string, prNumber: number, opts?: PredictOptions): Promise<Prediction>;
|
|
152
224
|
export {};
|
package/dist/predict.js
CHANGED
|
@@ -363,6 +363,11 @@ function calleeInputs(withBlock, subWf) {
|
|
|
363
363
|
* mixes the two is counted the same way.
|
|
364
364
|
*/
|
|
365
365
|
const MAX_REUSABLE_DEPTH = 4;
|
|
366
|
+
/** `ref` is already a commit id, so resolving it is a no-op. */
|
|
367
|
+
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
368
|
+
const isSha = (ref) => SHA_RE.test(ref);
|
|
369
|
+
/** Identity of a source as written, before resolution. */
|
|
370
|
+
const sourceKey = (s) => `${s.owner}/${s.repo}@${s.ref}`;
|
|
366
371
|
/**
|
|
367
372
|
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
368
373
|
*
|
|
@@ -395,7 +400,7 @@ export function parseUses(uses) {
|
|
|
395
400
|
return null;
|
|
396
401
|
return { path, source: { owner, repo, ref } };
|
|
397
402
|
}
|
|
398
|
-
async function expandJobs(wf, ctx,
|
|
403
|
+
async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}) {
|
|
399
404
|
const entries = [];
|
|
400
405
|
const jobs = wf.jobs ?? {};
|
|
401
406
|
const statuses = {};
|
|
@@ -466,18 +471,34 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
466
471
|
failure = `unresolvable reusable reference: ${uses}`;
|
|
467
472
|
}
|
|
468
473
|
else {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
474
|
+
// A local `./` call stays on the caller's source, which is already
|
|
475
|
+
// pinned to a commit. A cross-repo one arrives as whatever the `uses:`
|
|
476
|
+
// string spelled — `@v0` — and has to be resolved before anything is
|
|
477
|
+
// read from it, so the file that gets read and the commit the
|
|
478
|
+
// prediction names are the same one.
|
|
479
|
+
let resolved = source;
|
|
480
|
+
if (target.source != null) {
|
|
481
|
+
const { ref } = target.source;
|
|
482
|
+
const sha = isSha(ref) ? ref : await reader.resolveRef(target.source);
|
|
483
|
+
resolved = sha == null ? null : { ...target.source, sha };
|
|
484
|
+
}
|
|
485
|
+
if (resolved == null) {
|
|
486
|
+
failure = `cannot resolve ref for ${uses}`;
|
|
473
487
|
}
|
|
474
488
|
else {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
489
|
+
subSource = resolved;
|
|
490
|
+
const content = await reader.fetchWorkflow(target.path, subSource);
|
|
491
|
+
if (content == null) {
|
|
492
|
+
failure = `cannot fetch ${uses}`;
|
|
478
493
|
}
|
|
479
|
-
|
|
480
|
-
|
|
494
|
+
else {
|
|
495
|
+
try {
|
|
496
|
+
subWf = parseYaml(content);
|
|
497
|
+
subScope = { inputs: calleeInputs(job.with, subWf ?? {}) };
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
failure = `YAML parse error in ${uses}: ${e}`;
|
|
501
|
+
}
|
|
481
502
|
}
|
|
482
503
|
}
|
|
483
504
|
}
|
|
@@ -494,7 +515,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
494
515
|
});
|
|
495
516
|
continue;
|
|
496
517
|
}
|
|
497
|
-
entries.push(...(await expandJobs(subWf, ctx,
|
|
518
|
+
entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope)));
|
|
498
519
|
}
|
|
499
520
|
continue;
|
|
500
521
|
}
|
|
@@ -525,8 +546,8 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
525
546
|
* names can be tested against recorded GitHub behaviour without a network
|
|
526
547
|
* round-trip; `predict` is the API you want.
|
|
527
548
|
*/
|
|
528
|
-
export function expandWorkflowJobs(wf, ctx,
|
|
529
|
-
return expandJobs(wf, ctx,
|
|
549
|
+
export function expandWorkflowJobs(wf, ctx, reader, source) {
|
|
550
|
+
return expandJobs(wf, ctx, reader, source);
|
|
530
551
|
}
|
|
531
552
|
// ------------------------------------------------------------------- pipeline
|
|
532
553
|
export function makeOctokit() {
|
|
@@ -535,7 +556,7 @@ export function makeOctokit() {
|
|
|
535
556
|
throw new Error("GH_TOKEN or GITHUB_TOKEN must be set");
|
|
536
557
|
return new Octokit({ auth: token });
|
|
537
558
|
}
|
|
538
|
-
export async function predict(octokit, repo, prNumber) {
|
|
559
|
+
export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
539
560
|
const [owner, name] = repo.split("/");
|
|
540
561
|
const base = { owner, repo: name };
|
|
541
562
|
const { data: pr } = await octokit.rest.pulls.get({ ...base, pull_number: prNumber });
|
|
@@ -545,27 +566,67 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
545
566
|
per_page: 100,
|
|
546
567
|
});
|
|
547
568
|
const ctx = {
|
|
548
|
-
|
|
569
|
+
// The caller's answer wins whenever it has one. The commit-count fallback
|
|
570
|
+
// is a guess kept only so existing callers keep working.
|
|
571
|
+
action: opts.action ?? (pr.commits > 1 ? "synchronize" : "opened"),
|
|
549
572
|
baseRef: pr.base.ref,
|
|
550
573
|
files: files.map((f) => f.filename),
|
|
551
574
|
};
|
|
552
575
|
const headSha = pr.head.sha;
|
|
576
|
+
/**
|
|
577
|
+
* The PR's own repo at the head commit — where expansion starts, and already
|
|
578
|
+
* a commit id, so its `ref` and `sha` are the same string.
|
|
579
|
+
*/
|
|
580
|
+
const headSource = { owner, repo: name, ref: headSha, sha: headSha };
|
|
581
|
+
// Provenance for the answer, filled as expansion reaches each source. The head
|
|
582
|
+
// is in from the start: it is read even on the skip path, where the commit
|
|
583
|
+
// message is what decides the verdict.
|
|
584
|
+
const sources = new Map([[sourceKey(headSource), headSource]]);
|
|
553
585
|
const { data: headCommit } = await octokit.rest.repos.getCommit({
|
|
554
586
|
...base,
|
|
555
587
|
ref: headSha,
|
|
556
588
|
});
|
|
557
589
|
const headMsg = headCommit.commit.message;
|
|
558
590
|
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
559
|
-
return finalizePrediction([], "head commit message contains a skip instruction");
|
|
591
|
+
return finalizePrediction([], "head commit message contains a skip instruction", sources);
|
|
560
592
|
}
|
|
561
|
-
|
|
562
|
-
|
|
593
|
+
// A `uses:` naming a tag is the same lookup from every caller that writes it,
|
|
594
|
+
// so resolve each `owner/repo@ref` once. Misses are cached too: a ref that
|
|
595
|
+
// cannot be resolved will not start resolving on the second ask.
|
|
596
|
+
const refCache = new Map();
|
|
597
|
+
const resolveRef = async (src) => {
|
|
598
|
+
const key = sourceKey(src);
|
|
599
|
+
const hit = refCache.get(key);
|
|
600
|
+
if (hit !== undefined)
|
|
601
|
+
return hit;
|
|
602
|
+
let sha;
|
|
603
|
+
try {
|
|
604
|
+
const { data } = await octokit.rest.repos.getCommit({
|
|
605
|
+
owner: src.owner,
|
|
606
|
+
repo: src.repo,
|
|
607
|
+
ref: src.ref,
|
|
608
|
+
});
|
|
609
|
+
sha = data.sha;
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Deleted tag, private repo, rate limit, network: all one answer here.
|
|
613
|
+
// The caller turns it into an `unknown` entry rather than throwing.
|
|
614
|
+
sha = null;
|
|
615
|
+
}
|
|
616
|
+
refCache.set(key, sha);
|
|
617
|
+
if (sha != null)
|
|
618
|
+
sources.set(key, { ...src, sha });
|
|
619
|
+
return sha;
|
|
620
|
+
};
|
|
563
621
|
// One callee is commonly reached from several callers — a fleet repo calls
|
|
564
622
|
// the same `testing-conventions@v0` from eight workflows — so remember what
|
|
565
|
-
// each `owner/repo/path@
|
|
623
|
+
// each `owner/repo/path@sha` resolved to, misses included.
|
|
566
624
|
const cache = new Map();
|
|
567
625
|
const fetchWorkflow = async (path, src) => {
|
|
568
|
-
|
|
626
|
+
// Keyed and fetched on the commit, never the ref that named it. Two callers
|
|
627
|
+
// writing `@v0` and `@abc123` for the same commit are one read, and a tag
|
|
628
|
+
// that moves mid-prediction cannot hand back two different files.
|
|
629
|
+
const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
|
|
569
630
|
const hit = cache.get(key);
|
|
570
631
|
if (hit !== undefined)
|
|
571
632
|
return hit;
|
|
@@ -575,7 +636,7 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
575
636
|
owner: src.owner,
|
|
576
637
|
repo: src.repo,
|
|
577
638
|
path,
|
|
578
|
-
ref: src.
|
|
639
|
+
ref: src.sha,
|
|
579
640
|
mediaType: { format: "raw" },
|
|
580
641
|
});
|
|
581
642
|
content = data;
|
|
@@ -588,6 +649,7 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
588
649
|
cache.set(key, content);
|
|
589
650
|
return content;
|
|
590
651
|
};
|
|
652
|
+
const reader = { fetchWorkflow, resolveRef };
|
|
591
653
|
const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
|
|
592
654
|
...base,
|
|
593
655
|
per_page: 100,
|
|
@@ -640,7 +702,7 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
640
702
|
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
641
703
|
continue;
|
|
642
704
|
}
|
|
643
|
-
for (const j of await expandJobs(wf, ctx,
|
|
705
|
+
for (const j of await expandJobs(wf, ctx, reader, headSource)) {
|
|
644
706
|
entries.push({
|
|
645
707
|
workflow: path,
|
|
646
708
|
job: jobName(j.job),
|
|
@@ -650,18 +712,25 @@ export async function predict(octokit, repo, prNumber) {
|
|
|
650
712
|
});
|
|
651
713
|
}
|
|
652
714
|
}
|
|
653
|
-
return finalizePrediction(entries, null);
|
|
715
|
+
return finalizePrediction(entries, null, sources);
|
|
654
716
|
}
|
|
655
|
-
function finalizePrediction(entries, skip) {
|
|
717
|
+
function finalizePrediction(entries, skip, sources) {
|
|
656
718
|
const final = entries.map(finalize);
|
|
657
719
|
const names = new Set();
|
|
658
720
|
for (const e of final) {
|
|
659
721
|
if (e.status === "run" && e.checkName != null)
|
|
660
722
|
names.add(e.checkName);
|
|
661
723
|
}
|
|
662
|
-
return {
|
|
724
|
+
return {
|
|
725
|
+
entries: final,
|
|
726
|
+
checkNames: [...names].sort(),
|
|
727
|
+
skip,
|
|
728
|
+
sources: [...sources.values()].sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))),
|
|
729
|
+
};
|
|
663
730
|
}
|
|
664
731
|
// ------------------------------------------------------------------------ CLI
|
|
732
|
+
const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened] [--json]";
|
|
733
|
+
const isPrEventAction = (v) => v === "opened" || v === "synchronize" || v === "reopened";
|
|
665
734
|
function parseArgs(argv) {
|
|
666
735
|
const get = (flag) => {
|
|
667
736
|
const i = argv.indexOf(flag);
|
|
@@ -670,29 +739,47 @@ function parseArgs(argv) {
|
|
|
670
739
|
const repo = get("--repo");
|
|
671
740
|
const pr = get("--pr");
|
|
672
741
|
if (!repo || !pr) {
|
|
673
|
-
console.error(
|
|
742
|
+
console.error(USAGE);
|
|
674
743
|
process.exit(2);
|
|
675
744
|
}
|
|
676
|
-
|
|
745
|
+
// An unrecognised action is refused rather than ignored. Silently falling
|
|
746
|
+
// back to the guess would turn a typo into a wrong prediction, which is the
|
|
747
|
+
// failure this flag exists to remove.
|
|
748
|
+
const action = get("--action");
|
|
749
|
+
if (action !== undefined && !isPrEventAction(action)) {
|
|
750
|
+
console.error(`unknown --action: ${action}`);
|
|
751
|
+
console.error(USAGE);
|
|
752
|
+
process.exit(2);
|
|
753
|
+
}
|
|
754
|
+
return { repo, pr: Number(pr), json: argv.includes("--json"), action };
|
|
677
755
|
}
|
|
678
756
|
const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
|
|
679
757
|
if (isMain) {
|
|
680
758
|
const args = parseArgs(process.argv.slice(2));
|
|
681
|
-
const
|
|
759
|
+
const prediction = await predict(makeOctokit(), args.repo, args.pr, {
|
|
760
|
+
action: args.action,
|
|
761
|
+
});
|
|
762
|
+
const { entries, skip, sources } = prediction;
|
|
682
763
|
if (args.json) {
|
|
683
|
-
console.log(JSON.stringify(
|
|
684
|
-
}
|
|
685
|
-
else if (skip) {
|
|
686
|
-
console.log(`# ${skip} -> nothing dispatches`);
|
|
764
|
+
console.log(JSON.stringify(prediction, null, 2));
|
|
687
765
|
}
|
|
688
766
|
else {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
767
|
+
if (skip) {
|
|
768
|
+
console.log(`# ${skip} -> nothing dispatches`);
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
for (const e of entries) {
|
|
772
|
+
if (isWorkflowEntry(e))
|
|
773
|
+
console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
|
|
774
|
+
else {
|
|
775
|
+
const name = e.checkName ?? `${e.job} (name unresolved)`;
|
|
776
|
+
console.log(`${e.workflow} :: ${name} :: ${e.status}`);
|
|
777
|
+
}
|
|
695
778
|
}
|
|
696
779
|
}
|
|
780
|
+
// Last, and on the skip path too, so a red gate's first question — which
|
|
781
|
+
// commits was this read from? — is answered wherever the reader lands.
|
|
782
|
+
for (const s of sources)
|
|
783
|
+
console.log(`# read ${s.owner}/${s.repo}@${s.ref} -> ${s.sha}`);
|
|
697
784
|
}
|
|
698
785
|
}
|