@arjunkhera/atlas 0.3.43 → 0.3.45
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/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +23 -2
- package/door/lib/changed-tests.mjs +4 -2
- package/door/lib/fails-before.mjs +382 -0
- package/door/lib/gate.mjs +193 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +2 -1
package/door/cli.mjs
CHANGED
|
@@ -34,7 +34,9 @@ import { runKept } from '../tests/kept.mjs';
|
|
|
34
34
|
import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
|
|
35
35
|
import { issuesCommand as testsIssues } from './lib/issues.mjs';
|
|
36
36
|
import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
37
|
+
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
37
38
|
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
39
|
+
import { gateCommand } from './lib/gate.mjs';
|
|
38
40
|
import { judgeCommand } from './lib/judge.mjs';
|
|
39
41
|
|
|
40
42
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -86,6 +88,13 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
86
88
|
--head <ref> the head of the pull request; default HEAD. Only committed files count
|
|
87
89
|
--out <file> write the JSON here; default the terminal
|
|
88
90
|
--text print a short table instead of the JSON
|
|
91
|
+
atlas tests before --root <repo> --base <ref> each new or changed test case must fail on the old code and pass on the new code; exit 1 when one does not
|
|
92
|
+
--head <ref> the head of the pull request; default HEAD
|
|
93
|
+
--list <file> a changed-test list from atlas tests changed; default the command makes it
|
|
94
|
+
--side <old|new> run one side only, and write its run file (CI runs each side in its own job)
|
|
95
|
+
--old <file> --new <file> read the two run files and give the answer; runs nothing
|
|
96
|
+
--out <file> write the JSON here; default the terminal
|
|
97
|
+
--text print a short table instead of the JSON
|
|
89
98
|
atlas tests named --root <area> list the assertions that a pull request names
|
|
90
99
|
--base <ref> the base: an assertion is named when its words changed or it is new
|
|
91
100
|
--covers also name the assertions of a scenario whose covers path the change touches
|
|
@@ -116,6 +125,11 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
116
125
|
--event <file> the GitHub pull request event; adds the item check
|
|
117
126
|
--json print JSON
|
|
118
127
|
|
|
128
|
+
atlas gate apply --repo <owner/name> set the gate: two rulesets from atlas.yaml required_checks, and auto-merge
|
|
129
|
+
--root <repo> the checkout to read atlas.yaml from (default: this folder)
|
|
130
|
+
--dry-run print each call; change nothing
|
|
131
|
+
--off | --on break glass: turn both rulesets off, or back on
|
|
132
|
+
|
|
119
133
|
atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
|
|
120
134
|
atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
|
|
121
135
|
atlas judge run <tasks.md> <id> --out <dir> one fresh agent does one sealed task, in a clean clone at the sealed commit
|
|
@@ -151,8 +165,9 @@ export const FLAGS = Object.freeze({
|
|
|
151
165
|
ste: { share: 'switch', terms: 'value' },
|
|
152
166
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
153
167
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
154
|
-
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
|
|
168
|
+
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
|
|
155
169
|
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
170
|
+
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch' },
|
|
156
171
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
157
172
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
|
|
158
173
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -349,19 +364,24 @@ async function testsCommand(chosen) {
|
|
|
349
364
|
if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
350
365
|
if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
|
|
351
366
|
if (what === 'changed') return testsChanged({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
367
|
+
if (what === 'before') return testsBefore({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, side: chosen.side, old: chosen.old, new: chosen.new, text: Boolean(chosen.text) }, root, line);
|
|
352
368
|
if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
353
369
|
if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
354
370
|
if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
|
|
355
371
|
if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
356
372
|
if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
|
|
357
373
|
if (what === 'issues') return testsIssues({ findings: chosen.findings, repo: chosen.repo, max: chosen.max, bot: chosen.bot, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
358
|
-
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, sweep, covers, kept, issues or rerun.`);
|
|
374
|
+
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, sweep, covers, kept, issues or rerun.`);
|
|
359
375
|
}
|
|
360
376
|
|
|
361
377
|
function upkeepVerb(chosen) {
|
|
362
378
|
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json) }, rootOf(chosen), line);
|
|
363
379
|
}
|
|
364
380
|
|
|
381
|
+
function gateVerb(chosen) {
|
|
382
|
+
return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on) }, line);
|
|
383
|
+
}
|
|
384
|
+
|
|
365
385
|
function judgeVerb(chosen) {
|
|
366
386
|
return judgeCommand({ what: chosen._[1], file: chosen._[2], task: chosen._[3], tasks: chosen.tasks, verdict: chosen.verdict, seal: chosen.seal, runs: chosen.runs, out: chosen.out, repo: chosen.repo, budget: chosen.budget, model: chosen.model }, line, PACKAGE_ROOT);
|
|
367
387
|
}
|
|
@@ -399,6 +419,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
399
419
|
case 'view': return viewCommand(chosen);
|
|
400
420
|
case 'tests': return testsCommand(chosen);
|
|
401
421
|
case 'upkeep': return upkeepVerb(chosen);
|
|
422
|
+
case 'gate': return gateVerb(chosen);
|
|
402
423
|
case 'judge': return judgeVerb(chosen);
|
|
403
424
|
default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
|
|
404
425
|
}
|
|
@@ -46,10 +46,12 @@ const keyOf = (id) => String(id).replace(/#[0-9a-f]+$/, '');
|
|
|
46
46
|
const RUNNER_CONFIG = /^(vitest\.config\.[^/]+|vitest\.[^/]+\.config\.[^/]+|jest\.config\.[^/]+|playwright\.config\.[^/]+|\.mocharc(\.[^/]+)?|pytest\.ini|tox\.ini)$/;
|
|
47
47
|
const UNIT_TEST = /^([^/]+\.(test|spec)\.[^/]+|test_[^/]+\.py|[^/]+_test\.py|[^/]+_test\.go)$/;
|
|
48
48
|
// A helper folder at any depth. These names mean tests in every tool that uses them.
|
|
49
|
-
|
|
49
|
+
// `test` and `fixtures` are among them (shape/test/, pkg/fixtures/): fails-before needs a deep helper
|
|
50
|
+
// or a deep data file to be the same in both runs (design docs/design-docs/fails-before/).
|
|
51
|
+
const HELPER_ANY = new Set(['test', '__tests__', '__snapshots__', 'testdata', 'fixtures']);
|
|
50
52
|
// A helper folder only at the top of the repo or of an area. Deeper, such a name can be product code
|
|
51
53
|
// (the Atlas test kit ships in packages/atlas/tests/).
|
|
52
|
-
const HELPER_TOP = new Set(['
|
|
54
|
+
const HELPER_TOP = new Set(['tests', 'spec', 'golden']);
|
|
53
55
|
// Test settings that live inside a package.json, beside the test scripts.
|
|
54
56
|
const PACKAGE_TEST_KEYS = Object.freeze(['jest', 'vitest', 'mocha', 'ava', 'c8', 'nyc']);
|
|
55
57
|
const SCENARIO_TEST = /(^|\/)tests?\/scenarios\/[^/]+(\.test\.mjs|_test\.py)$/;
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// `atlas tests before`: each new or changed test case of a pull request must fail on the old code
|
|
2
|
+
// and pass on the new code (roadmap R4, tracker item 1bbafe3d; design docs/design-docs/fails-before/).
|
|
3
|
+
//
|
|
4
|
+
// It reads the changed-test list (changed-tests.mjs) and takes each unit test file that the pull
|
|
5
|
+
// request adds, changes or renames. Run 1 is on the old code: a tree of the merge base, with the
|
|
6
|
+
// head's test files and test helpers copied in. Run 2 is a tree of the head. Both runs use one
|
|
7
|
+
// path and one environment, so only the code under test differs. Each report is read with the JUnit
|
|
8
|
+
// reader of the kit.
|
|
9
|
+
//
|
|
10
|
+
// before after result
|
|
11
|
+
// assertion pass proven
|
|
12
|
+
// pass pass passes before (red: the test cannot catch this change)
|
|
13
|
+
// any other fails after (red)
|
|
14
|
+
// missing pass load error (not proven)
|
|
15
|
+
// error pass error before (not proven)
|
|
16
|
+
// skipped on either side skipped (red)
|
|
17
|
+
// two cases with one name duplicate (red)
|
|
18
|
+
//
|
|
19
|
+
// Green does not mean proven: the gate counts only `proven` cases (proven_count).
|
|
20
|
+
// The command runs head code. It holds no secret: each run gets a fixed environment and nothing else.
|
|
21
|
+
// In CI each side runs in its own job on a fresh runner (--side old, --side new), and a third job
|
|
22
|
+
// reads both run files (--old, --new) with the base copy of Atlas. Each tree comes from git archive.
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { dirname, join, posix, resolve } from 'node:path';
|
|
27
|
+
import { readJunit } from '../../tests/junit.mjs';
|
|
28
|
+
import { listChanged } from './changed-tests.mjs';
|
|
29
|
+
|
|
30
|
+
export const VERSION = 1;
|
|
31
|
+
|
|
32
|
+
function git(cwd, args, { allowFail = false, buffer = false } = {}) {
|
|
33
|
+
const r = spawnSync('git', args, { cwd, encoding: buffer ? 'buffer' : 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
34
|
+
if (r.error) throw new Error(`git cannot run: ${r.error.message}`);
|
|
35
|
+
if (r.status !== 0) {
|
|
36
|
+
if (allowFail) return null;
|
|
37
|
+
throw new Error(`git ${args.join(' ')} failed: ${String(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
|
|
38
|
+
}
|
|
39
|
+
return r.stdout;
|
|
40
|
+
}
|
|
41
|
+
const usage = (message) => Object.assign(new Error(message), { usage: true });
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- the cases of one test file
|
|
44
|
+
|
|
45
|
+
// The runner of a test file, by its name. null: no runner yet.
|
|
46
|
+
export function runnerOf(path) {
|
|
47
|
+
const name = posix.basename(path);
|
|
48
|
+
if (/\.test\.(mjs|js|cjs)$/.test(name)) return 'node';
|
|
49
|
+
if (/^test_[^/]+\.py$|_test\.py$/.test(name)) return 'pytest';
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Skips a string, a template string or a comment at `i`. Returns the index after it, or `i`.
|
|
54
|
+
function skip(src, i) {
|
|
55
|
+
const c = src[i];
|
|
56
|
+
if (c === '/' && src[i + 1] === '/') { const end = src.indexOf('\n', i); return end < 0 ? src.length : end; }
|
|
57
|
+
if (c === '/' && src[i + 1] === '*') { const end = src.indexOf('*/', i + 2); if (end < 0) throw new Error('open comment'); return end + 2; }
|
|
58
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
59
|
+
for (let j = i + 1; j < src.length; j += 1) {
|
|
60
|
+
if (src[j] === '\\') { j += 1; continue; }
|
|
61
|
+
if (src[j] === c) return j + 1;
|
|
62
|
+
if (c === '`' && src[j] === '$' && src[j + 1] === '{') j = close(src, j + 1) - 1;
|
|
63
|
+
else if (c !== '`' && src[j] === '\n') break;
|
|
64
|
+
}
|
|
65
|
+
throw new Error('open string');
|
|
66
|
+
}
|
|
67
|
+
return i;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The index after the bracket that closes the one at `open`. A regular expression is read as code,
|
|
71
|
+
// so a bracket inside one can end a case early; then the text still differs where the case differs.
|
|
72
|
+
function close(src, open) {
|
|
73
|
+
const pair = { '(': ')', '{': '}', '[': ']' };
|
|
74
|
+
const stack = [];
|
|
75
|
+
for (let i = open; i < src.length;) {
|
|
76
|
+
const after = skip(src, i);
|
|
77
|
+
if (after !== i) { i = after; continue; }
|
|
78
|
+
const c = src[i];
|
|
79
|
+
if (pair[c]) stack.push(pair[c]);
|
|
80
|
+
else if (c === ')' || c === '}' || c === ']') {
|
|
81
|
+
if (stack.pop() !== c) throw new Error('bracket mismatch');
|
|
82
|
+
if (!stack.length) return i + 1;
|
|
83
|
+
}
|
|
84
|
+
i += 1;
|
|
85
|
+
}
|
|
86
|
+
throw new Error('open bracket');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The test cases of one file: { cases: Map of name to its text, rest, duplicates }. `rest` is the
|
|
90
|
+
// text outside every case, so an edit there (a beforeEach, a shared value) can mark every case.
|
|
91
|
+
// null when the file cannot be read to the end. JavaScript: each test(…) or it(…) call whose first
|
|
92
|
+
// argument is a fixed string. Python: each def test_… function, at any indent, to the end of its block.
|
|
93
|
+
export function casesOf(text, path) {
|
|
94
|
+
const out = { cases: new Map(), rest: '', duplicates: new Set() };
|
|
95
|
+
if (text === null) return out;
|
|
96
|
+
const src = String(text).replace(/\r\n?/g, '\n');
|
|
97
|
+
const add = (name, body) => {
|
|
98
|
+
if (out.cases.has(name)) out.duplicates.add(name);
|
|
99
|
+
out.cases.set(name, out.cases.has(name) ? `${out.cases.get(name)}\n${body}` : body);
|
|
100
|
+
};
|
|
101
|
+
const rest = [];
|
|
102
|
+
try {
|
|
103
|
+
if (path.endsWith('.py')) {
|
|
104
|
+
const lines = src.split('\n');
|
|
105
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
106
|
+
const m = /^(\s*)(?:async\s+)?def\s+(test\w*)\s*\(/.exec(lines[i]);
|
|
107
|
+
if (!m) { rest.push(lines[i]); continue; }
|
|
108
|
+
let j = i + 1;
|
|
109
|
+
while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > m[1].length)) j += 1;
|
|
110
|
+
add(m[2], lines.slice(i, j).join('\n').trimEnd());
|
|
111
|
+
i = j - 1;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const call = /(?<![\w.$])(test|it)(\.(?:only|skip|todo))?\(\s*(["'`])([^"'`\n]*)\3/g;
|
|
115
|
+
let m;
|
|
116
|
+
let from = 0;
|
|
117
|
+
while ((m = call.exec(src))) {
|
|
118
|
+
if (m.index < from) continue;
|
|
119
|
+
const end = close(src, src.indexOf('(', m.index));
|
|
120
|
+
add(m[4], src.slice(m.index, end));
|
|
121
|
+
rest.push(src.slice(from, m.index));
|
|
122
|
+
from = end;
|
|
123
|
+
call.lastIndex = end;
|
|
124
|
+
}
|
|
125
|
+
rest.push(src.slice(from));
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// A piece between two cases that holds only white space and separators is no code.
|
|
131
|
+
out.rest = rest.map((piece) => piece.replace(/^[\s;,]+|[\s;,]+$/g, '')).filter(Boolean).join('\n').replace(/\s+/g, ' ').trim();
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------- one report
|
|
136
|
+
|
|
137
|
+
// The text of each <testcase> element, in order, so a failure can be sorted.
|
|
138
|
+
function caseTexts(xml) {
|
|
139
|
+
return [...String(xml).matchAll(/<testcase\b[^>]*?(?:\/>|>([\s\S]*?)<\/testcase>)/g)].map((m) => m[1] ?? '');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The results of one report: Map of case name to pass, assertion, error or skipped.
|
|
143
|
+
// For pytest, a name with parameters ("test_x[1]") counts under its function name.
|
|
144
|
+
export function readReport(xml) {
|
|
145
|
+
const cases = readJunit(xml);
|
|
146
|
+
const texts = caseTexts(xml);
|
|
147
|
+
const rank = { pass: 0, skipped: 1, error: 2, assertion: 3 };
|
|
148
|
+
const out = new Map();
|
|
149
|
+
cases.forEach((c, i) => {
|
|
150
|
+
let result = c.result;
|
|
151
|
+
if (result === 'fail') result = /AssertionError|ERR_ASSERTION/.test(`${c.message ?? ''}\n${texts[i] ?? ''}`) ? 'assertion' : 'error';
|
|
152
|
+
const name = c.name.replace(/\[.*\]$/, '');
|
|
153
|
+
// When one name holds several results, the worst one counts: a fail beats a pass.
|
|
154
|
+
if (!out.has(name) || rank[result] > rank[out.get(name)]) out.set(name, result);
|
|
155
|
+
});
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resultOf(before, after) {
|
|
160
|
+
if (before === 'skipped' || after === 'skipped') return 'skipped';
|
|
161
|
+
if (after !== 'pass') return 'fails after';
|
|
162
|
+
if (before === 'assertion') return 'proven';
|
|
163
|
+
if (before === 'pass') return 'passes before';
|
|
164
|
+
if (before === 'missing') return 'load error';
|
|
165
|
+
return 'error before';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const RED_RESULTS = Object.freeze(['passes before', 'fails after', 'skipped', 'duplicate']);
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------- the plan
|
|
171
|
+
|
|
172
|
+
// A JavaScript case that only moved its line breaks or spaces is not changed. In Python the indent
|
|
173
|
+
// is code, so its text counts as it is.
|
|
174
|
+
const squash = (text, path) => (path.endsWith('.py') ? text : text.replace(/\s+/g, ' ').trim());
|
|
175
|
+
const isProduct = (path) => !/^(docs|archive)\//.test(path) && !/^[^/]+\.md$/i.test(path);
|
|
176
|
+
const OVERLAY = new Set(['unit-test', 'test-helper', 'runner-config', 'kit-manifest', 'check-block']);
|
|
177
|
+
|
|
178
|
+
// What to run: the changed-test list, the files and, for each file that runs, its picked cases.
|
|
179
|
+
export function plan({ root, base, head = 'HEAD', list = null }) {
|
|
180
|
+
if (!base && !list) throw usage('give the base: --base <ref>');
|
|
181
|
+
const top = git(resolve(root), ['rev-parse', '--show-toplevel']).trim();
|
|
182
|
+
const changed = list ? JSON.parse(readFileSync(resolve(list), 'utf8')) : listChanged({ root: top, base, head });
|
|
183
|
+
if (changed.changed_tests !== 1) throw new Error(`the changed-test list has version ${changed.changed_tests}; this command reads version 1`);
|
|
184
|
+
const show = (sha, path) => git(top, ['show', `${sha}:${path}`], { allowFail: true });
|
|
185
|
+
const files = [];
|
|
186
|
+
const plans = [];
|
|
187
|
+
for (const r of changed.rows.filter((x) => x.group === 'check-block' && x.status !== 'removed')) {
|
|
188
|
+
if (!files.some((f) => f.path === r.path)) files.push({ path: r.path, runner: null, result: 'not run', why: 'a scenario test needs its environment; the planted-fault procedure covers it' });
|
|
189
|
+
}
|
|
190
|
+
for (const r of changed.rows.filter((x) => x.group === 'unit-test' && x.status !== 'removed')) {
|
|
191
|
+
const runner = runnerOf(r.path);
|
|
192
|
+
if (!runner) { files.push({ path: r.path, runner: null, result: 'not run', why: 'no runner for this file yet' }); continue; }
|
|
193
|
+
const before = r.status === 'added' ? casesOf(null, r.path) : casesOf(show(changed.merge_base, r.from ?? r.path), r.from ?? r.path);
|
|
194
|
+
const after = casesOf(show(changed.head, r.path), r.path);
|
|
195
|
+
// all: every case of the file counts as changed, also those that the reader did not find.
|
|
196
|
+
let all = false;
|
|
197
|
+
const picked = [];
|
|
198
|
+
if (after === null || before === null) all = true;
|
|
199
|
+
else {
|
|
200
|
+
if (r.status !== 'added' && before.rest !== after.rest) all = true;
|
|
201
|
+
for (const [name, text] of after.cases) {
|
|
202
|
+
if (all || !before.cases.has(name) || squash(before.cases.get(name), r.path) !== squash(text, r.path)) picked.push({ name, change: before.cases.has(name) ? 'changed' : 'added', duplicate: after.duplicates.has(name) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!picked.length && !all && r.status !== 'added') continue;
|
|
206
|
+
files.push({ path: r.path, runner, result: 'ran', why: null });
|
|
207
|
+
plans.push({ path: r.path, runner, picked, all: all || r.status === 'added', known: after ? [...after.cases.keys()] : [] });
|
|
208
|
+
}
|
|
209
|
+
files.sort((x, y) => (x.path < y.path ? -1 : 1));
|
|
210
|
+
return { top, changed, files, plans, productChanged: changed.other_paths.some(isProduct) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------- one run
|
|
214
|
+
|
|
215
|
+
// The environment of every run. Nothing of the parent passes through but PATH.
|
|
216
|
+
export function runEnv(work) {
|
|
217
|
+
return { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: join(work, 'home'), TMPDIR: join(work, 'tmp'), TZ: 'UTC', LANG: 'C.UTF-8', CI: 'true' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const commandOf = (runner, file, report) => (runner === 'node'
|
|
221
|
+
? ['node', '--test', '--test-reporter=junit', `--test-reporter-destination=${report}`, file]
|
|
222
|
+
: ['python3', '-B', '-m', 'pytest', '-p', 'no:cacheprovider', '-q', `--junitxml=${report}`, file]);
|
|
223
|
+
|
|
224
|
+
// A fresh tree of `sha` at `tree`, from git archive: no .git, so a test cannot read which commit it is.
|
|
225
|
+
function freshTree(top, tree, sha) {
|
|
226
|
+
rmSync(tree, { recursive: true, force: true });
|
|
227
|
+
mkdirSync(tree, { recursive: true });
|
|
228
|
+
const archive = spawnSync('git', ['archive', '--format=tar', sha], { cwd: top, maxBuffer: 1024 * 1024 * 1024 });
|
|
229
|
+
if (archive.status !== 0) throw new Error(`git archive failed: ${String(archive.stderr).trim()}`);
|
|
230
|
+
const untar = spawnSync('tar', ['-x', '-C', tree], { input: archive.stdout });
|
|
231
|
+
if (untar.status !== 0) throw new Error(`tar failed: ${String(untar.stderr).trim()}`);
|
|
232
|
+
if (existsSync(join(top, 'node_modules')) && !existsSync(join(tree, 'node_modules'))) symlinkSync(join(top, 'node_modules'), join(tree, 'node_modules'), 'dir');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Runs one side: `old` (the merge base with the head's test files) or `new` (the head).
|
|
236
|
+
// Returns the run file: { side, merge_base, head, tree, report, env, files: { path: { command, cases, why } } }.
|
|
237
|
+
export function runSide({ top, changed, plans, side, work = join(tmpdir(), 'atlas-before') }) {
|
|
238
|
+
if (side !== 'old' && side !== 'new') throw usage('--side is old or new');
|
|
239
|
+
const tree = join(work, 'repo');
|
|
240
|
+
const report = join(work, 'report', 'report.xml');
|
|
241
|
+
const env = runEnv(work);
|
|
242
|
+
const out = { fails_before_run: VERSION, side, merge_base: changed.merge_base, head: changed.head, tree, report, env, files: {} };
|
|
243
|
+
const python = plans.some((p) => p.runner === 'pytest');
|
|
244
|
+
try {
|
|
245
|
+
for (const p of plans) {
|
|
246
|
+
// A fresh tree, home and report folder for each file, so no file of one run reaches the next.
|
|
247
|
+
freshTree(top, tree, side === 'old' ? changed.merge_base : changed.head);
|
|
248
|
+
if (side === 'old') {
|
|
249
|
+
for (const r of changed.rows.filter((x) => OVERLAY.has(x.group))) {
|
|
250
|
+
if (r.from) rmSync(join(tree, r.from), { force: true });
|
|
251
|
+
if (r.status === 'removed') { rmSync(join(tree, r.path), { force: true }); continue; }
|
|
252
|
+
const bytes = git(top, ['show', `${changed.head}:${r.path}`], { allowFail: true, buffer: true });
|
|
253
|
+
if (bytes === null) continue;
|
|
254
|
+
mkdirSync(dirname(join(tree, r.path)), { recursive: true });
|
|
255
|
+
writeFileSync(join(tree, r.path), bytes);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const dir of ['home', 'tmp', 'report']) { rmSync(join(work, dir), { recursive: true, force: true }); mkdirSync(join(work, dir), { recursive: true }); }
|
|
259
|
+
const command = commandOf(p.runner, p.path, report);
|
|
260
|
+
if (python && p.runner === 'pytest' && spawnSync('python3', ['-B', '-c', 'import pytest'], { cwd: tree, env }).status !== 0) {
|
|
261
|
+
out.files[p.path] = { command, cases: null, why: 'pytest is not installed', none: true };
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const r = spawnSync(command[0] === 'node' ? process.execPath : command[0], command.slice(1), { cwd: tree, env, encoding: 'utf8', timeout: 10 * 60 * 1000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 * 1024 });
|
|
265
|
+
let cases = null;
|
|
266
|
+
let why = null;
|
|
267
|
+
if (r.error) why = `the runner did not finish: ${r.error.message}`;
|
|
268
|
+
else if (!existsSync(report)) why = `the runner wrote no report (exit ${r.status})`;
|
|
269
|
+
else {
|
|
270
|
+
try { cases = Object.fromEntries(readReport(readFileSync(report, 'utf8'))); } catch (error) { why = `the report does not read: ${error.message}`; }
|
|
271
|
+
}
|
|
272
|
+
out.files[p.path] = { command, cases, why };
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
rmSync(tree, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------- the answer
|
|
281
|
+
|
|
282
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
283
|
+
|
|
284
|
+
// Reads the two run files and gives the answer. It runs nothing.
|
|
285
|
+
export function combine(prepared, oldRun, newRun) {
|
|
286
|
+
const { changed, files, plans, productChanged } = prepared;
|
|
287
|
+
const answer = { fails_before: VERSION, base: changed.base, merge_base: changed.merge_base, head: changed.head, product_changed: productChanged, red: false, proven_count: 0, files: files.map((f) => ({ ...f })), cases: [], env: null, runs: null };
|
|
288
|
+
if (!productChanged || !plans.length) {
|
|
289
|
+
for (const p of plans) for (const c of p.picked) answer.cases.push({ path: p.path, name: c.name, change: c.change, before: null, after: null, result: c.duplicate ? 'duplicate' : 'not proven' });
|
|
290
|
+
return finish(answer);
|
|
291
|
+
}
|
|
292
|
+
if (oldRun?.side !== 'old' || newRun?.side !== 'new') throw new Error('give one run file of the side old and one of the side new');
|
|
293
|
+
for (const run of [oldRun, newRun]) {
|
|
294
|
+
if (run.fails_before_run !== VERSION) throw new Error(`a run file has version ${run.fails_before_run}; this command reads version ${VERSION}`);
|
|
295
|
+
if (run.merge_base !== changed.merge_base || run.head !== changed.head) throw new Error(`the run file of side ${run.side} is of other commits`);
|
|
296
|
+
}
|
|
297
|
+
if (!same(oldRun.env, newRun.env) || oldRun.tree !== newRun.tree || oldRun.report !== newRun.report) throw new Error('the two runs did not use one tree path, one report path and one environment');
|
|
298
|
+
answer.env = newRun.env;
|
|
299
|
+
answer.runs = { tree: newRun.tree, report: newRun.report };
|
|
300
|
+
for (const p of plans) {
|
|
301
|
+
const b = oldRun.files[p.path];
|
|
302
|
+
const a = newRun.files[p.path];
|
|
303
|
+
const file = answer.files.find((f) => f.path === p.path);
|
|
304
|
+
if (!a || !b) throw new Error(`a run file has no result for ${p.path}`);
|
|
305
|
+
if (!same(a.command, b.command)) throw new Error(`the two runs started ${p.path} with other command lines`);
|
|
306
|
+
if (a.none || b.none) { Object.assign(file, { result: 'not run', why: a.why ?? b.why }); continue; }
|
|
307
|
+
if (a.why || b.why) file.why = [b.why && `old code: ${b.why}`, a.why && `new code: ${a.why}`].filter(Boolean).join('; ');
|
|
308
|
+
const headCases = a.cases ?? {};
|
|
309
|
+
// A case of the head report that the reader did not find counts as changed.
|
|
310
|
+
const picked = [...p.picked];
|
|
311
|
+
const unknown = Object.keys(headCases).filter((name) => !p.known.includes(name));
|
|
312
|
+
for (const name of p.all ? Object.keys(headCases) : unknown) if (!picked.some((c) => c.name === name)) picked.push({ name, change: 'changed', duplicate: false });
|
|
313
|
+
if (!picked.length) { answer.cases.push({ path: p.path, name: null, change: 'changed', before: null, after: 'missing', result: 'fails after' }); continue; }
|
|
314
|
+
for (const c of picked) {
|
|
315
|
+
const before = b.cases?.[c.name] ?? 'missing';
|
|
316
|
+
const after = headCases[c.name] ?? 'missing';
|
|
317
|
+
answer.cases.push({ path: p.path, name: c.name, change: c.change, before, after, result: c.duplicate ? 'duplicate' : resultOf(before, after) });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return finish(answer);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Both runs, one after the other, then the answer. For a local run.
|
|
324
|
+
export function failsBefore({ root, base, head = 'HEAD', list = null, work = join(tmpdir(), 'atlas-before') }) {
|
|
325
|
+
const prepared = plan({ root, base, head, list });
|
|
326
|
+
if (!prepared.productChanged || !prepared.plans.length) return combine(prepared, null, null);
|
|
327
|
+
const oldRun = runSide({ ...prepared, side: 'old', work });
|
|
328
|
+
const newRun = runSide({ ...prepared, side: 'new', work });
|
|
329
|
+
return combine(prepared, oldRun, newRun);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function finish(answer) {
|
|
333
|
+
answer.cases.sort((x, y) => (x.path === y.path ? String(x.name).localeCompare(String(y.name)) : x.path < y.path ? -1 : 1));
|
|
334
|
+
answer.red = answer.cases.some((c) => RED_RESULTS.includes(c.result));
|
|
335
|
+
answer.proven_count = answer.cases.filter((c) => c.result === 'proven').length;
|
|
336
|
+
return answer;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------- the command
|
|
340
|
+
|
|
341
|
+
const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
342
|
+
|
|
343
|
+
export function renderText(a) {
|
|
344
|
+
const out = [`fails-before against ${oneLine(a.base)} (merge base ${a.merge_base.slice(0, 7)}): ${a.proven_count} of ${a.cases.length} case(s) proven, in ${a.files.filter((f) => f.result === 'ran').length} file(s); product code changed: ${a.product_changed ? 'yes' : 'no'}`];
|
|
345
|
+
for (const c of a.cases) out.push(` ${c.result.padEnd(14)} ${oneLine(c.path)} ${oneLine(c.name ?? '(the whole file)')}`);
|
|
346
|
+
for (const f of a.files.filter((x) => x.result === 'not run')) out.push(` ${'not run'.padEnd(14)} ${oneLine(f.path)} ${oneLine(f.why)}`);
|
|
347
|
+
for (const f of a.files.filter((x) => x.result === 'ran' && x.why)) out.push(` note ${oneLine(f.path)} ${oneLine(f.why)}`);
|
|
348
|
+
const red = a.cases.filter((c) => RED_RESULTS.includes(c.result));
|
|
349
|
+
if (red.length) out.push(`RED: ${red.length} case(s): ${[...new Set(red.map((c) => c.result))].join(', ')}. A case that passes on the old code cannot catch this change.`);
|
|
350
|
+
else if (!a.product_changed) out.push('GREEN: no product code changed, so the new cases are not proven.');
|
|
351
|
+
else if (a.product_changed && !a.cases.length) out.push('GREEN: product code changed, and no new or changed case runs. Nothing is proven.');
|
|
352
|
+
else out.push(`GREEN: no red case. Green does not mean proven: ${a.proven_count} proven, ${a.cases.length - a.proven_count} not proven.`);
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function beforeCommand({ base, head, list, out, side, old: oldFile, new: newFile, text = false }, root, line) {
|
|
357
|
+
try {
|
|
358
|
+
const write = (value) => {
|
|
359
|
+
const json = `${JSON.stringify(value, null, 2)}\n`;
|
|
360
|
+
if (out) writeFileSync(resolve(out), json);
|
|
361
|
+
else if (!text) process.stdout.write(json);
|
|
362
|
+
};
|
|
363
|
+
if (side) {
|
|
364
|
+
const prepared = plan({ root, base, head: head ?? 'HEAD', list });
|
|
365
|
+
const run = runSide({ ...prepared, side });
|
|
366
|
+
write(run);
|
|
367
|
+
if (text) line(`fails-before: ran the ${side} side of ${Object.keys(run.files).length} file(s).`);
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
let answer;
|
|
371
|
+
if (oldFile || newFile) {
|
|
372
|
+
const read = (file) => (file ? JSON.parse(readFileSync(resolve(file), 'utf8')) : null);
|
|
373
|
+
answer = combine(plan({ root, base, head: head ?? 'HEAD', list }), read(oldFile), read(newFile));
|
|
374
|
+
} else answer = failsBefore({ root, base, head: head ?? 'HEAD', list });
|
|
375
|
+
write(answer);
|
|
376
|
+
if (text) for (const l of renderText(answer)) line(l);
|
|
377
|
+
return answer.red ? 1 : 0;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
line(`before: ${error.message}`);
|
|
380
|
+
return error.usage ? 2 : 1;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `atlas gate apply`: the setup command of the gate (roadmap R9, design
|
|
2
|
+
// docs/design-docs/gate-apply/). The owner runs it with his own GitHub login.
|
|
3
|
+
// It sets two rulesets from the repo's atlas.yaml and allows auto-merge:
|
|
4
|
+
// 1. "atlas gate" on the default branch: a pull request with no approval,
|
|
5
|
+
// each required check from GitHub Actions, no force push, no delete;
|
|
6
|
+
// 2. "atlas release tags" on v* tags: no update, no delete.
|
|
7
|
+
// The bypass list stays empty. --off and --on are the break glass.
|
|
8
|
+
//
|
|
9
|
+
// The plan is built by pure functions, so a test reads it without GitHub. The
|
|
10
|
+
// gh calls sit in one place, behind `gh`, which a test replaces.
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
15
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
16
|
+
|
|
17
|
+
export const BRANCH_RULESET = 'atlas gate';
|
|
18
|
+
export const TAG_RULESET = 'atlas release tags';
|
|
19
|
+
// The app that posts GitHub Actions check runs. A status that another app posts
|
|
20
|
+
// under the same name does not count.
|
|
21
|
+
export const ACTIONS_APP_ID = 15368;
|
|
22
|
+
|
|
23
|
+
export class GateError extends Error {}
|
|
24
|
+
|
|
25
|
+
// What the gate reads from atlas.yaml.
|
|
26
|
+
export function gateFacts(root) {
|
|
27
|
+
const path = join(root, 'atlas.yaml');
|
|
28
|
+
if (!existsSync(path)) throw new GateError(`there is no atlas.yaml in ${root}. Nothing was changed.`);
|
|
29
|
+
const facts = parseYaml(readFileSync(path, 'utf8')) ?? {};
|
|
30
|
+
const checks = facts.required_checks;
|
|
31
|
+
if (!Array.isArray(checks) || checks.length === 0 || checks.some((name) => typeof name !== 'string' || !name.trim())) {
|
|
32
|
+
throw new GateError('atlas.yaml lists no required_checks. Nothing was changed.');
|
|
33
|
+
}
|
|
34
|
+
if (facts.up_to_date !== undefined && typeof facts.up_to_date !== 'boolean') throw new GateError('atlas.yaml up_to_date must be true or false. Nothing was changed.');
|
|
35
|
+
return { checks: [...checks], upToDate: facts.up_to_date === true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The two ruleset bodies, as the GitHub rulesets API takes them.
|
|
39
|
+
export function rulesets({ checks, upToDate }, enforcement = 'active') {
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
name: BRANCH_RULESET,
|
|
43
|
+
target: 'branch',
|
|
44
|
+
enforcement,
|
|
45
|
+
bypass_actors: [],
|
|
46
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
47
|
+
rules: [
|
|
48
|
+
{
|
|
49
|
+
type: 'pull_request',
|
|
50
|
+
parameters: {
|
|
51
|
+
required_approving_review_count: 0,
|
|
52
|
+
dismiss_stale_reviews_on_push: false,
|
|
53
|
+
require_code_owner_review: false,
|
|
54
|
+
require_last_push_approval: false,
|
|
55
|
+
required_review_thread_resolution: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'required_status_checks',
|
|
60
|
+
parameters: {
|
|
61
|
+
strict_required_status_checks_policy: upToDate,
|
|
62
|
+
required_status_checks: checks.map((context) => ({ context, integration_id: ACTIONS_APP_ID })),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{ type: 'non_fast_forward' },
|
|
66
|
+
{ type: 'deletion' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: TAG_RULESET,
|
|
71
|
+
target: 'tag',
|
|
72
|
+
enforcement,
|
|
73
|
+
bypass_actors: [],
|
|
74
|
+
conditions: { ref_name: { include: ['refs/tags/v*'], exclude: [] } },
|
|
75
|
+
rules: [{ type: 'update' }, { type: 'deletion' }],
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The calls of one run. `existing` is the list GitHub gives for the repo's
|
|
81
|
+
// rulesets: [{ id, name }]. A ruleset that exists is updated, never doubled.
|
|
82
|
+
// In a dry run, a ruleset that does not exist yet shows as <id>.
|
|
83
|
+
export function plan({ repo, facts, existing = [], mode = 'apply', dryRun = false }) {
|
|
84
|
+
const ids = new Map(existing.map((ruleset) => [ruleset.name, ruleset.id]));
|
|
85
|
+
if (mode === 'off' || mode === 'on') {
|
|
86
|
+
const enforcement = mode === 'off' ? 'disabled' : 'active';
|
|
87
|
+
return [BRANCH_RULESET, TAG_RULESET].map((name) => {
|
|
88
|
+
if (!ids.has(name) && !dryRun) throw new GateError(`the repo has no ruleset "${name}". Run "atlas gate apply" first.`);
|
|
89
|
+
return { method: 'PUT', path: `repos/${repo}/rulesets/${ids.get(name) ?? '<id>'}`, name, body: { enforcement } };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const calls = rulesets(facts).map((body) => (ids.has(body.name)
|
|
93
|
+
? { method: 'PUT', path: `repos/${repo}/rulesets/${ids.get(body.name)}`, name: body.name, body }
|
|
94
|
+
: { method: 'POST', path: `repos/${repo}/rulesets`, name: body.name, body }));
|
|
95
|
+
calls.push({ method: 'PATCH', path: `repos/${repo}`, name: 'allow auto-merge', body: { allow_auto_merge: true } });
|
|
96
|
+
return calls;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One readable block for each call, for the dry run and the log.
|
|
100
|
+
export function describe(call) {
|
|
101
|
+
const lines = [`${call.method.padEnd(6)} ${call.path} ${call.name}`];
|
|
102
|
+
const body = call.body;
|
|
103
|
+
if (body.rules) {
|
|
104
|
+
const on = body.target === 'branch' ? `branch ${body.conditions.ref_name.include.join(', ')}` : `tag ${body.conditions.ref_name.include.join(', ')}`;
|
|
105
|
+
lines.push(` ${on}, enforcement ${body.enforcement}, bypass ${body.bypass_actors.length ? body.bypass_actors.length : 'none'}`);
|
|
106
|
+
for (const rule of body.rules) {
|
|
107
|
+
if (rule.type === 'pull_request') lines.push(` pull_request: ${rule.parameters.required_approving_review_count} approvals`);
|
|
108
|
+
else if (rule.type === 'required_status_checks') {
|
|
109
|
+
lines.push(` required_status_checks (strict ${rule.parameters.strict_required_status_checks_policy}), from GitHub Actions (${ACTIONS_APP_ID}):`);
|
|
110
|
+
for (const check of rule.parameters.required_status_checks) lines.push(` ${check.context}`);
|
|
111
|
+
} else lines.push(` ${rule.type}`);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
lines.push(` ${Object.entries(body).map(([key, value]) => `${key} ${value}`).join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// What a read-back must hold, compared key by key. Returns the differences.
|
|
120
|
+
export function compare(wanted, got) {
|
|
121
|
+
const problems = [];
|
|
122
|
+
if (got.enforcement !== wanted.enforcement) problems.push(`${wanted.name}: enforcement is ${got.enforcement}, not ${wanted.enforcement}`);
|
|
123
|
+
if ((got.bypass_actors ?? []).length !== 0) problems.push(`${wanted.name}: the bypass list is not empty`);
|
|
124
|
+
if (JSON.stringify(got.conditions?.ref_name?.include ?? []) !== JSON.stringify(wanted.conditions.ref_name.include)) problems.push(`${wanted.name}: it holds for ${JSON.stringify(got.conditions?.ref_name?.include)}`);
|
|
125
|
+
for (const rule of wanted.rules) {
|
|
126
|
+
const found = (got.rules ?? []).find((each) => each.type === rule.type);
|
|
127
|
+
if (!found) { problems.push(`${wanted.name}: the rule ${rule.type} is missing`); continue; }
|
|
128
|
+
if (rule.type === 'pull_request' && found.parameters?.required_approving_review_count !== 0) problems.push(`${wanted.name}: pull_request asks for ${found.parameters?.required_approving_review_count} approvals`);
|
|
129
|
+
if (rule.type === 'required_status_checks') {
|
|
130
|
+
const key = (check) => `${check.context}|${check.integration_id}`;
|
|
131
|
+
const want = rule.parameters.required_status_checks.map(key).sort();
|
|
132
|
+
const have = (found.parameters?.required_status_checks ?? []).map(key).sort();
|
|
133
|
+
if (JSON.stringify(want) !== JSON.stringify(have)) problems.push(`${wanted.name}: the required checks are ${JSON.stringify(have)}, not ${JSON.stringify(want)}`);
|
|
134
|
+
if (Boolean(found.parameters?.strict_required_status_checks_policy) !== rule.parameters.strict_required_status_checks_policy) problems.push(`${wanted.name}: up to date is ${found.parameters?.strict_required_status_checks_policy}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return problems;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The gh door. Each call is `gh api` with a JSON body on stdin.
|
|
141
|
+
export function ghApi(method, path, body) {
|
|
142
|
+
const args = ['api', '--method', method, path, '-H', 'Accept: application/vnd.github+json'];
|
|
143
|
+
if (body !== undefined) args.push('--input', '-');
|
|
144
|
+
const out = execFileSync('gh', args, { encoding: 'utf8', input: body === undefined ? undefined : JSON.stringify(body), env: childEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
145
|
+
return out.trim() ? JSON.parse(out) : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const explain = (error) => {
|
|
149
|
+
const text = `${error.stderr ?? ''}${error.message ?? ''}`;
|
|
150
|
+
if (/HTTP 403|Upgrade to GitHub Pro|403/.test(text)) return 'GitHub refused the ruleset (403). A private repo needs GitHub Pro for rulesets, and only a repo admin may set one.';
|
|
151
|
+
return text.trim().split('\n').slice(-3).join(' ');
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// `atlas gate apply`. Returns the exit code.
|
|
155
|
+
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false }, line, gh = ghApi) {
|
|
156
|
+
if (what !== 'apply') throw new GateError(`there is no gate verb "${what ?? ''}". Use apply.`);
|
|
157
|
+
if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError('atlas gate apply needs --repo <owner/name>.');
|
|
158
|
+
if (off && on) throw new GateError('pick --off or --on, not both.');
|
|
159
|
+
const mode = off ? 'off' : on ? 'on' : 'apply';
|
|
160
|
+
const facts = mode === 'apply' ? gateFacts(root) : null;
|
|
161
|
+
if (dryRun) line('atlas gate apply: dry run, nothing changes.');
|
|
162
|
+
if (facts) line(`atlas.yaml read from ${root}: ${facts.checks.length} required check(s), up_to_date ${facts.upToDate}.`);
|
|
163
|
+
line(`GET repos/${repo}/rulesets`);
|
|
164
|
+
let existing;
|
|
165
|
+
try { existing = gh('GET', `repos/${repo}/rulesets`) ?? []; } catch (error) {
|
|
166
|
+
if (!dryRun) { line(`atlas gate apply: ${explain(error)} Nothing was changed.`); return 1; }
|
|
167
|
+
line(` (the read failed: ${explain(error)} The plan below assumes no ruleset exists.)`);
|
|
168
|
+
existing = [];
|
|
169
|
+
}
|
|
170
|
+
const calls = plan({ repo, facts, existing, mode, dryRun });
|
|
171
|
+
for (const call of calls) line(describe(call));
|
|
172
|
+
if (dryRun) return 0;
|
|
173
|
+
const written = [];
|
|
174
|
+
for (const call of calls) {
|
|
175
|
+
try { written.push({ call, answer: gh(call.method, call.path, call.body) }); } catch (error) {
|
|
176
|
+
line(`atlas gate apply: ${call.method} ${call.path} failed: ${explain(error)}`);
|
|
177
|
+
line(written.length ? `Done before the failure: ${written.map((each) => each.call.name).join(', ')}.` : 'Nothing was changed.');
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (mode !== 'apply') { line(`atlas gate apply: both rulesets are ${mode === 'off' ? 'disabled' : 'active'}.`); return 0; }
|
|
182
|
+
const problems = [];
|
|
183
|
+
for (const wanted of rulesets(facts)) {
|
|
184
|
+
const id = written.find((each) => each.call.name === wanted.name)?.answer?.id ?? existing.find((ruleset) => ruleset.name === wanted.name)?.id;
|
|
185
|
+
const got = gh('GET', `repos/${repo}/rulesets/${id}`);
|
|
186
|
+
problems.push(...compare(wanted, got ?? {}));
|
|
187
|
+
}
|
|
188
|
+
const settings = gh('GET', `repos/${repo}`);
|
|
189
|
+
if (settings?.allow_auto_merge !== true) problems.push('the repo does not allow auto-merge');
|
|
190
|
+
if (problems.length) { for (const problem of problems) line(` ✗ ${problem}`); line('atlas gate apply: the read-back differs. Look at the repo settings.'); return 1; }
|
|
191
|
+
line(`atlas gate apply: the gate is on for ${repo}. Read back: ${BRANCH_RULESET}, ${TAG_RULESET}, allow_auto_merge true.`);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -101,10 +101,11 @@ set. A test keeps the two lists equal.
|
|
|
101
101
|
| `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
|
|
102
102
|
| `atlas code-read` | To resolve the citations of a code digest |
|
|
103
103
|
| `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
|
|
104
|
-
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `changed`
|
|
104
|
+
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `changed` and `before` run test checks of the gate: the changed tests, and the proof that a new case fails on the old code. `sweep` and `issues` run the daily sweep |
|
|
105
105
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
106
106
|
| `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
|
|
107
107
|
| `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
|
|
108
|
+
| `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass |
|
|
108
109
|
| `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
|
|
109
110
|
|
|
110
111
|
A person merges every file that `atlas tooling` writes.
|