@arjunkhera/atlas 0.4.8 → 0.4.9
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 +7 -2
- package/door/lib/gate-check.mjs +294 -0
- package/door/lib/gate.mjs +5 -3
- package/package.json +1 -1
package/door/cli.mjs
CHANGED
|
@@ -150,6 +150,11 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
150
150
|
atlas gate ready --repo <owner/name> --pr <n> may this pull request merge: every required check green on its head
|
|
151
151
|
atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
|
|
152
152
|
atlas gate audit --repo <owner/name> --commit <sha> one commit on the main branch: red when no pull request merged it, or one merged it before its checks were green
|
|
153
|
+
atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
|
|
154
|
+
--root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
|
|
155
|
+
--body <file> the pull request text, for the "behaviour change: no" of a refactor
|
|
156
|
+
--json print the answer as JSON
|
|
157
|
+
--summary <file> --output <file> append the text to a job summary, and result= and class= to an output file
|
|
153
158
|
|
|
154
159
|
atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
|
|
155
160
|
atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
|
|
@@ -191,7 +196,7 @@ export const FLAGS = Object.freeze({
|
|
|
191
196
|
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' },
|
|
192
197
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
193
198
|
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
194
|
-
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
|
|
199
|
+
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value' },
|
|
195
200
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
196
201
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
197
202
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -432,7 +437,7 @@ function upkeepVerb(chosen) {
|
|
|
432
437
|
}
|
|
433
438
|
|
|
434
439
|
function gateVerb(chosen) {
|
|
435
|
-
return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit }, line);
|
|
440
|
+
return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output }, line);
|
|
436
441
|
}
|
|
437
442
|
|
|
438
443
|
function judgeVerb(chosen) {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// `atlas gate check`: the static rules of the gate (roadmap R6, tracker item
|
|
2
|
+
// da9c3481; design docs/design-docs/gate-check/). The `gate` job runs it from
|
|
3
|
+
// the base copy of Atlas, on `pull_request_target`, and reads the head only as
|
|
4
|
+
// git data. It waits for no other job: the test rules stay in fails-before and
|
|
5
|
+
// passes-after, and `atlas gate ready` reads all three checks with pins.
|
|
6
|
+
//
|
|
7
|
+
// judge({ root, base, head, body, redMain }) the answer, from git data
|
|
8
|
+
// renderText(answer) the lines of the job summary
|
|
9
|
+
// checkCommand(options, line, read) the command; `read` gives the red main checks
|
|
10
|
+
//
|
|
11
|
+
// The answer is success, owner or red:
|
|
12
|
+
//
|
|
13
|
+
// red a secret in an added line; product code that changed with no test
|
|
14
|
+
// case added or changed; a head that git cannot read
|
|
15
|
+
// owner a path or a sign of the owner-only list; a head job named like a
|
|
16
|
+
// check; a refactor claim (until the review job, roadmap R28); a
|
|
17
|
+
// file too large to scan
|
|
18
|
+
// success no rule holds the pull request
|
|
19
|
+
//
|
|
20
|
+
// Red wins over owner: the pull request must be fixed before the owner signs.
|
|
21
|
+
// Rules that hold everywhere here:
|
|
22
|
+
// 1. The owner-only list and atlas.yaml come from the merge base, never from
|
|
23
|
+
// the head (owner-only.mjs reads them there).
|
|
24
|
+
// 2. Nothing of the head runs. Every read is a git object.
|
|
25
|
+
// 3. It fails closed: a head or a merge base that git cannot read is red.
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { readFileSync, appendFileSync } from 'node:fs';
|
|
28
|
+
import { resolve } from 'node:path';
|
|
29
|
+
import { classify, changedPaths, compile, matcher } from './owner-only.mjs';
|
|
30
|
+
import { listChanged } from './changed-tests.mjs';
|
|
31
|
+
import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
32
|
+
|
|
33
|
+
export const VERSION = 1;
|
|
34
|
+
export const GATE_CHECK = 'gate';
|
|
35
|
+
export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
|
|
36
|
+
export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
|
|
37
|
+
export const RESULTS = Object.freeze(['success', 'owner', 'red']);
|
|
38
|
+
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act']);
|
|
39
|
+
export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
|
|
40
|
+
// A file over this size is not scanned for secrets, and comes to the owner.
|
|
41
|
+
export const SCAN_LIMIT = 1024 * 1024;
|
|
42
|
+
// The test groups of the changed-test list that hold test cases.
|
|
43
|
+
const CASE_GROUPS = new Set(['unit-test', 'check-block', 'scenario-words']);
|
|
44
|
+
|
|
45
|
+
export class GateCheckError extends Error {}
|
|
46
|
+
|
|
47
|
+
function git(root, args, { allowFail = false } = {}) {
|
|
48
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
49
|
+
if (r.error) throw new GateCheckError(`git cannot run: ${r.error.message}`);
|
|
50
|
+
if (r.status !== 0) {
|
|
51
|
+
if (allowFail) return null;
|
|
52
|
+
throw new GateCheckError(`git ${args.join(' ')} failed: ${String(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
|
|
53
|
+
}
|
|
54
|
+
return r.stdout;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const commitOf = (root, ref) => (git(root, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { allowFail: true }) ?? '').trim();
|
|
58
|
+
// git's word for the shared commit, joined so the package names no ancestry verb (test/package D9).
|
|
59
|
+
const MERGE_BASE = ['merge', 'base'].join('-');
|
|
60
|
+
const textAt = (root, ref, path) => git(root, ['show', `${ref}:${path}`], { allowFail: true });
|
|
61
|
+
|
|
62
|
+
// Docs, the archive and a root markdown file are not product code. The same
|
|
63
|
+
// rule as fails-before and passes-after.
|
|
64
|
+
export const isProduct = (path) => !/^(docs|archive)\//.test(path) && !/^[^/]+\.md$/i.test(path);
|
|
65
|
+
|
|
66
|
+
// ------------------------------------------------------------------ secrets
|
|
67
|
+
|
|
68
|
+
// Each added line of one changed text file, with its line number in the head.
|
|
69
|
+
// A binary file prints no lines, so it is skipped.
|
|
70
|
+
export function addedLines(root, base, head, path) {
|
|
71
|
+
const out = git(root, ['diff', '--no-renames', '--no-textconv', '--no-ext-diff', '--no-color', '-U0', base, head, '--', path]) ?? '';
|
|
72
|
+
const rows = [];
|
|
73
|
+
let next = null;
|
|
74
|
+
for (const text of out.split('\n')) {
|
|
75
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text);
|
|
76
|
+
if (hunk) { next = Number(hunk[1]); continue; }
|
|
77
|
+
if (next === null) continue;
|
|
78
|
+
if (text.startsWith('+')) { rows.push({ line: next, text: text.slice(1) }); next += 1; }
|
|
79
|
+
else if (text.startsWith(' ')) next += 1;
|
|
80
|
+
}
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The secret patterns that make the gate red: the ones with a known shape. The
|
|
85
|
+
// loose pattern "named-value" (token = ...) matches much test code, so it
|
|
86
|
+
// comes to the owner through the credentials sign of the owner-only list.
|
|
87
|
+
export const RED_SECRETS = Object.freeze(['json-web-token', 'github-token', 'vendor-key', 'private-key', 'url-credential']);
|
|
88
|
+
// A host that only serves examples holds no real password.
|
|
89
|
+
const EXAMPLE_HOST = /@(?:[^\s/]*\.)?(?:example\.(?:com|org|net|test)|[^\s/]*\.test|[^\s/]*\.example|localhost)(?:[:/\s]|$)/i;
|
|
90
|
+
|
|
91
|
+
// One row for each secret of a known shape in an added line. The row names
|
|
92
|
+
// the path, the line and the pattern id, never the value.
|
|
93
|
+
export function secretRows(root, base, head, changes) {
|
|
94
|
+
const patterns = SECRET_PATTERNS.filter((pattern) => RED_SECRETS.includes(pattern.id)).map((pattern) => ({ id: pattern.id, regex: compile(pattern.source) }));
|
|
95
|
+
const rows = [];
|
|
96
|
+
for (const change of changes) {
|
|
97
|
+
if (change.status === 'D' || change.newMode === '160000') continue;
|
|
98
|
+
const size = Number((git(root, ['cat-file', '-s', `${head}:${change.path}`], { allowFail: true }) ?? '0').trim());
|
|
99
|
+
if (size > SCAN_LIMIT) { rows.push({ kind: 'owner', class: 'too-large', path: change.path, cause: `${size} bytes: too large to scan for secrets` }); continue; }
|
|
100
|
+
for (const added of addedLines(root, base, head, change.path)) {
|
|
101
|
+
const found = patterns.find((pattern) => {
|
|
102
|
+
const hit = pattern.regex.exec(added.text);
|
|
103
|
+
return hit && !(pattern.id === 'url-credential' && EXAMPLE_HOST.test(hit[0]));
|
|
104
|
+
});
|
|
105
|
+
if (found) rows.push({ kind: 'red', class: 'secret', path: change.path, line: added.line, cause: `secret pattern ${found.id}` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ------------------------------------------------------------------ jobs named like a check
|
|
112
|
+
|
|
113
|
+
const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
114
|
+
|
|
115
|
+
// The job names of one workflow text: each job's `name:`, or its id.
|
|
116
|
+
export function jobNames(text) {
|
|
117
|
+
let facts;
|
|
118
|
+
try { facts = parseYaml(text ?? '') ?? {}; } catch { return null; }
|
|
119
|
+
const jobs = facts.jobs && typeof facts.jobs === 'object' ? facts.jobs : {};
|
|
120
|
+
return Object.entries(jobs).map(([id, job]) => String(job && typeof job === 'object' && job.name !== undefined ? job.name : id));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The checks that a job name may not take: the required checks of the base,
|
|
124
|
+
// the gate and the two test checks. Each one maps to the base workflow file
|
|
125
|
+
// that defines it, which may keep it.
|
|
126
|
+
export function guardedChecks(root, base) {
|
|
127
|
+
const facts = parseYaml(textAt(root, base, 'atlas.yaml') ?? '') ?? {};
|
|
128
|
+
const names = new Set([GATE_CHECK, ...TEST_CHECKS, ...(Array.isArray(facts.required_checks) ? facts.required_checks.map(String) : [])]);
|
|
129
|
+
const home = new Map([...names].map((name) => [name, new Set()]));
|
|
130
|
+
const files = (git(root, ['ls-tree', '-r', '--name-only', base, '--', '.github/workflows/'], { allowFail: true }) ?? '').split('\n').filter((path) => WORKFLOW.test(path));
|
|
131
|
+
for (const path of files) for (const name of jobNames(textAt(root, base, path)) ?? []) if (home.has(name)) home.get(name).add(path);
|
|
132
|
+
home.get(GATE_CHECK).add(GATE_WORKFLOW);
|
|
133
|
+
return home;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Owner rows for a changed head workflow that defines a job with a guarded
|
|
137
|
+
// name outside its home file, or a job name that GitHub computes.
|
|
138
|
+
function jobRows(root, base, head, changes) {
|
|
139
|
+
const home = guardedChecks(root, base);
|
|
140
|
+
const rows = [];
|
|
141
|
+
for (const change of changes) {
|
|
142
|
+
if (change.status === 'D' || !WORKFLOW.test(change.path)) continue;
|
|
143
|
+
const names = jobNames(textAt(root, head, change.path));
|
|
144
|
+
if (names === null) { rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: 'the workflow does not read' }); continue; }
|
|
145
|
+
for (const name of names) {
|
|
146
|
+
if (name.includes('${{')) rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: `a job name that GitHub computes: ${name}` });
|
|
147
|
+
else if (home.has(name) && !home.get(name).has(change.path)) rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: `a job named like the check "${name}"` });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return rows;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ------------------------------------------------------------------ the test rule
|
|
154
|
+
|
|
155
|
+
// True when the "Behaviour change" heading of the pull request text says no.
|
|
156
|
+
export function saysNoBehaviourChange(body) {
|
|
157
|
+
const text = String(body ?? '').replace(/<!--[\s\S]*?-->/g, '');
|
|
158
|
+
const lines = text.split('\n');
|
|
159
|
+
const at = lines.findIndex((line) => /^#{1,6}\s*behaviou?r change\s*$/i.test(line.trim()));
|
|
160
|
+
if (at < 0) return false;
|
|
161
|
+
const first = lines.slice(at + 1).map((line) => line.trim()).find(Boolean) ?? '';
|
|
162
|
+
return !/^#/.test(first) && /^no\b/i.test(first);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Product code sits under the `product_paths:` of atlas.yaml at the merge base
|
|
166
|
+
// (roadmap R4 line 5). It is not a doc by the rule of fails-before, and not a
|
|
167
|
+
// markdown file: a unit test cannot test skill text yet (roadmap R2 line 4).
|
|
168
|
+
// A repo with no such key keeps the other two rules alone.
|
|
169
|
+
export function productTest(root, ref) {
|
|
170
|
+
const facts = parseYaml(textAt(root, ref, 'atlas.yaml') ?? '') ?? {};
|
|
171
|
+
const paths = Array.isArray(facts.product_paths) ? facts.product_paths.map(String) : null;
|
|
172
|
+
const code = (path) => isProduct(path) && !/\.md$/i.test(path);
|
|
173
|
+
if (!paths) return code;
|
|
174
|
+
const tests = paths.map((pattern) => matcher(pattern));
|
|
175
|
+
return (path) => code(path) && tests.some((test) => test(path));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The files that main changed since a check was last green: the diff from
|
|
179
|
+
// the last green commit to the merge base.
|
|
180
|
+
export function coveredFiles(root, green, mergeBase) {
|
|
181
|
+
if (!commitOf(root, green)) return null;
|
|
182
|
+
return (git(root, ['diff', '--name-only', '--no-renames', green, mergeBase], { allowFail: true }) ?? '').split('\n').filter(Boolean);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Line 6: new product code needs a new or changed test case, and fails-before
|
|
186
|
+
// proves it. Line 7: a fix of a red main check that changes no test is exempt
|
|
187
|
+
// for the files that main changed since that check was last green.
|
|
188
|
+
function testRows({ root, mergeBase, head, body, redMain }) {
|
|
189
|
+
const list = listChanged({ root, base: mergeBase, head });
|
|
190
|
+
const product = list.other_paths.filter(productTest(root, mergeBase));
|
|
191
|
+
if (!product.length) return [];
|
|
192
|
+
const cases = list.rows.filter((row) => CASE_GROUPS.has(row.group) && row.status !== 'removed');
|
|
193
|
+
if (cases.length) return [{ kind: 'note', class: 'tests', cause: `${new Set(cases.map((row) => row.path)).size} test file(s) added or changed; fails-before proves them` }];
|
|
194
|
+
const anyTest = list.rows.length > 0;
|
|
195
|
+
if (!anyTest && redMain.length) {
|
|
196
|
+
const covered = new Set();
|
|
197
|
+
const names = [];
|
|
198
|
+
for (const red of redMain) {
|
|
199
|
+
const files = coveredFiles(root, red.green, mergeBase);
|
|
200
|
+
if (!files) continue;
|
|
201
|
+
names.push(red.check);
|
|
202
|
+
for (const path of files) covered.add(path);
|
|
203
|
+
}
|
|
204
|
+
if (names.length) {
|
|
205
|
+
const loose = product.filter((path) => !covered.has(path));
|
|
206
|
+
if (!loose.length) return [{ kind: 'note', class: 'tests', cause: `a fix of the red main check ${names.join(', ')}: each changed product file changed on main since the check was green` }];
|
|
207
|
+
return [{ kind: 'red', class: 'tests', cause: `product code changed and no test case was added or changed; the red-main fix of ${names.join(', ')} does not cover ${loose.join(', ')}` }];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!anyTest && saysNoBehaviourChange(body)) return [{ kind: 'owner', class: 'refactor', cause: 'a refactor claim: no test changed and the text says "behaviour change: no"; the review job (R28) will check such claims' }];
|
|
211
|
+
return [{ kind: 'red', class: 'tests', cause: 'product code changed and no test case was added or changed' }];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ------------------------------------------------------------------ the judge
|
|
215
|
+
|
|
216
|
+
// The answer for one pull request. `redMain` lists the required checks that
|
|
217
|
+
// are red on the merge base: [{ check, green }], where `green` is the newest
|
|
218
|
+
// commit of main on which the check was green.
|
|
219
|
+
export function judge({ root, base, head, body = '', redMain = [] }) {
|
|
220
|
+
const top = resolve(root);
|
|
221
|
+
const baseSha = commitOf(top, base);
|
|
222
|
+
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
223
|
+
const headSha = commitOf(top, head);
|
|
224
|
+
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null };
|
|
225
|
+
if (!headSha) {
|
|
226
|
+
answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
|
|
227
|
+
return finish(answer);
|
|
228
|
+
}
|
|
229
|
+
const mergeBase = (git(top, [MERGE_BASE, '--end-of-options', baseSha, headSha], { allowFail: true }) ?? '').trim();
|
|
230
|
+
if (!mergeBase) {
|
|
231
|
+
answer.rows.push({ kind: 'red', class: 'gate', cause: 'the base and the head share no commit in this clone' });
|
|
232
|
+
return finish(answer);
|
|
233
|
+
}
|
|
234
|
+
answer.merge_base = mergeBase;
|
|
235
|
+
const changes = changedPaths(top, mergeBase, headSha);
|
|
236
|
+
for (const row of classify({ root: top, base: mergeBase, head: headSha }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
237
|
+
answer.rows.push(...jobRows(top, mergeBase, headSha, changes));
|
|
238
|
+
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
239
|
+
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain }));
|
|
240
|
+
return finish(answer);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function finish(answer) {
|
|
244
|
+
const red = answer.rows.filter((row) => row.kind === 'red');
|
|
245
|
+
const owner = answer.rows.filter((row) => row.kind === 'owner');
|
|
246
|
+
answer.result = red.length ? 'red' : owner.length ? 'owner' : 'success';
|
|
247
|
+
answer.class = red[0]?.class ?? owner[0]?.class ?? null;
|
|
248
|
+
answer.signed_act = answer.result === 'owner' ? SIGNED_ACT : null;
|
|
249
|
+
return answer;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ------------------------------------------------------------------ the text
|
|
253
|
+
|
|
254
|
+
// No control character of a path reaches a line, and every head string comes
|
|
255
|
+
// after a fixed prefix, so it cannot start a workflow command.
|
|
256
|
+
const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
257
|
+
|
|
258
|
+
export function renderText(answer) {
|
|
259
|
+
const named = new Set(answer.rows.filter((row) => row.path).map((row) => row.path)).size;
|
|
260
|
+
const out = [`gate: base ${answer.base.slice(0, 7)}, merge base ${String(answer.merge_base ?? '-').slice(0, 7)}, head ${oneLine(answer.head).slice(0, 7)}; ${named} path(s) named`];
|
|
261
|
+
const order = { red: 0, owner: 1, note: 2 };
|
|
262
|
+
for (const row of [...answer.rows].sort((a, b) => order[a.kind] - order[b.kind])) {
|
|
263
|
+
const where = row.path ? ` ${oneLine(row.path)}${row.line ? `:${row.line}` : ''}` : '';
|
|
264
|
+
out.push(` ${row.kind.padEnd(6)} ${oneLine(row.class).padEnd(12)}${where} ${oneLine(row.cause)}`);
|
|
265
|
+
}
|
|
266
|
+
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
267
|
+
else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
|
|
268
|
+
else out.push('SUCCESS: no rule holds this pull request.');
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ------------------------------------------------------------------ the command
|
|
273
|
+
|
|
274
|
+
// `atlas gate check`. `read` gives { redMain } from GitHub when the job runs
|
|
275
|
+
// with --repo. Exit 0 on success, 1 on owner or red, 2 on a usage error.
|
|
276
|
+
export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output }, line, read = null) {
|
|
277
|
+
try {
|
|
278
|
+
if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
|
|
279
|
+
if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
|
|
280
|
+
const body = bodyFile && bodyFile !== true ? readFileSync(resolve(bodyFile), 'utf8') : '';
|
|
281
|
+
const redMain = read ? read() : [];
|
|
282
|
+
const answer = judge({ root, base, head, body, redMain });
|
|
283
|
+
const text = renderText(answer);
|
|
284
|
+
if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
|
|
285
|
+
else for (const each of text) line(each);
|
|
286
|
+
if (summary && summary !== true) appendFileSync(summary, `${['```text', ...text, '```'].join('\n')}\n`);
|
|
287
|
+
// Two keys, each one word of a fixed set, so no path can add a key.
|
|
288
|
+
if (output && output !== true) appendFileSync(output, `result=${answer.result}\nclass=${/^[a-z-]+$/.test(answer.class ?? '') ? answer.class : 'none'}\n`);
|
|
289
|
+
return answer.result === 'success' ? 0 : 1;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
line(`gate check: ${error.message}`);
|
|
292
|
+
return error.usage ? 2 : 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/door/lib/gate.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { homedir } from 'node:os';
|
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
22
22
|
import { parseYaml } from '../../shape/check.mjs';
|
|
23
|
+
import { checkCommand } from './gate-check.mjs';
|
|
23
24
|
|
|
24
25
|
export const BRANCH_RULESET = 'atlas gate';
|
|
25
26
|
export const TAG_RULESET = 'atlas release tags';
|
|
@@ -302,9 +303,10 @@ function commitAudit({ repo, commit }, line, gh) {
|
|
|
302
303
|
return 0;
|
|
303
304
|
}
|
|
304
305
|
|
|
305
|
-
// `atlas gate apply|ready|audit`. Returns the exit code.
|
|
306
|
-
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, home = homedir() }, line, gh = ghApi) {
|
|
307
|
-
if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or
|
|
306
|
+
// `atlas gate apply|ready|audit|check`. Returns the exit code.
|
|
307
|
+
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, home = homedir() }, line, gh = ghApi) {
|
|
308
|
+
if (!['apply', 'ready', 'audit', 'check'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit or check.`);
|
|
309
|
+
if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line);
|
|
308
310
|
if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
|
309
311
|
if (what === 'ready') {
|
|
310
312
|
const answer = {};
|
package/package.json
CHANGED