@arjunkhera/atlas 0.4.9 → 0.4.11
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 +6 -2
- package/door/lib/fails-before.mjs +21 -6
- package/door/lib/gate-check.mjs +85 -6
- package/door/lib/gate.mjs +115 -7
- package/door/lib/owner-only.mjs +5 -2
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
package/door/cli.mjs
CHANGED
|
@@ -155,6 +155,10 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
155
155
|
--body <file> the pull request text, for the "behaviour change: no" of a refactor
|
|
156
156
|
--json print the answer as JSON
|
|
157
157
|
--summary <file> --output <file> append the text to a job summary, and result= and class= to an output file
|
|
158
|
+
atlas gate replay [--days 30] the static rules of the gate over the first-parent commits of the main branch; the owner share. Writes nothing
|
|
159
|
+
--root <repo> the clone to read (default: this folder)
|
|
160
|
+
--branch <name> the branch; default origin/HEAD
|
|
161
|
+
--json print the answer as JSON
|
|
158
162
|
|
|
159
163
|
atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
|
|
160
164
|
atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
|
|
@@ -196,7 +200,7 @@ export const FLAGS = Object.freeze({
|
|
|
196
200
|
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' },
|
|
197
201
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
198
202
|
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
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' },
|
|
203
|
+
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', days: 'value', branch: 'value' },
|
|
200
204
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
201
205
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
202
206
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -437,7 +441,7 @@ function upkeepVerb(chosen) {
|
|
|
437
441
|
}
|
|
438
442
|
|
|
439
443
|
function gateVerb(chosen) {
|
|
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);
|
|
444
|
+
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, days: chosen.days, branch: chosen.branch }, line);
|
|
441
445
|
}
|
|
442
446
|
|
|
443
447
|
function judgeVerb(chosen) {
|
|
@@ -268,7 +268,7 @@ export const commandOf = (runner, file, report) => (runner === 'node'
|
|
|
268
268
|
// A fresh tree of `sha` at `tree`, from git archive: no .git of the repo, so a test cannot read which
|
|
269
269
|
// commit it is. gitTree then gives it a .git of its own.
|
|
270
270
|
export function freshTree(top, tree, sha) {
|
|
271
|
-
rmSync(tree, { recursive: true, force: true });
|
|
271
|
+
rmSync(tree, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
272
272
|
mkdirSync(tree, { recursive: true });
|
|
273
273
|
const archive = spawnSync('git', ['archive', '--format=tar', sha], { cwd: top, maxBuffer: 1024 * 1024 * 1024 });
|
|
274
274
|
if (archive.status !== 0) throw new Error(`git archive failed: ${String(archive.stderr).trim()}`);
|
|
@@ -328,7 +328,9 @@ export function runSide({ top, changed, plans, side, work = join(tmpdir(), 'atla
|
|
|
328
328
|
out.files[p.path] = { command, cases, why };
|
|
329
329
|
}
|
|
330
330
|
} finally {
|
|
331
|
-
|
|
331
|
+
// A process that a test left behind can still write in the tree for a
|
|
332
|
+
// moment; Node then retries the remove (atlas#209: ENOTEMPTY in CI).
|
|
333
|
+
rmSync(tree, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
332
334
|
}
|
|
333
335
|
return out;
|
|
334
336
|
}
|
|
@@ -343,7 +345,7 @@ export function combine(prepared, oldRun, newRun) {
|
|
|
343
345
|
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 };
|
|
344
346
|
if (!productChanged || !plans.length) {
|
|
345
347
|
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' });
|
|
346
|
-
return finish(answer);
|
|
348
|
+
return finish(answer, nothingProven(prepared, answer));
|
|
347
349
|
}
|
|
348
350
|
if (oldRun?.side !== 'old' || newRun?.side !== 'new') throw new Error('give one run file of the side old and one of the side new');
|
|
349
351
|
for (const run of [oldRun, newRun]) {
|
|
@@ -373,7 +375,18 @@ export function combine(prepared, oldRun, newRun) {
|
|
|
373
375
|
answer.cases.push({ path: p.path, name: c.name, change: c.change, before, after, result: c.duplicate ? 'duplicate' : resultOf(before, after) });
|
|
374
376
|
}
|
|
375
377
|
}
|
|
376
|
-
return finish(answer);
|
|
378
|
+
return finish(answer, nothingProven(prepared, answer));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Roadmap R6 done line 6: product code changed, the pull request changed a
|
|
382
|
+
// unit test file that runs here, and yet no case runs, such as a change of
|
|
383
|
+
// spaces in a case. The gate counts that file as a test change, so this check
|
|
384
|
+
// says red. A pull request whose only test change is a scenario file, or a
|
|
385
|
+
// file with no runner here, gets its proof elsewhere: the planted-fault
|
|
386
|
+
// procedure.
|
|
387
|
+
function nothingProven({ changed, productChanged }, answer) {
|
|
388
|
+
if (!productChanged || answer.cases.length) return false;
|
|
389
|
+
return changed.rows.some((r) => r.group === 'unit-test' && r.status !== 'removed' && runnerOf(r.path));
|
|
377
390
|
}
|
|
378
391
|
|
|
379
392
|
// Both runs, one after the other, then the answer. For a local run.
|
|
@@ -385,9 +398,10 @@ export function failsBefore({ root, base, head = 'HEAD', list = null, work = joi
|
|
|
385
398
|
return combine(prepared, oldRun, newRun);
|
|
386
399
|
}
|
|
387
400
|
|
|
388
|
-
function finish(answer) {
|
|
401
|
+
function finish(answer, nothing = false) {
|
|
389
402
|
answer.cases.sort((x, y) => (x.path === y.path ? String(x.name).localeCompare(String(y.name)) : x.path < y.path ? -1 : 1));
|
|
390
|
-
answer.
|
|
403
|
+
answer.nothing_proven = nothing;
|
|
404
|
+
answer.red = nothing || answer.cases.some((c) => RED_RESULTS.includes(c.result));
|
|
391
405
|
answer.proven_count = answer.cases.filter((c) => c.result === 'proven').length;
|
|
392
406
|
return answer;
|
|
393
407
|
}
|
|
@@ -403,6 +417,7 @@ export function renderText(a) {
|
|
|
403
417
|
for (const f of a.files.filter((x) => x.result === 'ran' && x.why)) out.push(` note ${oneLine(f.path)} ${oneLine(f.why)}`);
|
|
404
418
|
const red = a.cases.filter((c) => RED_RESULTS.includes(c.result));
|
|
405
419
|
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.`);
|
|
420
|
+
else if (a.nothing_proven) out.push('RED: nothing proven. Product code changed and a test file changed, but no new or changed case runs. Add or change a test case.');
|
|
406
421
|
else if (!a.product_changed) out.push('GREEN: no product code changed, so the new cases are not proven.');
|
|
407
422
|
else if (a.product_changed && !a.cases.length) out.push('GREEN: product code changed, and no new or changed case runs. Nothing is proven.');
|
|
408
423
|
else out.push(`GREEN: no red case. Green does not mean proven: ${a.proven_count} proven, ${a.cases.length - a.proven_count} not proven.`);
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -185,9 +185,9 @@ export function coveredFiles(root, green, mergeBase) {
|
|
|
185
185
|
// Line 6: new product code needs a new or changed test case, and fails-before
|
|
186
186
|
// proves it. Line 7: a fix of a red main check that changes no test is exempt
|
|
187
187
|
// for the files that main changed since that check was last green.
|
|
188
|
-
function testRows({ root, mergeBase, head, body, redMain }) {
|
|
188
|
+
function testRows({ root, mergeBase, head, body, redMain, rules }) {
|
|
189
189
|
const list = listChanged({ root, base: mergeBase, head });
|
|
190
|
-
const product = list.other_paths.filter(productTest(root, mergeBase));
|
|
190
|
+
const product = list.other_paths.filter(productTest(root, rules ?? mergeBase));
|
|
191
191
|
if (!product.length) return [];
|
|
192
192
|
const cases = list.rows.filter((row) => CASE_GROUPS.has(row.group) && row.status !== 'removed');
|
|
193
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` }];
|
|
@@ -216,7 +216,7 @@ function testRows({ root, mergeBase, head, body, redMain }) {
|
|
|
216
216
|
// The answer for one pull request. `redMain` lists the required checks that
|
|
217
217
|
// are red on the merge base: [{ check, green }], where `green` is the newest
|
|
218
218
|
// commit of main on which the check was green.
|
|
219
|
-
export function judge({ root, base, head, body = '', redMain = [] }) {
|
|
219
|
+
export function judge({ root, base, head, body = '', redMain = [], rules = null }) {
|
|
220
220
|
const top = resolve(root);
|
|
221
221
|
const baseSha = commitOf(top, base);
|
|
222
222
|
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
@@ -233,10 +233,10 @@ export function judge({ root, base, head, body = '', redMain = [] }) {
|
|
|
233
233
|
}
|
|
234
234
|
answer.merge_base = mergeBase;
|
|
235
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));
|
|
236
|
+
for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
237
|
+
answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
|
|
238
238
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
239
|
-
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain }));
|
|
239
|
+
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules }));
|
|
240
240
|
return finish(answer);
|
|
241
241
|
}
|
|
242
242
|
|
|
@@ -292,3 +292,82 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
|
|
|
292
292
|
return error.usage ? 2 : 1;
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
|
+
|
|
296
|
+
// ------------------------------------------------------------------ the replay
|
|
297
|
+
|
|
298
|
+
// `atlas gate replay`: the static rules over the first-parent commits of a
|
|
299
|
+
// branch in the last `days` days (done line 13). It writes nothing. The test
|
|
300
|
+
// checks cannot be replayed, so it also counts the commits that changed an
|
|
301
|
+
// old test: passes-after could add those to the owner share.
|
|
302
|
+
export function replay({ root, days = 30, branch = null, now = new Date() }) {
|
|
303
|
+
const top = resolve(root);
|
|
304
|
+
const ref = branch ?? ([(git(top, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { allowFail: true }) ?? '').trim(), 'origin/main', 'origin/master'].find((name) => name && commitOf(top, name)) || 'HEAD');
|
|
305
|
+
if (!commitOf(top, ref)) throw new GateCheckError(`there is no branch "${ref}" in this clone.`);
|
|
306
|
+
const tip = commitOf(top, ref);
|
|
307
|
+
const since = new Date(now.getTime() - days * 864e5).toISOString();
|
|
308
|
+
const shas = (git(top, ['log', '--first-parent', `--since=${since}`, '--format=%H %P', ref]) ?? '').split('\n').filter(Boolean);
|
|
309
|
+
const facts = parseYaml(textAt(top, ref, 'atlas.yaml') ?? '') ?? {};
|
|
310
|
+
const rows = [];
|
|
311
|
+
for (const entry of shas) {
|
|
312
|
+
const [sha, parent] = entry.split(' ');
|
|
313
|
+
if (!parent) continue;
|
|
314
|
+
const answer = judge({ root: top, base: parent, head: sha, rules: tip });
|
|
315
|
+
const list = listChanged({ root: top, base: parent, head: sha });
|
|
316
|
+
const oldTest = list.rows.some((row) => CASE_GROUPS.has(row.group) && row.status !== 'added');
|
|
317
|
+
rows.push({
|
|
318
|
+
sha, result: answer.result,
|
|
319
|
+
owner: answer.rows.some((row) => row.kind === 'owner'),
|
|
320
|
+
red: answer.rows.some((row) => row.kind === 'red'),
|
|
321
|
+
old_test: oldTest,
|
|
322
|
+
causes: [...new Set(answer.rows.filter((row) => row.kind !== 'note').map((row) => `${row.kind}: ${row.class}: ${causeKey(row)}`))],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const count = (test) => rows.filter(test).length;
|
|
326
|
+
const share = (n) => (rows.length ? Math.round((n / rows.length) * 100) : 0);
|
|
327
|
+
const causes = new Map();
|
|
328
|
+
for (const row of rows) for (const cause of row.causes) causes.set(cause, (causes.get(cause) ?? 0) + 1);
|
|
329
|
+
return {
|
|
330
|
+
gate_replay: VERSION, branch: ref, since, commits: rows.length,
|
|
331
|
+
meaningful: Array.isArray(facts.product_paths),
|
|
332
|
+
owner: count((row) => row.owner), red: count((row) => row.red), success: count((row) => row.result === 'success'),
|
|
333
|
+
owner_share: share(count((row) => row.owner)),
|
|
334
|
+
old_test_share: share(count((row) => row.old_test)),
|
|
335
|
+
old_test_only_share: share(count((row) => row.old_test && !row.owner)),
|
|
336
|
+
causes: [...causes].sort((a, b) => b[1] - a[1]).map(([cause, n]) => ({ cause, commits: n })),
|
|
337
|
+
rows,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// One cause for the count: a list or floor rule keeps its rule, and a path
|
|
342
|
+
// cause keeps its folder, so one rule counts once over many files.
|
|
343
|
+
function causeKey(row) {
|
|
344
|
+
if (/^(list|floor|sign): /.test(row.cause)) return row.cause.replace(/ \(.*\)$/, '');
|
|
345
|
+
if (row.class === 'secret') return row.cause;
|
|
346
|
+
if (row.class === 'unlisted') return 'outside product_paths';
|
|
347
|
+
return row.cause.replace(/: .*$/, '').replace(/ src\/\S+| \S+\/\S+/g, '');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function renderReplay(answer) {
|
|
351
|
+
const out = [`gate replay: ${answer.commits} first-parent commit(s) of ${answer.branch} since ${answer.since.slice(0, 10)}, by the list at ${answer.branch}`];
|
|
352
|
+
if (!answer.meaningful) out.push(' NOT MEANINGFUL: atlas.yaml lists no product_paths, so every path comes to the owner.');
|
|
353
|
+
out.push(` owner share (path and sign rules): ${answer.owner_share}% (${answer.owner} of ${answer.commits})`);
|
|
354
|
+
out.push(` changed an old test, where passes-after decides: ${answer.old_test_share}%; of those, not owner by path: ${answer.old_test_only_share}%`);
|
|
355
|
+
out.push(` red: ${answer.red}, success: ${answer.success}`);
|
|
356
|
+
out.push(' causes, by commits:');
|
|
357
|
+
for (const each of answer.causes.slice(0, 12)) out.push(` ${String(each.commits).padStart(4)} ${oneLine(each.cause)}`);
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function replayCommand({ root, days, branch, json = false }, line) {
|
|
362
|
+
try {
|
|
363
|
+
const n = days === undefined || days === true ? 30 : Number(days);
|
|
364
|
+
if (!Number.isInteger(n) || n < 1 || n > 365) throw Object.assign(new GateCheckError('--days must be a whole number from 1 to 365.'), { usage: true });
|
|
365
|
+
const answer = replay({ root, days: n, branch: branch && branch !== true ? branch : null });
|
|
366
|
+
if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
|
|
367
|
+
else for (const each of renderReplay(answer)) line(each);
|
|
368
|
+
return 0;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
line(`gate replay: ${error.message}`);
|
|
371
|
+
return error.usage ? 2 : 1;
|
|
372
|
+
}
|
|
373
|
+
}
|
package/door/lib/gate.mjs
CHANGED
|
@@ -20,7 +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
|
+
import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
24
24
|
|
|
25
25
|
export const BRANCH_RULESET = 'atlas gate';
|
|
26
26
|
export const TAG_RULESET = 'atlas release tags';
|
|
@@ -195,6 +195,85 @@ function checksAt(gh, repo, ref) {
|
|
|
195
195
|
const checkRuns = (gh, repo, sha) => gh('GET', `repos/${repo}/commits/${sha}/check-runs?per_page=100`)?.check_runs ?? [];
|
|
196
196
|
const checkLine = (row) => ` ${row.state === 'green' ? '✓' : '✗'} ${row.state.padEnd(8)} ${row.name}${row.conclusion && row.state === 'red' ? ` (${row.conclusion})` : ''}`;
|
|
197
197
|
|
|
198
|
+
// The base files that define each required check (roadmap R6 done line 8).
|
|
199
|
+
// A check counts only from a workflow run of one of its files, as the base
|
|
200
|
+
// branch holds them. The gate always comes from its own file.
|
|
201
|
+
function homesAt(gh, repo, ref, checks) {
|
|
202
|
+
const homes = new Map(checks.map((name) => [name, new Set()]));
|
|
203
|
+
const listing = gh('GET', `repos/${repo}/contents/.github/workflows?ref=${encodeURIComponent(ref)}`);
|
|
204
|
+
for (const entry of Array.isArray(listing) ? listing : []) {
|
|
205
|
+
if (entry?.type !== 'file' || !/\.ya?ml$/.test(entry.name ?? '')) continue;
|
|
206
|
+
const file = gh('GET', `repos/${repo}/contents/${entry.path}?ref=${encodeURIComponent(ref)}`);
|
|
207
|
+
if (typeof file?.content !== 'string') continue;
|
|
208
|
+
for (const name of jobNames(Buffer.from(file.content, 'base64').toString('utf8')) ?? []) if (homes.has(name)) homes.get(name).add(entry.path);
|
|
209
|
+
}
|
|
210
|
+
if (homes.has(GATE_CHECK)) homes.set(GATE_CHECK, new Set([GATE_WORKFLOW]));
|
|
211
|
+
return homes;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The title that the gate workflow gives each run (its `run-name`). The base
|
|
215
|
+
// file sets it, so a pull request cannot write it.
|
|
216
|
+
export const gateRunTitle = (pr, sha) => `gate #${pr} at ${sha}`;
|
|
217
|
+
|
|
218
|
+
// Why one workflow run cannot carry a required check of this pull request,
|
|
219
|
+
// or null when it can. The run must come from a base file that defines the
|
|
220
|
+
// check, from a pull request event, on this head, for this pull request into
|
|
221
|
+
// its base branch.
|
|
222
|
+
export function refusal(run, { name, pr, sha, baseRef, homes }) {
|
|
223
|
+
if (!run) return 'no workflow run';
|
|
224
|
+
if (!homes.get(name)?.has(run.path)) return `from ${run.path ?? 'no file'}, not a base file that defines the check`;
|
|
225
|
+
if (run.event !== 'pull_request') return `from the event ${run.event}`;
|
|
226
|
+
if (run.head_sha !== sha) return 'of another head commit';
|
|
227
|
+
const pulls = run.pull_requests ?? [];
|
|
228
|
+
if (!pulls.some((each) => Number(each.number) === Number(pr) && each.base?.ref === baseRef)) return `not of #${pr} into ${baseRef}`;
|
|
229
|
+
// GitHub lists every open pull request with this head, so a run made for a
|
|
230
|
+
// pull request into a side branch can also name this one. Such a run used
|
|
231
|
+
// the side branch's file, so it does not count.
|
|
232
|
+
if (pulls.some((each) => each.base?.ref !== baseRef)) return `also of a pull request into ${pulls.find((each) => each.base?.ref !== baseRef).base?.ref ?? 'another branch'}`;
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The same for a run of the gate. It runs on pull_request_target, so it sits
|
|
237
|
+
// on a commit of the base branch, not on the head. Its title names the pull
|
|
238
|
+
// request and the head; the run must come from the base branch and its file.
|
|
239
|
+
export function gateRefusal(run, { pr, sha, baseRef, onBase }) {
|
|
240
|
+
if (run.path !== GATE_WORKFLOW) return `from ${run.path ?? 'no file'}, not ${GATE_WORKFLOW}`;
|
|
241
|
+
if (run.event !== 'pull_request_target') return `from the event ${run.event}`;
|
|
242
|
+
if (run.display_title !== gateRunTitle(pr, sha)) return 'of another pull request or head';
|
|
243
|
+
if (run.head_branch !== baseRef) return `from the branch ${run.head_branch}, not ${baseRef}`;
|
|
244
|
+
if (!onBase(run.head_sha)) return `from a commit that is not on ${baseRef}`;
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The check runs that may count for this pull request, and the ones refused.
|
|
249
|
+
function pinnedRuns(gh, repo, { pr, sha, baseRef, checks }) {
|
|
250
|
+
const homes = homesAt(gh, repo, baseRef, checks);
|
|
251
|
+
const suites = new Map();
|
|
252
|
+
const runOf = (id) => {
|
|
253
|
+
if (!suites.has(id)) suites.set(id, id ? (gh('GET', `repos/${repo}/actions/runs?check_suite_id=${id}`)?.workflow_runs ?? [])[0] ?? null : null);
|
|
254
|
+
return suites.get(id);
|
|
255
|
+
};
|
|
256
|
+
const kept = [];
|
|
257
|
+
const refused = [];
|
|
258
|
+
for (const check of checkRuns(gh, repo, sha)) {
|
|
259
|
+
if (!checks.includes(check.name) || check.name === GATE_CHECK || check.app?.id !== ACTIONS_APP_ID) continue;
|
|
260
|
+
const why = refusal(runOf(check.check_suite?.id), { name: check.name, pr, sha, baseRef, homes });
|
|
261
|
+
if (why) refused.push({ name: check.name, why }); else kept.push(check);
|
|
262
|
+
}
|
|
263
|
+
if (checks.includes(GATE_CHECK)) {
|
|
264
|
+
const onBase = (commit) => ['ahead', 'identical'].includes(gh('GET', `repos/${repo}/compare/${commit}...${encodeURIComponent(baseRef)}`)?.status);
|
|
265
|
+
const runs = gh('GET', `repos/${repo}/actions/workflows/${GATE_WORKFLOW.split('/').pop()}/runs?event=pull_request_target&per_page=100`)?.workflow_runs ?? [];
|
|
266
|
+
for (const run of runs.filter((each) => each.display_title === gateRunTitle(pr, sha))) {
|
|
267
|
+
const why = gateRefusal(run, { pr, sha, baseRef, onBase });
|
|
268
|
+
if (why) { refused.push({ name: GATE_CHECK, why }); continue; }
|
|
269
|
+
for (const job of gh('GET', `repos/${repo}/actions/runs/${run.id}/jobs`)?.jobs ?? []) {
|
|
270
|
+
if (job.name === GATE_CHECK) kept.push({ ...job, app: { id: ACTIONS_APP_ID } });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { kept, refused };
|
|
275
|
+
}
|
|
276
|
+
|
|
198
277
|
// `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
|
|
199
278
|
// every required check is green on its head commit, 1 when not.
|
|
200
279
|
export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
@@ -204,9 +283,12 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
|
204
283
|
if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
|
|
205
284
|
const sha = pull.head.sha;
|
|
206
285
|
const checks = checksAt(gh, repo, pull.base.ref);
|
|
207
|
-
const
|
|
286
|
+
const { kept, refused } = pinnedRuns(gh, repo, { pr, sha, baseRef: pull.base.ref, checks });
|
|
287
|
+
const rows = checkStates(checks, kept);
|
|
208
288
|
line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
|
|
209
289
|
for (const row of rows) line(checkLine(row));
|
|
290
|
+
// A refused run names its check and the pin it broke, never a text of the run.
|
|
291
|
+
for (const one of refused) line(` - ignored a run of ${one.name}: ${one.why}`);
|
|
210
292
|
// Checks that ran on an old base prove nothing about the merge: two pull
|
|
211
293
|
// requests, each green alone, can turn the main branch red together (atlas#188).
|
|
212
294
|
const behind = Number(gh('GET', `repos/${repo}/compare/${encodeURIComponent(pull.base.ref)}...${sha}`)?.behind_by ?? NaN);
|
|
@@ -303,11 +385,37 @@ function commitAudit({ repo, commit }, line, gh) {
|
|
|
303
385
|
return 0;
|
|
304
386
|
}
|
|
305
387
|
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
388
|
+
// The required checks that are red on the base commit, each with the newest
|
|
389
|
+
// first-parent commit before it where that check was green (done line 7). The
|
|
390
|
+
// gate job reads them with its read-only token. It walks back at most
|
|
391
|
+
// `depth` commits; a check with no green commit in reach is left out.
|
|
392
|
+
export function redMain({ root, base }, gh, repo, depth = 30) {
|
|
393
|
+
const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
394
|
+
const sha = git(['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`]);
|
|
395
|
+
let checks;
|
|
396
|
+
try { checks = factsOf(git(['show', `${sha}:atlas.yaml`])).checks.filter((name) => name !== GATE_CHECK); } catch { return []; }
|
|
397
|
+
const red = checkStates(checks, checkRuns(gh, repo, sha)).filter((row) => row.state === 'red').map((row) => row.name);
|
|
398
|
+
if (!red.length) return [];
|
|
399
|
+
const older = git(['rev-list', '--first-parent', `--max-count=${depth}`, `${sha}^`]).split('\n').filter(Boolean);
|
|
400
|
+
const found = [];
|
|
401
|
+
let left = red;
|
|
402
|
+
for (const commit of older) {
|
|
403
|
+
if (!left.length) break;
|
|
404
|
+
const states = checkStates(left, checkRuns(gh, repo, commit));
|
|
405
|
+
for (const row of states.filter((each) => each.state === 'green')) found.push({ check: row.name, green: commit });
|
|
406
|
+
left = left.filter((name) => !found.some((each) => each.check === name));
|
|
407
|
+
}
|
|
408
|
+
return found;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
|
|
412
|
+
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, days, branch, home = homedir() }, line, gh = ghApi) {
|
|
413
|
+
if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
|
|
414
|
+
// With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
|
|
415
|
+
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
416
|
+
if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line, named ? () => redMain({ root, base }, gh, repo) : null);
|
|
417
|
+
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
418
|
+
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
|
311
419
|
if (what === 'ready') {
|
|
312
420
|
const answer = {};
|
|
313
421
|
let code = 1;
|
package/door/lib/owner-only.mjs
CHANGED
|
@@ -496,8 +496,11 @@ const newest = (paths) => paths.reduce((best, path) => (best === null || newer(p
|
|
|
496
496
|
|
|
497
497
|
// ------------------------------------------------------------------ classify
|
|
498
498
|
|
|
499
|
-
|
|
500
|
-
|
|
499
|
+
// `rules` names the commit whose list holds; the default is the base. The
|
|
500
|
+
// gate replay passes today's main branch, to ask what today's list would do.
|
|
501
|
+
export function classify({ root, base, head, rules: rulesRef = null, classes = loadClasses() }) {
|
|
502
|
+
const { rules, product, tree } = rulesAt({ root, ref: rulesRef ?? base, classes });
|
|
503
|
+
const baseTree = rulesRef ? treeAt(root, base) : tree;
|
|
501
504
|
const headTree = treeAt(root, head);
|
|
502
505
|
const changes = changedPaths(root, base, head);
|
|
503
506
|
const rows = [];
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -111,7 +111,7 @@ set. A test keeps the two lists equal.
|
|
|
111
111
|
| `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 |
|
|
112
112
|
| `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
|
|
113
113
|
| `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 |
|
|
114
|
-
| `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. On a plan with no rulesets it reports advisory mode. `ready` before each merge. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
114
|
+
| `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. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
115
115
|
| `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 |
|
|
116
116
|
|
|
117
117
|
A person merges every file that `atlas tooling` writes.
|