willfire 0.1.11 → 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.
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/parseArgs.d.ts +9 -0
- package/dist/cli/parseArgs.js +42 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +41 -0
- package/dist/entries/index.d.ts +3 -0
- package/dist/entries/index.js +3 -0
- package/dist/entries/isJobEntry.d.ts +3 -0
- package/dist/entries/isJobEntry.js +2 -0
- package/dist/entries/isWorkflowEntry.d.ts +3 -0
- package/dist/entries/isWorkflowEntry.js +2 -0
- package/dist/entries/jobName.d.ts +3 -0
- package/dist/entries/jobName.js +2 -0
- package/dist/execute.d.ts +1 -1
- package/dist/filters/index.d.ts +2 -0
- package/dist/filters/index.js +2 -0
- package/dist/filters/matchFilters.d.ts +2 -0
- package/dist/filters/matchFilters.js +12 -0
- package/dist/filters/patternToRegex.d.ts +1 -0
- package/dist/filters/patternToRegex.js +35 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/jobs/evalIf.d.ts +10 -0
- package/dist/jobs/evalIf.js +18 -0
- package/dist/jobs/expandJobs.d.ts +4 -0
- package/dist/jobs/expandJobs.js +247 -0
- package/dist/jobs/expandWorkflowJobs.d.ts +14 -0
- package/dist/jobs/expandWorkflowJobs.js +14 -0
- package/dist/jobs/index.d.ts +4 -0
- package/dist/jobs/index.js +4 -0
- package/dist/jobs/prScope.d.ts +10 -0
- package/dist/jobs/prScope.js +19 -0
- package/dist/matrix/expandMatrix.d.ts +4 -0
- package/dist/matrix/expandMatrix.js +6 -0
- package/dist/matrix/expandMatrixDetailed.d.ts +3 -0
- package/dist/matrix/expandMatrixDetailed.js +74 -0
- package/dist/matrix/formatMatrixValue.d.ts +8 -0
- package/dist/matrix/formatMatrixValue.js +16 -0
- package/dist/matrix/index.d.ts +4 -0
- package/dist/matrix/index.js +4 -0
- package/dist/matrix/matrixSuffix.d.ts +3 -0
- package/dist/matrix/matrixSuffix.js +8 -0
- package/dist/names/index.d.ts +4 -0
- package/dist/names/index.js +4 -0
- package/dist/names/jobDisplayName.d.ts +16 -0
- package/dist/names/jobDisplayName.js +25 -0
- package/dist/names/lookupPath.d.ts +1 -0
- package/dist/names/lookupPath.js +9 -0
- package/dist/names/renderName.d.ts +2 -0
- package/dist/names/renderName.js +26 -0
- package/dist/names/skippedDisplayName.d.ts +14 -0
- package/dist/names/skippedDisplayName.js +16 -0
- package/dist/predict/finalizePrediction.d.ts +2 -0
- package/dist/predict/finalizePrediction.js +19 -0
- package/dist/predict/index.d.ts +5 -0
- package/dist/predict/index.js +5 -0
- package/dist/predict/makeOctokit.d.ts +2 -0
- package/dist/predict/makeOctokit.js +7 -0
- package/dist/predict/predict.d.ts +3 -0
- package/dist/predict/predict.js +214 -0
- package/dist/predict/sourceKey.d.ts +3 -0
- package/dist/predict/sourceKey.js +2 -0
- package/dist/predict/stackTargetRef.d.ts +14 -0
- package/dist/predict/stackTargetRef.js +58 -0
- package/dist/triggers/getPrTrigger.d.ts +3 -0
- package/dist/triggers/getPrTrigger.js +18 -0
- package/dist/triggers/index.d.ts +2 -0
- package/dist/triggers/index.js +2 -0
- package/dist/triggers/workflowDispatches.d.ts +2 -0
- package/dist/triggers/workflowDispatches.js +45 -0
- package/dist/{predict.d.ts → types.d.ts} +64 -53
- package/dist/types.js +1 -0
- package/dist/uses/index.d.ts +1 -0
- package/dist/uses/index.js +1 -0
- package/dist/uses/parseUses.d.ts +15 -0
- package/dist/uses/parseUses.js +33 -0
- package/dist/verify.js +1 -1
- package/package.json +7 -7
- package/dist/predict.js +0 -912
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { evaluateValue } from "../expr.js";
|
|
2
|
+
/**
|
|
3
|
+
* The values of one matrix axis, or null when they cannot be known.
|
|
4
|
+
*
|
|
5
|
+
* A plain list is itself. An axis written as an expression —
|
|
6
|
+
* `language: ${{ fromJSON(needs.detect.outputs.coverage_languages) }}` — is
|
|
7
|
+
* the values another job computed, and is knowable exactly when the scope
|
|
8
|
+
* carries that job's outputs. Anything else stays null, which is what makes
|
|
9
|
+
* the whole job `unknown` rather than a guess at how many checks it creates.
|
|
10
|
+
*/
|
|
11
|
+
function axisValues(v, scope) {
|
|
12
|
+
if (Array.isArray(v))
|
|
13
|
+
return v;
|
|
14
|
+
if (typeof v !== "string")
|
|
15
|
+
return null;
|
|
16
|
+
const val = evaluateValue(v, scope);
|
|
17
|
+
if (val.kind !== "json" || !Array.isArray(val.v))
|
|
18
|
+
return null;
|
|
19
|
+
return val.v;
|
|
20
|
+
}
|
|
21
|
+
export function expandMatrixDetailed(strategy, scope = {}) {
|
|
22
|
+
const matrix = strategy?.matrix;
|
|
23
|
+
if (matrix == null)
|
|
24
|
+
return [null];
|
|
25
|
+
// `matrix: ${{ ... }}` — the whole matrix as one expression, rather than the
|
|
26
|
+
// per-axis form below. It yields include-style entries, not axes, so it is a
|
|
27
|
+
// separate expansion and is not modelled.
|
|
28
|
+
if (typeof matrix === "string")
|
|
29
|
+
return null;
|
|
30
|
+
const include = matrix.include ?? [];
|
|
31
|
+
const exclude = matrix.exclude ?? [];
|
|
32
|
+
if (typeof include === "string" || typeof exclude === "string")
|
|
33
|
+
return null;
|
|
34
|
+
const axes = {};
|
|
35
|
+
for (const [k, v] of Object.entries(matrix)) {
|
|
36
|
+
if (k === "include" || k === "exclude")
|
|
37
|
+
continue;
|
|
38
|
+
const vals = axisValues(v, scope);
|
|
39
|
+
if (vals == null)
|
|
40
|
+
return null;
|
|
41
|
+
axes[k] = vals;
|
|
42
|
+
}
|
|
43
|
+
const axisKeys = Object.keys(axes);
|
|
44
|
+
let combos = [{ values: {}, displayKeys: axisKeys }];
|
|
45
|
+
for (const [k, vals] of Object.entries(axes)) {
|
|
46
|
+
combos = combos.flatMap((c) => vals.map((v) => ({ values: { ...c.values, [k]: v }, displayKeys: axisKeys })));
|
|
47
|
+
}
|
|
48
|
+
if (axisKeys.length === 0)
|
|
49
|
+
combos = [];
|
|
50
|
+
combos = combos.filter((c) => !exclude.some((ex) => Object.entries(ex).every(([k, v]) => c.values[k] === v)));
|
|
51
|
+
const extra = [];
|
|
52
|
+
for (const inc of include) {
|
|
53
|
+
const overlapping = Object.fromEntries(Object.entries(inc).filter(([k]) => k in axes));
|
|
54
|
+
const targets = combos.filter((c) => Object.entries(overlapping).every(([k, v]) => c.values[k] === v));
|
|
55
|
+
if (axisKeys.length > 0 && targets.length > 0) {
|
|
56
|
+
// Merge into the matching combinations. With no overlapping keys this
|
|
57
|
+
// matches every combination, per the docs ("added to each of the matrix
|
|
58
|
+
// combinations if none of the key:value pairs overwrite any of the
|
|
59
|
+
// original matrix values").
|
|
60
|
+
for (const c of targets)
|
|
61
|
+
Object.assign(c.values, inc);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// No combination to attach to: the include entry becomes a combination
|
|
65
|
+
// of its own, and every one of its keys shows in the name.
|
|
66
|
+
extra.push({ values: { ...inc }, displayKeys: Object.keys(inc) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
combos.push(...extra);
|
|
70
|
+
// Zero combinations is a real answer, not a missing one: an empty axis, or an
|
|
71
|
+
// `exclude` that removes everything, schedules no jobs at all. Only an absent
|
|
72
|
+
// `matrix:` key means "one unsuffixed job", and that returned above.
|
|
73
|
+
return combos;
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a single matrix value is rendered inside a check name.
|
|
3
|
+
*
|
|
4
|
+
* Probe-verified: object values are flattened to their own values, so
|
|
5
|
+
* `cfg: {os: linux, arch: x64}` renders as `linux, x64` — the check is
|
|
6
|
+
* `m-object (linux, x64)`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatMatrixValue(v: unknown): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a single matrix value is rendered inside a check name.
|
|
3
|
+
*
|
|
4
|
+
* Probe-verified: object values are flattened to their own values, so
|
|
5
|
+
* `cfg: {os: linux, arch: x64}` renders as `linux, x64` — the check is
|
|
6
|
+
* `m-object (linux, x64)`.
|
|
7
|
+
*/
|
|
8
|
+
export function formatMatrixValue(v) {
|
|
9
|
+
if (v == null)
|
|
10
|
+
return "";
|
|
11
|
+
if (Array.isArray(v))
|
|
12
|
+
return v.map(formatMatrixValue).join(", ");
|
|
13
|
+
if (typeof v === "object")
|
|
14
|
+
return Object.values(v).map(formatMatrixValue).join(", ");
|
|
15
|
+
return String(v);
|
|
16
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { formatMatrixValue } from "./formatMatrixValue.js";
|
|
2
|
+
/** The ` (v1, v2)` suffix GitHub appends for a matrix combination. */
|
|
3
|
+
export function matrixSuffix(combo) {
|
|
4
|
+
const keys = combo.displayKeys.filter((k) => k in combo.values);
|
|
5
|
+
if (keys.length === 0)
|
|
6
|
+
return "";
|
|
7
|
+
return ` (${keys.map((k) => formatMatrixValue(combo.values[k])).join(", ")})`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DetailedCombo, DisplayName, Workflow } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A `name:` that contains any `${{ }}` expression suppresses the matrix
|
|
4
|
+
* parenthetical; a literal one does not.
|
|
5
|
+
*
|
|
6
|
+
* Probe-verified three ways over `a: [x, y]`: `name: Static Label` yields
|
|
7
|
+
* `Static Label (x)` / `Static Label (y)`, `name: ev ${{ github.event_name }}`
|
|
8
|
+
* yields two checks both called `ev pull_request`, and
|
|
9
|
+
* `name: p ${{ matrix.a }}` over `a: [x], b: ["1", "2"]` yields two checks
|
|
10
|
+
* both called `p x`. So the trigger is the presence of an expression, not
|
|
11
|
+
* whether the expression happens to read the matrix — and duplicate check
|
|
12
|
+
* names are a real outcome GitHub allows.
|
|
13
|
+
*/
|
|
14
|
+
export declare const EXPRESSION_RE: RegExp;
|
|
15
|
+
/** The check name for one job/combination. */
|
|
16
|
+
export declare function jobDisplayName(jobId: string, job: Workflow, combo: DetailedCombo | null): DisplayName;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { matrixSuffix } from "../matrix/matrixSuffix.js";
|
|
2
|
+
import { renderName } from "./renderName.js";
|
|
3
|
+
/**
|
|
4
|
+
* A `name:` that contains any `${{ }}` expression suppresses the matrix
|
|
5
|
+
* parenthetical; a literal one does not.
|
|
6
|
+
*
|
|
7
|
+
* Probe-verified three ways over `a: [x, y]`: `name: Static Label` yields
|
|
8
|
+
* `Static Label (x)` / `Static Label (y)`, `name: ev ${{ github.event_name }}`
|
|
9
|
+
* yields two checks both called `ev pull_request`, and
|
|
10
|
+
* `name: p ${{ matrix.a }}` over `a: [x], b: ["1", "2"]` yields two checks
|
|
11
|
+
* both called `p x`. So the trigger is the presence of an expression, not
|
|
12
|
+
* whether the expression happens to read the matrix — and duplicate check
|
|
13
|
+
* names are a real outcome GitHub allows.
|
|
14
|
+
*/
|
|
15
|
+
export const EXPRESSION_RE = /\$\{\{/;
|
|
16
|
+
/** The check name for one job/combination. */
|
|
17
|
+
export function jobDisplayName(jobId, job, combo) {
|
|
18
|
+
const raw = job != null && job.name != null ? String(job.name) : null;
|
|
19
|
+
if (raw === null) {
|
|
20
|
+
return { name: jobId + (combo ? matrixSuffix(combo) : ""), resolved: true };
|
|
21
|
+
}
|
|
22
|
+
const { text, resolved } = renderName(raw, combo?.values ?? null);
|
|
23
|
+
const suffix = combo && !EXPRESSION_RE.test(raw) ? matrixSuffix(combo) : "";
|
|
24
|
+
return { name: text + suffix, resolved };
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function lookupPath(obj: any, path: string): unknown;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { formatMatrixValue } from "../matrix/formatMatrixValue.js";
|
|
2
|
+
import { lookupPath } from "./lookupPath.js";
|
|
3
|
+
export function renderName(template, combo) {
|
|
4
|
+
let resolved = true;
|
|
5
|
+
const text = template.replace(/\$\{\{(.*?)\}\}/g, (whole, inner) => {
|
|
6
|
+
const expr = String(inner).trim();
|
|
7
|
+
if (expr.startsWith("matrix.")) {
|
|
8
|
+
if (!combo) {
|
|
9
|
+
resolved = false;
|
|
10
|
+
return whole;
|
|
11
|
+
}
|
|
12
|
+
const val = lookupPath(combo, expr.slice("matrix.".length));
|
|
13
|
+
if (val === undefined) {
|
|
14
|
+
resolved = false;
|
|
15
|
+
return whole;
|
|
16
|
+
}
|
|
17
|
+
return formatMatrixValue(val);
|
|
18
|
+
}
|
|
19
|
+
// We only predict pull_request dispatch, so this one is knowable.
|
|
20
|
+
if (expr === "github.event_name")
|
|
21
|
+
return "pull_request";
|
|
22
|
+
resolved = false;
|
|
23
|
+
return whole;
|
|
24
|
+
});
|
|
25
|
+
return { text, resolved };
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DisplayName, Workflow } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The check name a job gets when it is skipped.
|
|
4
|
+
*
|
|
5
|
+
* A skipped job is never set up, so nothing about it is evaluated: the matrix
|
|
6
|
+
* does not expand and `name:` is not interpolated. Probe-verified twice over:
|
|
7
|
+
* `if: false` with `a: [x, y]` produces the single check `m-skipped`, not
|
|
8
|
+
* `m-skipped (x)` / `m-skipped (y)`; and `name: sk ${{ github.event_name }}`
|
|
9
|
+
* with `if: false` produces a check literally called
|
|
10
|
+
* `sk ${{ github.event_name }}`, expression text and all. The same collapse
|
|
11
|
+
* applies to a skipped reusable-workflow call: one check named after the
|
|
12
|
+
* caller, with no `/ <callee job>` entries.
|
|
13
|
+
*/
|
|
14
|
+
export declare function skippedDisplayName(jobId: string, job: Workflow): DisplayName;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check name a job gets when it is skipped.
|
|
3
|
+
*
|
|
4
|
+
* A skipped job is never set up, so nothing about it is evaluated: the matrix
|
|
5
|
+
* does not expand and `name:` is not interpolated. Probe-verified twice over:
|
|
6
|
+
* `if: false` with `a: [x, y]` produces the single check `m-skipped`, not
|
|
7
|
+
* `m-skipped (x)` / `m-skipped (y)`; and `name: sk ${{ github.event_name }}`
|
|
8
|
+
* with `if: false` produces a check literally called
|
|
9
|
+
* `sk ${{ github.event_name }}`, expression text and all. The same collapse
|
|
10
|
+
* applies to a skipped reusable-workflow call: one check named after the
|
|
11
|
+
* caller, with no `/ <callee job>` entries.
|
|
12
|
+
*/
|
|
13
|
+
export function skippedDisplayName(jobId, job) {
|
|
14
|
+
const raw = job != null && job.name != null ? String(job.name) : null;
|
|
15
|
+
return { name: raw ?? jobId, resolved: true };
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sourceKey } from "./sourceKey.js";
|
|
2
|
+
const isWorkflowDraft = (e) => e.job === "*";
|
|
3
|
+
const finalize = (e) => isWorkflowDraft(e)
|
|
4
|
+
? { ...e, checkName: null }
|
|
5
|
+
: { ...e, checkName: e.checkName ?? null };
|
|
6
|
+
export function finalizePrediction(entries, skip, sources) {
|
|
7
|
+
const final = entries.map(finalize);
|
|
8
|
+
const names = new Set();
|
|
9
|
+
for (const e of final) {
|
|
10
|
+
if (e.status === "run" && e.checkName != null)
|
|
11
|
+
names.add(e.checkName);
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
entries: final,
|
|
15
|
+
checkNames: [...names].sort(),
|
|
16
|
+
skip,
|
|
17
|
+
sources: [...sources.values()].sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Predict the set of CI check entries GitHub Actions will create for a PR.
|
|
2
|
+
//
|
|
3
|
+
// Faithful port of predict.py, which was verified entry-for-entry against
|
|
4
|
+
// live dispatches on thekevinbot/willrun-probe (PRs 1-7). Check-name
|
|
5
|
+
// resolution was verified the same way on probe PR 8, and cross-repo reusable
|
|
6
|
+
// workflow calls on probe PR 9; the rules they turned up are pinned in
|
|
7
|
+
// src/names.test.ts.
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
import { jobName } from "../entries/jobName.js";
|
|
10
|
+
import { makeExecutor, makeTreeProvider, runShell } from "../execute.js";
|
|
11
|
+
import { expandJobs } from "../jobs/expandJobs.js";
|
|
12
|
+
import { workflowDispatches } from "../triggers/workflowDispatches.js";
|
|
13
|
+
import { finalizePrediction } from "./finalizePrediction.js";
|
|
14
|
+
import { sourceKey } from "./sourceKey.js";
|
|
15
|
+
import { stackTargetRef } from "./stackTargetRef.js";
|
|
16
|
+
const SKIP_RE = /\[(skip ci|ci skip|no ci|skip actions|actions skip)\]/i;
|
|
17
|
+
const SKIP_TRAILER_RE = /^skip-checks:\s*true/im;
|
|
18
|
+
export async function predict(octokit, repo, prNumber, opts = {}) {
|
|
19
|
+
const [owner, name] = repo.split("/");
|
|
20
|
+
const base = { owner, repo: name };
|
|
21
|
+
const { data: pr } = await octokit.rest.pulls.get({ ...base, pull_number: prNumber });
|
|
22
|
+
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
|
|
23
|
+
...base,
|
|
24
|
+
pull_number: prNumber,
|
|
25
|
+
per_page: 100,
|
|
26
|
+
});
|
|
27
|
+
const stackTarget = await stackTargetRef(octokit, owner, name, pr);
|
|
28
|
+
const ctx = {
|
|
29
|
+
// The caller's answer wins whenever it has one. The commit-count fallback
|
|
30
|
+
// is a guess kept only so existing callers keep working.
|
|
31
|
+
action: opts.action ?? (pr.commits > 1 ? "synchronize" : "opened"),
|
|
32
|
+
baseRef: pr.base.ref,
|
|
33
|
+
...(stackTarget != null ? { stackTarget } : {}),
|
|
34
|
+
files: files.map((f) => f.filename),
|
|
35
|
+
};
|
|
36
|
+
const headSha = pr.head.sha;
|
|
37
|
+
/**
|
|
38
|
+
* The PR's own repo at the head commit — where expansion starts, and already
|
|
39
|
+
* a commit id, so its `ref` and `sha` are the same string.
|
|
40
|
+
*/
|
|
41
|
+
const headSource = { owner, repo: name, ref: headSha, sha: headSha };
|
|
42
|
+
// Provenance for the answer, filled as expansion reaches each source. The head
|
|
43
|
+
// is in from the start: it is read even on the skip path, where the commit
|
|
44
|
+
// message is what decides the verdict.
|
|
45
|
+
const sources = new Map([[sourceKey(headSource), headSource]]);
|
|
46
|
+
const { data: headCommit } = await octokit.rest.repos.getCommit({
|
|
47
|
+
...base,
|
|
48
|
+
ref: headSha,
|
|
49
|
+
});
|
|
50
|
+
const headMsg = headCommit.commit.message;
|
|
51
|
+
if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
|
|
52
|
+
return finalizePrediction([], "head commit message contains a skip instruction", sources);
|
|
53
|
+
}
|
|
54
|
+
// A `uses:` naming a tag is the same lookup from every caller that writes it,
|
|
55
|
+
// so resolve each `owner/repo@ref` once. Misses are cached too: a ref that
|
|
56
|
+
// cannot be resolved will not start resolving on the second ask.
|
|
57
|
+
const refCache = new Map();
|
|
58
|
+
const resolveRef = async (src) => {
|
|
59
|
+
const key = sourceKey(src);
|
|
60
|
+
const hit = refCache.get(key);
|
|
61
|
+
if (hit !== undefined)
|
|
62
|
+
return hit;
|
|
63
|
+
let sha;
|
|
64
|
+
try {
|
|
65
|
+
const { data } = await octokit.rest.repos.getCommit({
|
|
66
|
+
owner: src.owner,
|
|
67
|
+
repo: src.repo,
|
|
68
|
+
ref: src.ref,
|
|
69
|
+
});
|
|
70
|
+
sha = data.sha;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Deleted tag, private repo, rate limit, network: all one answer here.
|
|
74
|
+
// The caller turns it into an `unknown` entry rather than throwing.
|
|
75
|
+
sha = null;
|
|
76
|
+
}
|
|
77
|
+
refCache.set(key, sha);
|
|
78
|
+
if (sha != null)
|
|
79
|
+
sources.set(key, { ...src, sha });
|
|
80
|
+
return sha;
|
|
81
|
+
};
|
|
82
|
+
// One callee is commonly reached from several callers — a fleet repo calls
|
|
83
|
+
// the same `testing-conventions@v0` from eight workflows — so remember what
|
|
84
|
+
// each `owner/repo/path@sha` resolved to, misses included.
|
|
85
|
+
const cache = new Map();
|
|
86
|
+
const fetchWorkflow = async (path, src) => {
|
|
87
|
+
// Keyed and fetched on the commit, never the ref that named it. Two callers
|
|
88
|
+
// writing `@v0` and `@abc123` for the same commit are one read, and a tag
|
|
89
|
+
// that moves mid-prediction cannot hand back two different files.
|
|
90
|
+
const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
|
|
91
|
+
const hit = cache.get(key);
|
|
92
|
+
if (hit !== undefined)
|
|
93
|
+
return hit;
|
|
94
|
+
let content;
|
|
95
|
+
try {
|
|
96
|
+
const { data } = await octokit.rest.repos.getContent({
|
|
97
|
+
owner: src.owner,
|
|
98
|
+
repo: src.repo,
|
|
99
|
+
path,
|
|
100
|
+
ref: src.sha,
|
|
101
|
+
mediaType: { format: "raw" },
|
|
102
|
+
});
|
|
103
|
+
content = data;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Private, deleted, bad ref, rate limit, network: all one answer here.
|
|
107
|
+
// The caller turns it into an `unknown` entry rather than throwing.
|
|
108
|
+
content = null;
|
|
109
|
+
}
|
|
110
|
+
cache.set(key, content);
|
|
111
|
+
return content;
|
|
112
|
+
};
|
|
113
|
+
const reader = { fetchWorkflow, resolveRef };
|
|
114
|
+
// The executor exists only when the caller granted something. Trees come
|
|
115
|
+
// from the tarball endpoint at the resolved commit, and every subprocess —
|
|
116
|
+
// `tar` included — goes through the one `runShell` seam.
|
|
117
|
+
let executor;
|
|
118
|
+
if (opts.execute != null && opts.execute.length > 0) {
|
|
119
|
+
const download = async (src) => {
|
|
120
|
+
try {
|
|
121
|
+
const { data } = await octokit.rest.repos.downloadTarballArchive({
|
|
122
|
+
owner: src.owner,
|
|
123
|
+
repo: src.repo,
|
|
124
|
+
ref: src.sha,
|
|
125
|
+
});
|
|
126
|
+
return new Uint8Array(data);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Private, deleted, rate limit, network: one answer, and the entries
|
|
130
|
+
// behind it stay unresolved with the failure named.
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
executor = makeExecutor({
|
|
135
|
+
grants: opts.execute,
|
|
136
|
+
workspace: headSource,
|
|
137
|
+
deps: {
|
|
138
|
+
provideTree: makeTreeProvider(download, runShell),
|
|
139
|
+
runCommand: runShell,
|
|
140
|
+
resolveRef,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
|
|
145
|
+
...base,
|
|
146
|
+
per_page: 100,
|
|
147
|
+
});
|
|
148
|
+
// `github.repository` is fixed for everything predicted here: reusable
|
|
149
|
+
// workflows and composite actions all run in the repo the PR is against.
|
|
150
|
+
// Seeding it once makes guards like the fleet's hermetic-vs-published
|
|
151
|
+
// `github.repository ==` checks decidable everywhere, granted or not.
|
|
152
|
+
const prFacts = {
|
|
153
|
+
github: { repository: `${headSource.owner}/${headSource.repo}` },
|
|
154
|
+
};
|
|
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;
|
|
168
|
+
}
|
|
169
|
+
const content = await fetchWorkflow(path, headSource);
|
|
170
|
+
if (content == null) {
|
|
171
|
+
// The Actions API keeps listing a workflow as `active` after its file is
|
|
172
|
+
// deleted. There is no file at head, so there is nothing to dispatch —
|
|
173
|
+
// 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;
|
|
181
|
+
}
|
|
182
|
+
let wf;
|
|
183
|
+
try {
|
|
184
|
+
wf = parseYaml(content);
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
// GitHub creates a run for an unparseable workflow file and concludes it
|
|
188
|
+
// `startup_failure`. The run exists but has no jobs, so this is a
|
|
189
|
+
// 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;
|
|
197
|
+
}
|
|
198
|
+
const [dispatches, reason] = workflowDispatches(wf, ctx);
|
|
199
|
+
if (!dispatches) {
|
|
200
|
+
entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
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
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return finalizePrediction(entries, null, sources);
|
|
214
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Octokit } from "@octokit/rest";
|
|
2
|
+
import type { StackNode } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The branch this PR's stack ultimately targets, or null for a plain PR.
|
|
5
|
+
*
|
|
6
|
+
* GitHub's stacked-PR machinery (server-side, per-repo rollout — engaged on
|
|
7
|
+
* dirsql, not on willrun-probe, so it cannot be inferred from PR structure)
|
|
8
|
+
* builds a child PR's test merge on the parent PR's test merge and evaluates
|
|
9
|
+
* `branches:` against the stack's terminal target (#30). The mode is read off
|
|
10
|
+
* `merge_commit_sha`: its first parent is the base tip in normal mode and the
|
|
11
|
+
* parent PR's own merge sha in stacked mode. Anything undecidable ends the
|
|
12
|
+
* walk at the last proven hop; never throws.
|
|
13
|
+
*/
|
|
14
|
+
export declare function stackTargetRef(octokit: Octokit, owner: string, repo: string, pr: StackNode): Promise<string | null>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Past this the walk stops at the last proven hop, which only narrows reach. */
|
|
2
|
+
const MAX_STACK_DEPTH = 10;
|
|
3
|
+
/**
|
|
4
|
+
* The branch this PR's stack ultimately targets, or null for a plain PR.
|
|
5
|
+
*
|
|
6
|
+
* GitHub's stacked-PR machinery (server-side, per-repo rollout — engaged on
|
|
7
|
+
* dirsql, not on willrun-probe, so it cannot be inferred from PR structure)
|
|
8
|
+
* builds a child PR's test merge on the parent PR's test merge and evaluates
|
|
9
|
+
* `branches:` against the stack's terminal target (#30). The mode is read off
|
|
10
|
+
* `merge_commit_sha`: its first parent is the base tip in normal mode and the
|
|
11
|
+
* parent PR's own merge sha in stacked mode. Anything undecidable ends the
|
|
12
|
+
* walk at the last proven hop; never throws.
|
|
13
|
+
*/
|
|
14
|
+
export async function stackTargetRef(octokit, owner, repo, pr) {
|
|
15
|
+
let target = null;
|
|
16
|
+
let cur = pr;
|
|
17
|
+
try {
|
|
18
|
+
for (let hop = 0; hop < MAX_STACK_DEPTH; hop++) {
|
|
19
|
+
const mergeSha = cur.merge_commit_sha;
|
|
20
|
+
if (mergeSha == null)
|
|
21
|
+
break;
|
|
22
|
+
const { data: preview } = await octokit.rest.repos.getCommit({
|
|
23
|
+
owner,
|
|
24
|
+
repo,
|
|
25
|
+
ref: mergeSha,
|
|
26
|
+
});
|
|
27
|
+
const previewParent = preview.parents[0]?.sha;
|
|
28
|
+
if (previewParent == null)
|
|
29
|
+
break;
|
|
30
|
+
const { data: baseTip } = await octokit.rest.repos.getCommit({
|
|
31
|
+
owner,
|
|
32
|
+
repo,
|
|
33
|
+
ref: cur.base.ref,
|
|
34
|
+
});
|
|
35
|
+
// Built on the base branch tip: normal mode, the walk is done.
|
|
36
|
+
if (previewParent === baseTip.sha)
|
|
37
|
+
break;
|
|
38
|
+
// Otherwise only an exact match against an open PR whose head is the
|
|
39
|
+
// base branch proves stacked mode; a stale preview matches nothing.
|
|
40
|
+
const { data: candidates } = await octokit.rest.pulls.list({
|
|
41
|
+
owner,
|
|
42
|
+
repo,
|
|
43
|
+
state: "open",
|
|
44
|
+
head: `${owner}:${cur.base.ref}`,
|
|
45
|
+
per_page: 100,
|
|
46
|
+
});
|
|
47
|
+
const parent = candidates.find((p) => p.merge_commit_sha === previewParent);
|
|
48
|
+
if (parent == null)
|
|
49
|
+
break;
|
|
50
|
+
target = parent.base.ref;
|
|
51
|
+
cur = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Rate limit, permissions, network: stop at the last proven hop.
|
|
56
|
+
}
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const MISSING = Symbol("missing");
|
|
2
|
+
export function getPrTrigger(wf) {
|
|
3
|
+
// YAML 1.1 parsers read `on` as boolean true; the `yaml` package (1.2)
|
|
4
|
+
// keeps it a string key. Handle both.
|
|
5
|
+
const on = wf["on"] ?? wf["true"];
|
|
6
|
+
if (on == null)
|
|
7
|
+
return MISSING;
|
|
8
|
+
if (typeof on === "string")
|
|
9
|
+
return on === "pull_request" ? {} : MISSING;
|
|
10
|
+
if (Array.isArray(on))
|
|
11
|
+
return on.includes("pull_request") ? {} : MISSING;
|
|
12
|
+
if (typeof on === "object") {
|
|
13
|
+
if ("pull_request" in on)
|
|
14
|
+
return on["pull_request"] ?? {};
|
|
15
|
+
return MISSING;
|
|
16
|
+
}
|
|
17
|
+
return MISSING;
|
|
18
|
+
}
|