@bluewombat/slots 0.10.0 → 0.11.1
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 +152 -63
- package/agents/agent-plugins.mjs +154 -6
- package/agents/claude.mjs +2 -1
- package/agents/cursor.mjs +2 -0
- package/builders/{e2e.mjs → feature-test.mjs} +27 -17
- package/gates/base-diff.mjs +276 -0
- package/gates/ci-green.mjs +83 -3
- package/gates/feature-tests.mjs +280 -0
- package/gates/parent-clean.mjs +46 -8
- package/gates/tests-only.mjs +65 -0
- package/package.json +2 -2
- package/publishers/git.mjs +117 -16
- package/builders/tests.mjs +0 -139
- package/gates/e2e.mjs +0 -538
- package/gates/tests-frozen.mjs +0 -96
- package/gates/tests-red.mjs +0 -227
|
@@ -15,29 +15,38 @@ import {
|
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
16
|
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
|
-
// The Ticket that holds a Feature's
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// The Ticket that holds a Feature's tests. One command for its first pass and for a pass a Gate
|
|
19
|
+
// refused: the tests are written once from the scenarios of the task, and a refusal is about their
|
|
20
|
+
// form, so there is no second command to tell apart. `{{report}}` and `{{refused_by}}` are empty
|
|
21
|
+
// on a first pass; `{{refusal}}` is a ready-made paragraph about them.
|
|
22
22
|
|
|
23
|
-
const NAME = "builder
|
|
23
|
+
const NAME = "builder feature-test";
|
|
24
24
|
|
|
25
25
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
26
26
|
|
|
27
|
-
You write the
|
|
28
|
-
|
|
27
|
+
You write the tests of a feature. The task below lists its scenarios, agreed with the operator
|
|
28
|
+
before development, each with an id (S1, S2, ...). The feature is built: this checkout holds every
|
|
29
|
+
other Ticket of it.
|
|
29
30
|
|
|
30
31
|
Task:
|
|
31
32
|
{{task}}
|
|
32
33
|
|
|
33
|
-
- Write one test per scenario, in the
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
- Write one test per scenario, in the test framework this project already uses, at the level the
|
|
35
|
+
scenario describes (through the feature's public interfaces: routes, services, pages), and start
|
|
36
|
+
the name of each test with its scenario id ("S1 …"). Follow the scenario's steps and expected
|
|
37
|
+
result as written; do not add, merge, drop or weaken one. If a scenario changes a behaviour that
|
|
38
|
+
existing tests describe, update those tests to the new behaviour, and only those.
|
|
39
|
+
- List the ids of the scenarios in the manifest \`feature-tests/scenarios.json\`, a JSON array:
|
|
40
|
+
["S1", "S2"]. Only the scenarios of this feature, never the project's other tests.
|
|
41
|
+
- Write only tests, their helpers and the manifest. Touch no file of the application, and add no
|
|
38
42
|
dependency it does not already have.
|
|
39
|
-
-
|
|
40
|
-
|
|
43
|
+
- A Gate runs your tests against the built feature after every attempt and gives you its report
|
|
44
|
+
(the refusal below). Read it: a test that fails for its own reasons (a wrong selector or wait, a
|
|
45
|
+
wrong data setup, a misread expectation) is yours to fix. Check only that your tests compile
|
|
46
|
+
before you finish, with the project's own typecheck or build command.
|
|
47
|
+
- If a test stays red because the feature does not do what the scenario says, do not change the
|
|
48
|
+
application and do not weaken the test: that is a finding. Change nothing more and end with
|
|
49
|
+
MASON_BLOCKED, naming the scenario, what the feature does instead and where.
|
|
41
50
|
|
|
42
51
|
{{refusal}}
|
|
43
52
|
|
|
@@ -45,9 +54,10 @@ Task:
|
|
|
45
54
|
|
|
46
55
|
## When you cannot act
|
|
47
56
|
|
|
48
|
-
If what you were given is not enough to act on (a scenario is ambiguous,
|
|
49
|
-
the
|
|
50
|
-
|
|
57
|
+
If what you were given is not enough to act on (a scenario is ambiguous, cannot be written without
|
|
58
|
+
changing the application or without a weaker result than asked, the framework is missing, the
|
|
59
|
+
feature is wrong, the cause is outside this directory), do not invent a change and do not lower
|
|
60
|
+
the scenario. Change nothing and end your final message with exactly one line:
|
|
51
61
|
|
|
52
62
|
MASON_BLOCKED: <what is missing, in one line>
|
|
53
63
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
emitVerdict,
|
|
8
|
+
GATE_FLAGS,
|
|
9
|
+
listOption,
|
|
10
|
+
ownArgv,
|
|
11
|
+
parseOptions,
|
|
12
|
+
pathMatchesGlob,
|
|
13
|
+
stageOf,
|
|
14
|
+
take,
|
|
15
|
+
textOption,
|
|
16
|
+
} from "@bluewombat/slot-kit";
|
|
17
|
+
|
|
18
|
+
// What the work changed since the line it is offered to, judged by path.
|
|
19
|
+
//
|
|
20
|
+
// The base is the work line the workspace was cut from. Without --base it is the
|
|
21
|
+
// branch checked out in the main worktree of the repository the workspace belongs
|
|
22
|
+
// to: Isolation makes every workspace a worktree of the work line's copy, and Host
|
|
23
|
+
// keeps that copy on the line the work lands on (a Feature's own line while a
|
|
24
|
+
// Feature is open), so the answer follows the line. A branch named by hand
|
|
25
|
+
// (`--base main`) is the copy's local branch and is only as fresh as the last
|
|
26
|
+
// time something moved it; Host moves the Feature's line, not `main`.
|
|
27
|
+
//
|
|
28
|
+
// The changed set is what differs between the base and the workspace: the commits
|
|
29
|
+
// since the workspace left it (`BASE...HEAD`) and whatever is on disk and not yet
|
|
30
|
+
// committed (a fold can carry it out, so it counts; an ignored path does not).
|
|
31
|
+
// Unlike a Gate that compares the disk to the last commit, it still sees the work
|
|
32
|
+
// once it is committed, so it answers at the unit, the ticket and the recheck
|
|
33
|
+
// after the align (`--stage assembly`).
|
|
34
|
+
//
|
|
35
|
+
// At `--stage feature` there is no base to derive: the Feature's workspace is its
|
|
36
|
+
// line with the target merged in, so the line would show the target's work. Name
|
|
37
|
+
// the target with --base there.
|
|
38
|
+
//
|
|
39
|
+
// --base REF optional. The ref to compare with, instead of the main
|
|
40
|
+
// worktree's branch.
|
|
41
|
+
//
|
|
42
|
+
// Two ways to use the changed set, alone or together:
|
|
43
|
+
//
|
|
44
|
+
// --deny GLOB repeatable. fail-retryable when a changed path matches:
|
|
45
|
+
// "this must not be touched".
|
|
46
|
+
// --when GLOB repeatable, with --run. When a changed path matches, run
|
|
47
|
+
// CMD with project-checks (shell, output cleaned, the Gate's
|
|
48
|
+
// ceiling honoured) and answer as it does: "if this changed,
|
|
49
|
+
// this must hold". When nothing matches, pass without running it.
|
|
50
|
+
// --run CMD MASON_BASE holds the base; MASON_CHANGED_FILES holds the
|
|
51
|
+
// changed paths, one per line, so CMD can narrow itself to them.
|
|
52
|
+
//
|
|
53
|
+
// `*` is one path segment, `**` any depth.
|
|
54
|
+
|
|
55
|
+
const cwd = process.cwd();
|
|
56
|
+
const projectChecks = join(dirname(fileURLToPath(import.meta.url)), "project-checks.mjs");
|
|
57
|
+
|
|
58
|
+
const parsed = parseOptions(
|
|
59
|
+
ownArgv(process.argv, GATE_FLAGS),
|
|
60
|
+
{ "--base": {}, "--deny": { many: true }, "--when": { many: true }, "--run": {} },
|
|
61
|
+
"base-diff",
|
|
62
|
+
);
|
|
63
|
+
if (!parsed.ok) {
|
|
64
|
+
emitVerdict("fail-blocking", parsed.reason);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
const explicitBase = textOption(parsed.values, "base");
|
|
68
|
+
const deny = listOption(parsed.values, "deny");
|
|
69
|
+
const when = listOption(parsed.values, "when");
|
|
70
|
+
const command = textOption(parsed.values, "run");
|
|
71
|
+
|
|
72
|
+
if (explicitBase?.startsWith("-")) {
|
|
73
|
+
emitVerdict("fail-blocking", `base-diff cannot take "${explicitBase}" as a base.`);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
if (deny.length === 0 && when.length === 0 && command === undefined) {
|
|
77
|
+
emitVerdict(
|
|
78
|
+
"fail-blocking",
|
|
79
|
+
"base-diff needs at least one --deny GLOB, or --when GLOB with --run CMD.",
|
|
80
|
+
);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
if (when.length > 0 !== (command !== undefined)) {
|
|
84
|
+
emitVerdict(
|
|
85
|
+
"fail-blocking",
|
|
86
|
+
"base-diff takes --when and --run together: a condition, and what to run on it.",
|
|
87
|
+
);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
92
|
+
emitVerdict("fail-blocking", "base-diff needs a git workspace.");
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const base = explicitBase ?? deriveBase();
|
|
97
|
+
if (base === undefined) {
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
if (git(["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).status !== 0) {
|
|
101
|
+
emitVerdict("fail-blocking", `base-diff cannot find the base "${base}" in this workspace.`);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const changed = changedPaths();
|
|
106
|
+
if (changed === undefined) {
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (deny.length > 0) {
|
|
111
|
+
const hit = changed.filter((path) => matchesAny(path, deny));
|
|
112
|
+
if (hit.length > 0) {
|
|
113
|
+
emitVerdict(
|
|
114
|
+
"fail-retryable",
|
|
115
|
+
`Paths changed since ${base} that this work may not touch (${deny.join(", ")}):\n${hit.join("\n")}`,
|
|
116
|
+
);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (command !== undefined && changed.some((path) => matchesAny(path, when))) {
|
|
122
|
+
runCondition(command);
|
|
123
|
+
} else {
|
|
124
|
+
emitVerdict("pass");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The branch checked out in the main worktree: the line this workspace's work
|
|
129
|
+
* lands on. Nothing when there is none to name; the verdict is then already out.
|
|
130
|
+
* @returns {string | undefined}
|
|
131
|
+
*/
|
|
132
|
+
function deriveBase() {
|
|
133
|
+
if (stageOf(process.argv) === "feature") {
|
|
134
|
+
emitVerdict(
|
|
135
|
+
"fail-blocking",
|
|
136
|
+
"base-diff cannot derive a base at the feature stage: the Feature's workspace is its line with the target merged in, so the line would show the target's work, not the Feature's. Name the target with --base.",
|
|
137
|
+
);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const listed = git(["worktree", "list", "--porcelain"]);
|
|
141
|
+
if (listed.status !== 0) {
|
|
142
|
+
emitVerdict("fail-blocking", `base-diff cannot list the worktrees: ${listed.stderr.trim()}`);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
// The first entry is the main worktree.
|
|
146
|
+
const main = listed.stdout.split("\n\n")[0] ?? "";
|
|
147
|
+
const mainPath = /^worktree (.+)$/m.exec(main)?.[1];
|
|
148
|
+
const branch = /^branch refs\/heads\/(.+)$/m.exec(main)?.[1];
|
|
149
|
+
const top = git(["rev-parse", "--show-toplevel"]).stdout.trim();
|
|
150
|
+
if (mainPath === undefined || top.length === 0) {
|
|
151
|
+
emitVerdict(
|
|
152
|
+
"fail-blocking",
|
|
153
|
+
"base-diff cannot tell which worktree is the main one. Name the base with --base.",
|
|
154
|
+
);
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
if (sameDirectory(mainPath, top)) {
|
|
158
|
+
emitVerdict(
|
|
159
|
+
"fail-blocking",
|
|
160
|
+
"base-diff has no base to derive: this workspace is the main worktree itself, not a worktree of a work line. Name the base with --base.",
|
|
161
|
+
);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
if (branch === undefined) {
|
|
165
|
+
emitVerdict(
|
|
166
|
+
"fail-blocking",
|
|
167
|
+
`base-diff has no base to derive: the main worktree (${mainPath}) has no branch checked out. Name the base with --base.`,
|
|
168
|
+
);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
return `refs/heads/${branch}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Runs the --run command through project-checks, so it is cleaned, bounded and
|
|
176
|
+
* classified the way every command of the Project is, and relays its verdict.
|
|
177
|
+
* @param {string} cmd
|
|
178
|
+
*/
|
|
179
|
+
function runCondition(cmd) {
|
|
180
|
+
const flags = ["--cmd", cmd];
|
|
181
|
+
const ceiling = take(process.argv, "--timeout-ms");
|
|
182
|
+
if (ceiling !== undefined) {
|
|
183
|
+
flags.push("--timeout-ms", ceiling);
|
|
184
|
+
}
|
|
185
|
+
const result = spawnSync(process.execPath, [projectChecks, ...flags], {
|
|
186
|
+
cwd,
|
|
187
|
+
encoding: "utf8",
|
|
188
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
189
|
+
env: { ...process.env, MASON_BASE: base, MASON_CHANGED_FILES: (changed ?? []).join("\n") },
|
|
190
|
+
});
|
|
191
|
+
const line = (result.stdout ?? "").trim().split("\n").filter(Boolean).at(-1);
|
|
192
|
+
/** @type {{ verdict?: string; report?: string } | undefined} */
|
|
193
|
+
let answer;
|
|
194
|
+
try {
|
|
195
|
+
answer = line === undefined ? undefined : JSON.parse(line);
|
|
196
|
+
} catch {
|
|
197
|
+
answer = undefined;
|
|
198
|
+
}
|
|
199
|
+
if (answer?.verdict === "pass") {
|
|
200
|
+
emitVerdict("pass");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (answer?.verdict === "fail-retryable" || answer?.verdict === "fail-blocking") {
|
|
204
|
+
const lead = `Paths matching ${when.join(", ")} changed since ${base}. `;
|
|
205
|
+
emitVerdict(answer.verdict, `${lead}${answer.report ?? ""}`.trim());
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
emitVerdict(
|
|
209
|
+
"fail-blocking",
|
|
210
|
+
`base-diff could not read the answer of "${cmd}": ${(result.stderr ?? "").trim().slice(-2_000) || "no output"}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Every path that differs from the base, committed or not, sorted. Nothing when
|
|
216
|
+
* git could not say: the verdict is already out.
|
|
217
|
+
* @returns {string[] | undefined}
|
|
218
|
+
*/
|
|
219
|
+
function changedPaths() {
|
|
220
|
+
// --no-renames: a move is a removal and an addition, so the old path counts too.
|
|
221
|
+
const committed = git(["diff", "--name-only", "--no-renames", "-z", `${base}...HEAD`]);
|
|
222
|
+
if (committed.status !== 0) {
|
|
223
|
+
emitVerdict(
|
|
224
|
+
"fail-blocking",
|
|
225
|
+
`base-diff cannot diff HEAD against "${base}": ${committed.stderr.trim()}`,
|
|
226
|
+
);
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const status = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
230
|
+
if (status.status !== 0) {
|
|
231
|
+
emitVerdict("fail-blocking", `base-diff cannot read the workspace: ${status.stderr.trim()}`);
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const paths = new Set(committed.stdout.split("\0").filter(Boolean));
|
|
235
|
+
const fields = status.stdout.split("\0");
|
|
236
|
+
for (let i = 0; i < fields.length; i++) {
|
|
237
|
+
const entry = fields[i] ?? "";
|
|
238
|
+
if (entry.length < 4) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
paths.add(entry.slice(3));
|
|
242
|
+
// A rename or a copy carries the old path in the next field.
|
|
243
|
+
if (entry[0] === "R" || entry[0] === "C") {
|
|
244
|
+
const old = fields[++i];
|
|
245
|
+
if (old !== undefined && old.length > 0) {
|
|
246
|
+
paths.add(old);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...paths].map((path) => path.replaceAll("\\", "/")).sort();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} a
|
|
255
|
+
* @param {string} b
|
|
256
|
+
*/
|
|
257
|
+
function sameDirectory(a, b) {
|
|
258
|
+
try {
|
|
259
|
+
return realpathSync(a) === realpathSync(b);
|
|
260
|
+
} catch {
|
|
261
|
+
return a === b;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {string} path
|
|
267
|
+
* @param {readonly string[]} patterns
|
|
268
|
+
*/
|
|
269
|
+
function matchesAny(path, patterns) {
|
|
270
|
+
return patterns.some((pattern) => pathMatchesGlob(path, pattern));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {string[]} args */
|
|
274
|
+
function git(args) {
|
|
275
|
+
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
276
|
+
}
|
package/gates/ci-green.mjs
CHANGED
|
@@ -20,6 +20,14 @@ import { emitVerdict, GATE_FLAGS, ownArgv, take } from "@bluewombat/slot-kit";
|
|
|
20
20
|
// to it failed only at the first assembly of a real run
|
|
21
21
|
// --require-checks wait for a check to appear; without it, a commit with no
|
|
22
22
|
// check at all passes
|
|
23
|
+
// --require-check NAME repeatable. The check run (or status context) called
|
|
24
|
+
// exactly NAME must exist and have succeeded. A `skipped` or
|
|
25
|
+
// `neutral` one is not a pass, which is what the general
|
|
26
|
+
// rule lets through (a job skipped for want of its secrets
|
|
27
|
+
// would let the line go green): it is refused. One not
|
|
28
|
+
// there yet is waited for like --require-checks, and at the
|
|
29
|
+
// ceiling the CI did not answer. A matrix is one check per
|
|
30
|
+
// cell, named as GitHub names it ("e2e (chrome)"): list each one.
|
|
23
31
|
//
|
|
24
32
|
// It also receives `--timeout-ms` from Implementer: the ceiling it will be
|
|
25
33
|
// killed at. A Gate that waits on a CI it does not run must not wait to be cut
|
|
@@ -38,6 +46,7 @@ if (!options.ok) {
|
|
|
38
46
|
process.exit(0);
|
|
39
47
|
}
|
|
40
48
|
const apiBase = options.apiBase;
|
|
49
|
+
const requireCheck = options.requireCheck;
|
|
41
50
|
|
|
42
51
|
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
43
52
|
emitVerdict("fail-blocking", "ci-green needs a git workspace.");
|
|
@@ -93,6 +102,25 @@ for (;;) {
|
|
|
93
102
|
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
94
103
|
continue;
|
|
95
104
|
}
|
|
105
|
+
const required = judgeRequired(checks.named);
|
|
106
|
+
if (required.refused.length > 0) {
|
|
107
|
+
// A check that settled without running says nothing of the work, and nothing
|
|
108
|
+
// the work could change (a job skipped for want of its secrets): a person's,
|
|
109
|
+
// not an agent's to repair.
|
|
110
|
+
emitVerdict(
|
|
111
|
+
"fail-blocking",
|
|
112
|
+
`${required.refused.join("\n")}\nThis is not a refusal of the work: the CI did not run a check the Project requires, and no change to the work makes it run.`,
|
|
113
|
+
);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
if (required.missing.length > 0) {
|
|
117
|
+
// Same window as above, for a check the Project named.
|
|
118
|
+
if (Date.now() >= stopAt) {
|
|
119
|
+
stopWaiting(pull, `required check ${quoted(required.missing)} has not appeared`, true);
|
|
120
|
+
}
|
|
121
|
+
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
96
124
|
if (checks.running === 0) {
|
|
97
125
|
emitVerdict("pass");
|
|
98
126
|
process.exit(0);
|
|
@@ -106,6 +134,40 @@ for (;;) {
|
|
|
106
134
|
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
107
135
|
}
|
|
108
136
|
|
|
137
|
+
/**
|
|
138
|
+
* What `--require-check` asks of the checks read: each name must have a
|
|
139
|
+
* success. A name not there yet is `missing` (the CI may still create it); one
|
|
140
|
+
* still running is waited for with the rest; one that settled without success
|
|
141
|
+
* (`skipped`, `neutral`) is `refused`: nothing will turn it green, and no change
|
|
142
|
+
* to the work can, so it goes to a person.
|
|
143
|
+
*
|
|
144
|
+
* @param {Map<string, string[]>} named every check by name, with its outcome:
|
|
145
|
+
* `success`, `pending`, or the conclusion it settled on
|
|
146
|
+
* @returns {{ missing: string[]; refused: string[] }}
|
|
147
|
+
*/
|
|
148
|
+
function judgeRequired(named) {
|
|
149
|
+
/** @type {string[]} */
|
|
150
|
+
const missing = [];
|
|
151
|
+
/** @type {string[]} */
|
|
152
|
+
const refused = [];
|
|
153
|
+
for (const name of requireCheck) {
|
|
154
|
+
const outcomes = named.get(name);
|
|
155
|
+
if (outcomes === undefined) {
|
|
156
|
+
missing.push(name);
|
|
157
|
+
} else if (!outcomes.includes("success") && !outcomes.includes("pending")) {
|
|
158
|
+
refused.push(
|
|
159
|
+
`${name}: ${outcomes[0]}. It is required to succeed, and a check that did not run is not a pass.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { missing, refused };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @param {readonly string[]} names */
|
|
167
|
+
function quoted(names) {
|
|
168
|
+
return names.map((name) => `"${name}"`).join(", ");
|
|
169
|
+
}
|
|
170
|
+
|
|
109
171
|
/**
|
|
110
172
|
* The moment to stop waiting: a margin before Implementer's ceiling (5 %, between
|
|
111
173
|
* 2 and 30 seconds, and at most half of it), so the verdict is out before the
|
|
@@ -153,7 +215,7 @@ function stopWaiting(pr, what, never = false) {
|
|
|
153
215
|
|
|
154
216
|
/**
|
|
155
217
|
* @param {readonly string[]} tokens
|
|
156
|
-
* @returns {{ ok: true; remote: string; apiBase: string; pollMs: number; tokenEnv: string; requireChecks: boolean } | { ok: false; reason: string }}
|
|
218
|
+
* @returns {{ ok: true; remote: string; apiBase: string; pollMs: number; tokenEnv: string; requireChecks: boolean; requireCheck: string[] } | { ok: false; reason: string }}
|
|
157
219
|
*/
|
|
158
220
|
function parseOwnArgv(tokens) {
|
|
159
221
|
let remote = "origin";
|
|
@@ -161,6 +223,8 @@ function parseOwnArgv(tokens) {
|
|
|
161
223
|
let pollMs = 15_000;
|
|
162
224
|
let tokenEnv;
|
|
163
225
|
let requireChecks = false;
|
|
226
|
+
/** @type {string[]} */
|
|
227
|
+
const requireCheck = [];
|
|
164
228
|
for (let i = 0; i < tokens.length; i++) {
|
|
165
229
|
const token = tokens[i];
|
|
166
230
|
if (token === "--require-checks") {
|
|
@@ -176,6 +240,8 @@ function parseOwnArgv(tokens) {
|
|
|
176
240
|
remote = value;
|
|
177
241
|
} else if (token === "--api-base") {
|
|
178
242
|
apiBase = value.replace(/\/+$/, "");
|
|
243
|
+
} else if (token === "--require-check") {
|
|
244
|
+
requireCheck.push(value);
|
|
179
245
|
} else if (token === "--token-env") {
|
|
180
246
|
tokenEnv = value;
|
|
181
247
|
} else if (token === "--poll-ms") {
|
|
@@ -195,7 +261,7 @@ function parseOwnArgv(tokens) {
|
|
|
195
261
|
"ci-green needs --token-env NAME: the environment variable holding a token that can read the checks (the same one @bluewombat/manager-github's tokenEnv names, never GITHUB_TOKEN).",
|
|
196
262
|
};
|
|
197
263
|
}
|
|
198
|
-
return { ok: true, remote, apiBase, pollMs, tokenEnv, requireChecks };
|
|
264
|
+
return { ok: true, remote, apiBase, pollMs, tokenEnv, requireChecks, requireCheck };
|
|
199
265
|
}
|
|
200
266
|
|
|
201
267
|
/**
|
|
@@ -269,12 +335,25 @@ async function readChecks(sha) {
|
|
|
269
335
|
// on the same commit: same name, same log. Said once.
|
|
270
336
|
const seen = new Set();
|
|
271
337
|
let running = 0;
|
|
338
|
+
/** @type {Map<string, string[]>} */
|
|
339
|
+
const named = new Map();
|
|
340
|
+
/**
|
|
341
|
+
* @param {unknown} name
|
|
342
|
+
* @param {string} outcome
|
|
343
|
+
*/
|
|
344
|
+
const note = (name, outcome) => {
|
|
345
|
+
if (typeof name === "string") {
|
|
346
|
+
named.set(name, [...(named.get(name) ?? []), outcome]);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
272
349
|
for (const entry of checkRuns) {
|
|
273
350
|
const run = record(entry) ?? {};
|
|
274
351
|
if (run.status !== "completed") {
|
|
275
352
|
running += 1;
|
|
353
|
+
note(run.name, "pending");
|
|
276
354
|
continue;
|
|
277
355
|
}
|
|
356
|
+
note(run.name, String(run.conclusion));
|
|
278
357
|
if (
|
|
279
358
|
run.conclusion === "success" ||
|
|
280
359
|
run.conclusion === "neutral" ||
|
|
@@ -293,6 +372,7 @@ async function readChecks(sha) {
|
|
|
293
372
|
// and where it said more is all there is.
|
|
294
373
|
for (const entry of statuses) {
|
|
295
374
|
const status = record(entry) ?? {};
|
|
375
|
+
note(status.context, status.state === "pending" ? "pending" : String(status.state));
|
|
296
376
|
if (status.state === "pending") {
|
|
297
377
|
running += 1;
|
|
298
378
|
continue;
|
|
@@ -310,7 +390,7 @@ async function readChecks(sha) {
|
|
|
310
390
|
);
|
|
311
391
|
failed.push([heading, ...more].join("\n"));
|
|
312
392
|
}
|
|
313
|
-
return { total: checkRuns.length + statuses.length, running, failed };
|
|
393
|
+
return { total: checkRuns.length + statuses.length, running, failed, named };
|
|
314
394
|
}
|
|
315
395
|
|
|
316
396
|
/**
|