@bluewombat/slots 0.9.0 → 0.11.0

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.
@@ -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
+ }
@@ -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.");
@@ -88,7 +97,26 @@ for (;;) {
88
97
  // yet, and an empty list then says nothing. Ask again, until the wait
89
98
  // runs out: a commit no check ever picks up is not a refusal of the work.
90
99
  if (Date.now() >= stopAt) {
91
- stopWaiting(pull, "no check has appeared");
100
+ stopWaiting(pull, "no check has appeared", true);
101
+ }
102
+ await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
103
+ continue;
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);
92
120
  }
93
121
  await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
94
122
  continue;
@@ -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
@@ -128,23 +190,32 @@ function stopTime(from, raw) {
128
190
  * could change: blocking, so a person reads it instead of an agent being sent
129
191
  * to repair what was never judged.
130
192
  *
131
- * @param {{ number: number; sha: string }} pr
193
+ * When no check ever appeared, the cause is on the repository's side, and the
194
+ * message says the three it can be: no CI at all, a CI that does not run on pull
195
+ * requests into this branch, or a CI that is late.
196
+ *
197
+ * @param {{ number: number; sha: string; base?: string }} pr
132
198
  * @param {string} what
199
+ * @param {boolean} [never]
133
200
  * @returns {never}
134
201
  */
135
- function stopWaiting(pr, what) {
202
+ function stopWaiting(pr, what, never = false) {
136
203
  const seconds = Math.round((Date.now() - startedAt) / 1000);
137
204
  const lasted = seconds >= 120 ? `${Math.round(seconds / 60)} min` : `${seconds} s`;
205
+ const into = pr.base === undefined ? "this branch" : `"${pr.base}"`;
206
+ const hint = never
207
+ ? ` Either this repository has no CI (then take ci-green out of the Gate list), or its workflows do not run on pull requests into ${into}, or the CI was removed or is late.`
208
+ : "";
138
209
  emitVerdict(
139
210
  "fail-blocking",
140
- `ci-green stopped waiting after ${lasted}: ${what} on ${pr.sha.slice(0, 7)} (pull request #${pr.number}). The CI did not answer in time; this is not a refusal of the work, and no change to it can make the CI answer.`,
211
+ `ci-green stopped waiting after ${lasted}: ${what} on ${pr.sha.slice(0, 7)} (pull request #${pr.number}). The CI did not answer in time; this is not a refusal of the work, and no change to it can make the CI answer.${hint}`,
141
212
  );
142
213
  process.exit(0);
143
214
  }
144
215
 
145
216
  /**
146
217
  * @param {readonly string[]} tokens
147
- * @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 }}
148
219
  */
149
220
  function parseOwnArgv(tokens) {
150
221
  let remote = "origin";
@@ -152,6 +223,8 @@ function parseOwnArgv(tokens) {
152
223
  let pollMs = 15_000;
153
224
  let tokenEnv;
154
225
  let requireChecks = false;
226
+ /** @type {string[]} */
227
+ const requireCheck = [];
155
228
  for (let i = 0; i < tokens.length; i++) {
156
229
  const token = tokens[i];
157
230
  if (token === "--require-checks") {
@@ -167,6 +240,8 @@ function parseOwnArgv(tokens) {
167
240
  remote = value;
168
241
  } else if (token === "--api-base") {
169
242
  apiBase = value.replace(/\/+$/, "");
243
+ } else if (token === "--require-check") {
244
+ requireCheck.push(value);
170
245
  } else if (token === "--token-env") {
171
246
  tokenEnv = value;
172
247
  } else if (token === "--poll-ms") {
@@ -186,7 +261,7 @@ function parseOwnArgv(tokens) {
186
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).",
187
262
  };
188
263
  }
189
- return { ok: true, remote, apiBase, pollMs, tokenEnv, requireChecks };
264
+ return { ok: true, remote, apiBase, pollMs, tokenEnv, requireChecks, requireCheck };
190
265
  }
191
266
 
192
267
  /**
@@ -221,7 +296,7 @@ function repoFrom(url) {
221
296
 
222
297
  /**
223
298
  * @param {string} head
224
- * @returns {Promise<{ number: number; sha: string } | undefined>}
299
+ * @returns {Promise<{ number: number; sha: string; base?: string } | undefined>}
225
300
  */
226
301
  async function openPullFor(head) {
227
302
  const listed = await api(
@@ -232,8 +307,10 @@ async function openPullFor(head) {
232
307
  if (first === undefined || headOf === undefined) {
233
308
  return undefined;
234
309
  }
310
+ const baseOf = record(first.base);
311
+ const base = typeof baseOf?.ref === "string" ? baseOf.ref : undefined;
235
312
  return typeof first.number === "number" && typeof headOf.sha === "string"
236
- ? { number: first.number, sha: headOf.sha }
313
+ ? { number: first.number, sha: headOf.sha, ...(base === undefined ? {} : { base }) }
237
314
  : undefined;
238
315
  }
239
316
 
@@ -258,12 +335,25 @@ async function readChecks(sha) {
258
335
  // on the same commit: same name, same log. Said once.
259
336
  const seen = new Set();
260
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
+ };
261
349
  for (const entry of checkRuns) {
262
350
  const run = record(entry) ?? {};
263
351
  if (run.status !== "completed") {
264
352
  running += 1;
353
+ note(run.name, "pending");
265
354
  continue;
266
355
  }
356
+ note(run.name, String(run.conclusion));
267
357
  if (
268
358
  run.conclusion === "success" ||
269
359
  run.conclusion === "neutral" ||
@@ -282,6 +372,7 @@ async function readChecks(sha) {
282
372
  // and where it said more is all there is.
283
373
  for (const entry of statuses) {
284
374
  const status = record(entry) ?? {};
375
+ note(status.context, status.state === "pending" ? "pending" : String(status.state));
285
376
  if (status.state === "pending") {
286
377
  running += 1;
287
378
  continue;
@@ -299,7 +390,7 @@ async function readChecks(sha) {
299
390
  );
300
391
  failed.push([heading, ...more].join("\n"));
301
392
  }
302
- return { total: checkRuns.length + statuses.length, running, failed };
393
+ return { total: checkRuns.length + statuses.length, running, failed, named };
303
394
  }
304
395
 
305
396
  /**