willfire 0.1.9 → 0.1.11
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 +109 -10
- package/dist/execute.d.ts +123 -0
- package/dist/execute.js +490 -0
- package/dist/expr.d.ts +51 -3
- package/dist/expr.js +85 -18
- package/dist/predict.d.ts +69 -8
- package/dist/predict.js +253 -54
- package/package.json +1 -1
package/dist/predict.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// src/names.test.ts.
|
|
13
13
|
import { Octokit } from "@octokit/rest";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
|
-
import {
|
|
15
|
+
import { makeExecutor, makeTreeProvider, parseGrant, runShell, } from "./execute.js";
|
|
16
|
+
import { evaluate, evaluateValue, UNKNOWN } from "./expr.js";
|
|
16
17
|
/** Tag a job display name. Rejects the workflow-level sentinel. */
|
|
17
18
|
export const jobName = (name) => name;
|
|
18
19
|
/** Narrow to the workflow-level variant without inspecting the sentinel. */
|
|
@@ -126,12 +127,34 @@ function workflowDispatches(wf, ctx) {
|
|
|
126
127
|
}
|
|
127
128
|
return [true, "trigger matched"];
|
|
128
129
|
}
|
|
129
|
-
|
|
130
|
+
/**
|
|
131
|
+
* The values of one matrix axis, or null when they cannot be known.
|
|
132
|
+
*
|
|
133
|
+
* A plain list is itself. An axis written as an expression —
|
|
134
|
+
* `language: ${{ fromJSON(needs.detect.outputs.coverage_languages) }}` — is
|
|
135
|
+
* the values another job computed, and is knowable exactly when the scope
|
|
136
|
+
* carries that job's outputs. Anything else stays null, which is what makes
|
|
137
|
+
* the whole job `unknown` rather than a guess at how many checks it creates.
|
|
138
|
+
*/
|
|
139
|
+
function axisValues(v, scope) {
|
|
140
|
+
if (Array.isArray(v))
|
|
141
|
+
return v;
|
|
142
|
+
if (typeof v !== "string")
|
|
143
|
+
return null;
|
|
144
|
+
const val = evaluateValue(v, scope);
|
|
145
|
+
if (val.kind !== "json" || !Array.isArray(val.v))
|
|
146
|
+
return null;
|
|
147
|
+
return val.v;
|
|
148
|
+
}
|
|
149
|
+
function expandMatrixDetailed(strategy, scope = {}) {
|
|
130
150
|
const matrix = strategy?.matrix;
|
|
131
151
|
if (matrix == null)
|
|
132
152
|
return [null];
|
|
153
|
+
// `matrix: ${{ ... }}` — the whole matrix as one expression, rather than the
|
|
154
|
+
// per-axis form below. It yields include-style entries, not axes, so it is a
|
|
155
|
+
// separate expansion and is not modelled.
|
|
133
156
|
if (typeof matrix === "string")
|
|
134
|
-
return null;
|
|
157
|
+
return null;
|
|
135
158
|
const include = matrix.include ?? [];
|
|
136
159
|
const exclude = matrix.exclude ?? [];
|
|
137
160
|
if (typeof include === "string" || typeof exclude === "string")
|
|
@@ -140,9 +163,10 @@ function expandMatrixDetailed(strategy) {
|
|
|
140
163
|
for (const [k, v] of Object.entries(matrix)) {
|
|
141
164
|
if (k === "include" || k === "exclude")
|
|
142
165
|
continue;
|
|
143
|
-
|
|
166
|
+
const vals = axisValues(v, scope);
|
|
167
|
+
if (vals == null)
|
|
144
168
|
return null;
|
|
145
|
-
axes[k] =
|
|
169
|
+
axes[k] = vals;
|
|
146
170
|
}
|
|
147
171
|
const axisKeys = Object.keys(axes);
|
|
148
172
|
let combos = [{ values: {}, displayKeys: axisKeys }];
|
|
@@ -177,8 +201,8 @@ function expandMatrixDetailed(strategy) {
|
|
|
177
201
|
return combos;
|
|
178
202
|
}
|
|
179
203
|
/** Return list of matrix combination dicts, or null if dynamic. */
|
|
180
|
-
export function expandMatrix(strategy) {
|
|
181
|
-
const detailed = expandMatrixDetailed(strategy);
|
|
204
|
+
export function expandMatrix(strategy, scope = {}) {
|
|
205
|
+
const detailed = expandMatrixDetailed(strategy, scope);
|
|
182
206
|
return detailed == null ? null : detailed.map((c) => (c == null ? null : c.values));
|
|
183
207
|
}
|
|
184
208
|
/**
|
|
@@ -283,20 +307,30 @@ function skippedDisplayName(jobId, job) {
|
|
|
283
307
|
* resolve instead of hanging the job on an unknown.
|
|
284
308
|
*/
|
|
285
309
|
const PR_GITHUB_CONTEXT = { event_name: "pull_request" };
|
|
310
|
+
/**
|
|
311
|
+
* A scope with the fixed pull-request facts filled in.
|
|
312
|
+
*
|
|
313
|
+
* Every `${{ }}` this module evaluates — a job `if:`, a matrix axis — is
|
|
314
|
+
* evaluated for the same event, so they all get the same `github.*`. Anything
|
|
315
|
+
* the caller states wins; there is nothing here worth overriding, but a scope
|
|
316
|
+
* that silently ignored what it was handed would be the wrong shape.
|
|
317
|
+
*/
|
|
318
|
+
const prScope = (scope) => ({
|
|
319
|
+
...scope,
|
|
320
|
+
github: { ...PR_GITHUB_CONTEXT, ...scope.github },
|
|
321
|
+
});
|
|
286
322
|
/**
|
|
287
323
|
* Return run|skipped|unknown for a job-level `if:`.
|
|
288
324
|
*
|
|
289
|
-
* `scope` carries the inputs the calling workflow passed down
|
|
290
|
-
* reusable workflow's guards are all
|
|
291
|
-
* written against `inputs
|
|
325
|
+
* `scope` carries the inputs the calling workflow passed down, and any job
|
|
326
|
+
* outputs the caller knows. Without it a reusable workflow's guards are all
|
|
327
|
+
* unknown, because every one of them is written against `inputs.*` or
|
|
328
|
+
* `needs.*`.
|
|
292
329
|
*/
|
|
293
330
|
export function evalIf(cond, scope = {}) {
|
|
294
331
|
if (cond == null)
|
|
295
332
|
return "run";
|
|
296
|
-
const verdict = evaluate(String(cond),
|
|
297
|
-
inputs: scope.inputs,
|
|
298
|
-
github: { ...PR_GITHUB_CONTEXT, ...scope.github },
|
|
299
|
-
});
|
|
333
|
+
const verdict = evaluate(String(cond), prScope(scope));
|
|
300
334
|
if (verdict === null)
|
|
301
335
|
return "unknown";
|
|
302
336
|
return verdict ? "run" : "skipped";
|
|
@@ -363,6 +397,11 @@ function calleeInputs(withBlock, subWf) {
|
|
|
363
397
|
* mixes the two is counted the same way.
|
|
364
398
|
*/
|
|
365
399
|
const MAX_REUSABLE_DEPTH = 4;
|
|
400
|
+
/** `ref` is already a commit id, so resolving it is a no-op. */
|
|
401
|
+
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
402
|
+
const isSha = (ref) => SHA_RE.test(ref);
|
|
403
|
+
/** Identity of a source as written, before resolution. */
|
|
404
|
+
const sourceKey = (s) => `${s.owner}/${s.repo}@${s.ref}`;
|
|
366
405
|
/**
|
|
367
406
|
* Split a job-level `uses:` into the file it names and the repo it lives in.
|
|
368
407
|
*
|
|
@@ -395,13 +434,43 @@ export function parseUses(uses) {
|
|
|
395
434
|
return null;
|
|
396
435
|
return { path, source: { owner, repo, ref } };
|
|
397
436
|
}
|
|
398
|
-
async function expandJobs(wf, ctx,
|
|
437
|
+
async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}, executor) {
|
|
399
438
|
const entries = [];
|
|
400
439
|
const jobs = wf.jobs ?? {};
|
|
401
440
|
const statuses = {};
|
|
441
|
+
// Execute what the caller granted, before anything reads `needs`. The
|
|
442
|
+
// grant names the repo the workflow *file* lives in, so a granted callee
|
|
443
|
+
// job fires here in the recursion, where `source` is that repo. The guard
|
|
444
|
+
// is the same `evalIf` the main loop applies — same scope, same verdict —
|
|
445
|
+
// so a job is executed exactly when it is predicted to run. A job that
|
|
446
|
+
// would not run is not executed; a job that fails to execute contributes
|
|
447
|
+
// nothing but its reason, which `execNote` threads into the entries that
|
|
448
|
+
// needed it.
|
|
449
|
+
let scoped = scope;
|
|
450
|
+
const execFailures = {};
|
|
451
|
+
if (executor != null) {
|
|
452
|
+
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
453
|
+
if (!executor.granted(source, jobId))
|
|
454
|
+
continue;
|
|
455
|
+
const job = jobRaw ?? {};
|
|
456
|
+
if (evalIf(job.if, scoped) !== "run")
|
|
457
|
+
continue;
|
|
458
|
+
const res = await executor.executeJob(jobId, job, wf, scoped);
|
|
459
|
+
if (res.ok) {
|
|
460
|
+
scoped = { ...scoped, needs: { ...scoped.needs, [jobId]: { outputs: res.outputs } } };
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
execFailures[jobId] = res.reason;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const execNote = (needs) => {
|
|
468
|
+
const failed = needs.find((n) => n in execFailures);
|
|
469
|
+
return failed == null ? "" : `; executing '${failed}' failed: ${execFailures[failed]}`;
|
|
470
|
+
};
|
|
402
471
|
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
403
472
|
const job = jobRaw ?? {};
|
|
404
|
-
let status = evalIf(job.if,
|
|
473
|
+
let status = evalIf(job.if, scoped);
|
|
405
474
|
let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
|
|
406
475
|
let needs = job.needs ?? [];
|
|
407
476
|
if (typeof needs === "string")
|
|
@@ -433,7 +502,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
433
502
|
});
|
|
434
503
|
continue;
|
|
435
504
|
}
|
|
436
|
-
const combos = expandMatrixDetailed(job.strategy);
|
|
505
|
+
const combos = expandMatrixDetailed(job.strategy, prScope(scoped));
|
|
437
506
|
if ("uses" in job) {
|
|
438
507
|
// Reusable workflow call. The calling job produces no check of its own;
|
|
439
508
|
// each called job becomes `<calling job name> / <called job name>`, and
|
|
@@ -446,7 +515,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
446
515
|
job: prefix + jobId,
|
|
447
516
|
checkName: null,
|
|
448
517
|
status: "unknown",
|
|
449
|
-
reason: "dynamic matrix on reusable workflow call",
|
|
518
|
+
reason: "dynamic matrix on reusable workflow call" + execNote(needs),
|
|
450
519
|
});
|
|
451
520
|
continue;
|
|
452
521
|
}
|
|
@@ -466,18 +535,37 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
466
535
|
failure = `unresolvable reusable reference: ${uses}`;
|
|
467
536
|
}
|
|
468
537
|
else {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
538
|
+
// A local `./` call stays on the caller's source, which is already
|
|
539
|
+
// pinned to a commit. A cross-repo one arrives as whatever the `uses:`
|
|
540
|
+
// string spelled — `@v0` — and has to be resolved before anything is
|
|
541
|
+
// read from it, so the file that gets read and the commit the
|
|
542
|
+
// prediction names are the same one.
|
|
543
|
+
let resolved = source;
|
|
544
|
+
if (target.source != null) {
|
|
545
|
+
const { ref } = target.source;
|
|
546
|
+
const sha = isSha(ref) ? ref : await reader.resolveRef(target.source);
|
|
547
|
+
resolved = sha == null ? null : { ...target.source, sha };
|
|
548
|
+
}
|
|
549
|
+
if (resolved == null) {
|
|
550
|
+
failure = `cannot resolve ref for ${uses}`;
|
|
473
551
|
}
|
|
474
552
|
else {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
553
|
+
subSource = resolved;
|
|
554
|
+
const content = await reader.fetchWorkflow(target.path, subSource);
|
|
555
|
+
if (content == null) {
|
|
556
|
+
failure = `cannot fetch ${uses}`;
|
|
478
557
|
}
|
|
479
|
-
|
|
480
|
-
|
|
558
|
+
else {
|
|
559
|
+
try {
|
|
560
|
+
subWf = parseYaml(content);
|
|
561
|
+
// `inputs.*` changes at the call boundary; `github.*` does not.
|
|
562
|
+
// A callee's jobs run in the caller's repo, so the facts seeded
|
|
563
|
+
// at the top of the prediction stay true all the way down.
|
|
564
|
+
subScope = { inputs: calleeInputs(job.with, subWf ?? {}), github: scoped.github };
|
|
565
|
+
}
|
|
566
|
+
catch (e) {
|
|
567
|
+
failure = `YAML parse error in ${uses}: ${e}`;
|
|
568
|
+
}
|
|
481
569
|
}
|
|
482
570
|
}
|
|
483
571
|
}
|
|
@@ -494,7 +582,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
494
582
|
});
|
|
495
583
|
continue;
|
|
496
584
|
}
|
|
497
|
-
entries.push(...(await expandJobs(subWf, ctx,
|
|
585
|
+
entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope, executor)));
|
|
498
586
|
}
|
|
499
587
|
continue;
|
|
500
588
|
}
|
|
@@ -503,7 +591,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
503
591
|
job: prefix + jobId,
|
|
504
592
|
checkName: null,
|
|
505
593
|
status: "unknown",
|
|
506
|
-
reason: "dynamic matrix",
|
|
594
|
+
reason: "dynamic matrix" + execNote(needs),
|
|
507
595
|
});
|
|
508
596
|
continue;
|
|
509
597
|
}
|
|
@@ -524,9 +612,14 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
|
|
|
524
612
|
* Expand one already-parsed workflow into its job entries. Exported so check
|
|
525
613
|
* names can be tested against recorded GitHub behaviour without a network
|
|
526
614
|
* round-trip; `predict` is the API you want.
|
|
615
|
+
*
|
|
616
|
+
* `scope` seeds what this workflow's own `${{ }}` resolve against — notably
|
|
617
|
+
* `needs`, the outputs of jobs that have not run. Nothing here works out what
|
|
618
|
+
* those are; a caller that knows hands them in, and a caller that does not
|
|
619
|
+
* leaves them out and gets `unknown` where they would have been used.
|
|
527
620
|
*/
|
|
528
|
-
export function expandWorkflowJobs(wf, ctx,
|
|
529
|
-
return expandJobs(wf, ctx,
|
|
621
|
+
export function expandWorkflowJobs(wf, ctx, reader, source, scope = {}, executor) {
|
|
622
|
+
return expandJobs(wf, ctx, reader, source, 0, "", true, scope, executor);
|
|
530
623
|
}
|
|
531
624
|
// ------------------------------------------------------------------- pipeline
|
|
532
625
|
export function makeOctokit() {
|
|
@@ -552,22 +645,60 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
|
552
645
|
files: files.map((f) => f.filename),
|
|
553
646
|
};
|
|
554
647
|
const headSha = pr.head.sha;
|
|
648
|
+
/**
|
|
649
|
+
* The PR's own repo at the head commit — where expansion starts, and already
|
|
650
|
+
* a commit id, so its `ref` and `sha` are the same string.
|
|
651
|
+
*/
|
|
652
|
+
const headSource = { owner, repo: name, ref: headSha, sha: headSha };
|
|
653
|
+
// Provenance for the answer, filled as expansion reaches each source. The head
|
|
654
|
+
// is in from the start: it is read even on the skip path, where the commit
|
|
655
|
+
// message is what decides the verdict.
|
|
656
|
+
const sources = new Map([[sourceKey(headSource), headSource]]);
|
|
555
657
|
const { data: headCommit } = await octokit.rest.repos.getCommit({
|
|
556
658
|
...base,
|
|
557
659
|
ref: headSha,
|
|
558
660
|
});
|
|
559
661
|
const headMsg = headCommit.commit.message;
|
|
560
662
|
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
561
|
-
return finalizePrediction([], "head commit message contains a skip instruction");
|
|
663
|
+
return finalizePrediction([], "head commit message contains a skip instruction", sources);
|
|
562
664
|
}
|
|
563
|
-
|
|
564
|
-
|
|
665
|
+
// A `uses:` naming a tag is the same lookup from every caller that writes it,
|
|
666
|
+
// so resolve each `owner/repo@ref` once. Misses are cached too: a ref that
|
|
667
|
+
// cannot be resolved will not start resolving on the second ask.
|
|
668
|
+
const refCache = new Map();
|
|
669
|
+
const resolveRef = async (src) => {
|
|
670
|
+
const key = sourceKey(src);
|
|
671
|
+
const hit = refCache.get(key);
|
|
672
|
+
if (hit !== undefined)
|
|
673
|
+
return hit;
|
|
674
|
+
let sha;
|
|
675
|
+
try {
|
|
676
|
+
const { data } = await octokit.rest.repos.getCommit({
|
|
677
|
+
owner: src.owner,
|
|
678
|
+
repo: src.repo,
|
|
679
|
+
ref: src.ref,
|
|
680
|
+
});
|
|
681
|
+
sha = data.sha;
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Deleted tag, private repo, rate limit, network: all one answer here.
|
|
685
|
+
// The caller turns it into an `unknown` entry rather than throwing.
|
|
686
|
+
sha = null;
|
|
687
|
+
}
|
|
688
|
+
refCache.set(key, sha);
|
|
689
|
+
if (sha != null)
|
|
690
|
+
sources.set(key, { ...src, sha });
|
|
691
|
+
return sha;
|
|
692
|
+
};
|
|
565
693
|
// One callee is commonly reached from several callers — a fleet repo calls
|
|
566
694
|
// the same `testing-conventions@v0` from eight workflows — so remember what
|
|
567
|
-
// each `owner/repo/path@
|
|
695
|
+
// each `owner/repo/path@sha` resolved to, misses included.
|
|
568
696
|
const cache = new Map();
|
|
569
697
|
const fetchWorkflow = async (path, src) => {
|
|
570
|
-
|
|
698
|
+
// Keyed and fetched on the commit, never the ref that named it. Two callers
|
|
699
|
+
// writing `@v0` and `@abc123` for the same commit are one read, and a tag
|
|
700
|
+
// that moves mid-prediction cannot hand back two different files.
|
|
701
|
+
const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
|
|
571
702
|
const hit = cache.get(key);
|
|
572
703
|
if (hit !== undefined)
|
|
573
704
|
return hit;
|
|
@@ -577,7 +708,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
|
577
708
|
owner: src.owner,
|
|
578
709
|
repo: src.repo,
|
|
579
710
|
path,
|
|
580
|
-
ref: src.
|
|
711
|
+
ref: src.sha,
|
|
581
712
|
mediaType: { format: "raw" },
|
|
582
713
|
});
|
|
583
714
|
content = data;
|
|
@@ -590,10 +721,48 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
|
590
721
|
cache.set(key, content);
|
|
591
722
|
return content;
|
|
592
723
|
};
|
|
724
|
+
const reader = { fetchWorkflow, resolveRef };
|
|
725
|
+
// The executor exists only when the caller granted something. Trees come
|
|
726
|
+
// from the tarball endpoint at the resolved commit, and every subprocess —
|
|
727
|
+
// `tar` included — goes through the one `runShell` seam.
|
|
728
|
+
let executor;
|
|
729
|
+
if (opts.execute != null && opts.execute.length > 0) {
|
|
730
|
+
const download = async (src) => {
|
|
731
|
+
try {
|
|
732
|
+
const { data } = await octokit.rest.repos.downloadTarballArchive({
|
|
733
|
+
owner: src.owner,
|
|
734
|
+
repo: src.repo,
|
|
735
|
+
ref: src.sha,
|
|
736
|
+
});
|
|
737
|
+
return new Uint8Array(data);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// Private, deleted, rate limit, network: one answer, and the entries
|
|
741
|
+
// behind it stay unresolved with the failure named.
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
executor = makeExecutor({
|
|
746
|
+
grants: opts.execute,
|
|
747
|
+
workspace: headSource,
|
|
748
|
+
deps: {
|
|
749
|
+
provideTree: makeTreeProvider(download, runShell),
|
|
750
|
+
runCommand: runShell,
|
|
751
|
+
resolveRef,
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
}
|
|
593
755
|
const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
|
|
594
756
|
...base,
|
|
595
757
|
per_page: 100,
|
|
596
758
|
});
|
|
759
|
+
// `github.repository` is fixed for everything predicted here: reusable
|
|
760
|
+
// workflows and composite actions all run in the repo the PR is against.
|
|
761
|
+
// Seeding it once makes guards like the fleet's hermetic-vs-published
|
|
762
|
+
// `github.repository ==` checks decidable everywhere, granted or not.
|
|
763
|
+
const prFacts = {
|
|
764
|
+
github: { repository: `${headSource.owner}/${headSource.repo}` },
|
|
765
|
+
};
|
|
597
766
|
const entries = [];
|
|
598
767
|
for (const w of workflows) {
|
|
599
768
|
const path = w.path;
|
|
@@ -642,7 +811,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
|
642
811
|
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
643
812
|
continue;
|
|
644
813
|
}
|
|
645
|
-
for (const j of await expandJobs(wf, ctx,
|
|
814
|
+
for (const j of await expandJobs(wf, ctx, reader, headSource, 0, "", true, prFacts, executor)) {
|
|
646
815
|
entries.push({
|
|
647
816
|
workflow: path,
|
|
648
817
|
job: jobName(j.job),
|
|
@@ -652,19 +821,25 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
|
652
821
|
});
|
|
653
822
|
}
|
|
654
823
|
}
|
|
655
|
-
return finalizePrediction(entries, null);
|
|
824
|
+
return finalizePrediction(entries, null, sources);
|
|
656
825
|
}
|
|
657
|
-
function finalizePrediction(entries, skip) {
|
|
826
|
+
function finalizePrediction(entries, skip, sources) {
|
|
658
827
|
const final = entries.map(finalize);
|
|
659
828
|
const names = new Set();
|
|
660
829
|
for (const e of final) {
|
|
661
830
|
if (e.status === "run" && e.checkName != null)
|
|
662
831
|
names.add(e.checkName);
|
|
663
832
|
}
|
|
664
|
-
return {
|
|
833
|
+
return {
|
|
834
|
+
entries: final,
|
|
835
|
+
checkNames: [...names].sort(),
|
|
836
|
+
skip,
|
|
837
|
+
sources: [...sources.values()].sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))),
|
|
838
|
+
};
|
|
665
839
|
}
|
|
666
840
|
// ------------------------------------------------------------------------ CLI
|
|
667
|
-
const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened]
|
|
841
|
+
const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened]" +
|
|
842
|
+
" [--execute owner/repo:job1,job2]... [--json]";
|
|
668
843
|
const isPrEventAction = (v) => v === "opened" || v === "synchronize" || v === "reopened";
|
|
669
844
|
function parseArgs(argv) {
|
|
670
845
|
const get = (flag) => {
|
|
@@ -686,28 +861,52 @@ function parseArgs(argv) {
|
|
|
686
861
|
console.error(USAGE);
|
|
687
862
|
process.exit(2);
|
|
688
863
|
}
|
|
689
|
-
|
|
864
|
+
// Repeatable, one grant per flag. A malformed grant is refused for the same
|
|
865
|
+
// reason a bad --action is: silently dropping it would predict without the
|
|
866
|
+
// execution the caller thought they asked for.
|
|
867
|
+
const execute = [];
|
|
868
|
+
for (let i = 0; i < argv.length; i++) {
|
|
869
|
+
if (argv[i] !== "--execute")
|
|
870
|
+
continue;
|
|
871
|
+
const spec = argv[i + 1];
|
|
872
|
+
const grant = spec == null ? null : parseGrant(spec);
|
|
873
|
+
if (grant == null) {
|
|
874
|
+
console.error(`bad --execute: ${spec}`);
|
|
875
|
+
console.error(USAGE);
|
|
876
|
+
process.exit(2);
|
|
877
|
+
}
|
|
878
|
+
execute.push(grant);
|
|
879
|
+
}
|
|
880
|
+
return { repo, pr: Number(pr), json: argv.includes("--json"), action, execute };
|
|
690
881
|
}
|
|
691
882
|
const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
|
|
692
883
|
if (isMain) {
|
|
693
884
|
const args = parseArgs(process.argv.slice(2));
|
|
694
|
-
const
|
|
885
|
+
const prediction = await predict(makeOctokit(), args.repo, args.pr, {
|
|
695
886
|
action: args.action,
|
|
887
|
+
execute: args.execute,
|
|
696
888
|
});
|
|
889
|
+
const { entries, skip, sources } = prediction;
|
|
697
890
|
if (args.json) {
|
|
698
|
-
console.log(JSON.stringify(
|
|
699
|
-
}
|
|
700
|
-
else if (skip) {
|
|
701
|
-
console.log(`# ${skip} -> nothing dispatches`);
|
|
891
|
+
console.log(JSON.stringify(prediction, null, 2));
|
|
702
892
|
}
|
|
703
893
|
else {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
894
|
+
if (skip) {
|
|
895
|
+
console.log(`# ${skip} -> nothing dispatches`);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
for (const e of entries) {
|
|
899
|
+
if (isWorkflowEntry(e))
|
|
900
|
+
console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
|
|
901
|
+
else {
|
|
902
|
+
const name = e.checkName ?? `${e.job} (name unresolved)`;
|
|
903
|
+
console.log(`${e.workflow} :: ${name} :: ${e.status}`);
|
|
904
|
+
}
|
|
710
905
|
}
|
|
711
906
|
}
|
|
907
|
+
// Last, and on the skip path too, so a red gate's first question — which
|
|
908
|
+
// commits was this read from? — is answered wherever the reader lands.
|
|
909
|
+
for (const s of sources)
|
|
910
|
+
console.log(`# read ${s.owner}/${s.repo}@${s.ref} -> ${s.sha}`);
|
|
712
911
|
}
|
|
713
912
|
}
|