@arjunkhera/atlas 0.4.53 → 0.4.55
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/.mcp.json +1 -1
- package/door/cli.mjs +65 -57
- package/door/lib/approve.mjs +115 -5
- package/door/lib/gate-audit.mjs +1 -0
- package/door/lib/gate-check.mjs +42 -2
- package/door/lib/gate.mjs +30 -2
- package/door/lib/install.mjs +50 -26
- package/door/lib/refresh-judge.mjs +132 -0
- package/door/lib/refresh.mjs +320 -0
- package/door/lib/signed-act.mjs +9 -1
- package/door/lib/window.mjs +271 -0
- package/owner-only.yaml +1 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +15 -7
- package/work/mcp.mjs +33 -4
- package/work/start.mjs +63 -0
package/.mcp.json
CHANGED
package/door/cli.mjs
CHANGED
|
@@ -15,35 +15,17 @@ import { resolve, join, dirname, basename } from 'node:path';
|
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
|
-
import { open } from './lib/pull-request.mjs';
|
|
19
|
-
import { write as writeTooling, changes as toolingChanges, ToolingRefusal, CHECK_PATH, WORKFLOW_PATH } from './lib/tooling.mjs';
|
|
20
|
-
import { statusMany, copies } from './lib/status.mjs';
|
|
21
18
|
import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
|
|
22
19
|
import { packagePackageVersion } from './lib/releases.mjs';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import { proofCommand as testsProof } from './lib/proof.mjs';
|
|
33
|
-
import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunCommand as testsRerun } from '../tests/verdict.mjs';
|
|
34
|
-
import { runKept } from '../tests/kept.mjs';
|
|
35
|
-
import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
|
|
36
|
-
import { issuesCommand as testsIssues } from './lib/issues.mjs';
|
|
37
|
-
import { jobsCommand } from './lib/jobs.mjs';
|
|
38
|
-
import { runnerCommand } from './lib/runner.mjs';
|
|
39
|
-
import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
40
|
-
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
41
|
-
import { afterCommand as testsAfter } from './lib/passes-after.mjs';
|
|
42
|
-
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
43
|
-
import { gateCommand, mergeList } from './lib/gate.mjs';
|
|
44
|
-
import { judgeCommand } from './lib/judge.mjs';
|
|
45
|
-
import { approveCommand } from './lib/approve.mjs';
|
|
46
|
-
|
|
20
|
+
// Each command loads its own code when it runs, with a plain `import()`. So
|
|
21
|
+
// `atlas gate check` loads only the gate's code, and a file that no gate
|
|
22
|
+
// command imports cannot change the gate (the signing window, item 4af66381,
|
|
23
|
+
// rule 1: the gate's import closure is its own code).
|
|
24
|
+
|
|
25
|
+
// The commands of the Mac setup, loaded when one runs.
|
|
26
|
+
const install = async (...args) => (await import('./lib/install.mjs')).install(...args);
|
|
27
|
+
const upgrade = async (...args) => (await import('./lib/install.mjs')).upgrade(...args);
|
|
28
|
+
const doctor = async (...args) => (await import('./lib/install.mjs')).doctor(...args);
|
|
47
29
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
30
|
const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
|
|
49
31
|
|
|
@@ -182,6 +164,9 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
182
164
|
--body <file> the pull request text, for the "behaviour change: no" of a refactor
|
|
183
165
|
--json print the answer as JSON
|
|
184
166
|
--summary <file> --output <file> append the text to a job summary, and result= and class= to an output file
|
|
167
|
+
atlas gate hold --repo <owner/name> --commit <sha> the release job's owner hold: exit 3 when a merge since the owner's last seen commit has an owner change that no merge act signed
|
|
168
|
+
--branch <name> the branch; default GitHub's default branch
|
|
169
|
+
--root <repo> the clone with the history (default: this folder)
|
|
185
170
|
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
|
|
186
171
|
--root <repo> the clone to read (default: this folder)
|
|
187
172
|
--branch <name> the branch; default origin/HEAD
|
|
@@ -195,6 +180,9 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
195
180
|
atlas approve act <kind> sign one other act and print its block: brake-clear --repo --commit, ready --repo --item, lock --item --card <file>, decision --item --words <file>, waiver --repo --pr --head --decision --check
|
|
196
181
|
--out <file> write the block here; default the terminal
|
|
197
182
|
atlas approve audit [<pr>...] the owner's acceptance of the night's audit reds, on his Mac: list each open audit issue of an owner merge that no touch signed, then one touch signs an accept act for each, the block goes on each pull request, each audit issue closes, and it prints the one message for the lead
|
|
183
|
+
atlas approve window the signing window, on his Mac in a clone: list each merge since the last seen commit, then one touch marks the tip seen and opens a window of up to 12 hours; the block goes on the atlas-window issue
|
|
184
|
+
--hours <n> the length of the window, 0 to 12; default 12. With 0 the touch only marks the list seen
|
|
185
|
+
--list show the list and sign nothing
|
|
198
186
|
atlas approve verify --file <file> check each signed block of a file against atlas/owner-signers; exit 0 when one counts
|
|
199
187
|
--signers <file> the key file; default atlas/owner-signers under --root
|
|
200
188
|
atlas approve key-check one touch signs a test text, and the key's own public half checks it: does the key count for the gate
|
|
@@ -241,7 +229,7 @@ export const FLAGS = Object.freeze({
|
|
|
241
229
|
runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', comment: 'value', notice: 'switch', json: 'switch' },
|
|
242
230
|
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
|
|
243
231
|
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', from: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value', errors: 'switch', forced: 'switch', findings: 'value' },
|
|
244
|
-
approve: { repo: 'value', root: 'optional', key: 'value', provider: 'value', yes: 'switch', out: 'value', file: 'value', signers: 'value', commit: 'value', item: 'value', card: 'value', words: 'value', pr: 'value', head: 'value', decision: 'value', check: 'value' },
|
|
232
|
+
approve: { repo: 'value', root: 'optional', key: 'value', provider: 'value', yes: 'switch', out: 'value', file: 'value', signers: 'value', commit: 'value', item: 'value', card: 'value', words: 'value', pr: 'value', head: 'value', decision: 'value', check: 'value', hours: 'value', list: 'switch' },
|
|
245
233
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
246
234
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
247
235
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -287,7 +275,9 @@ let readerClosed = false;
|
|
|
287
275
|
const line = (text = '') => { if (!readerClosed) process.stdout.write(`${text}\n`); };
|
|
288
276
|
process.stdout.on('error', (error) => { if (error.code === 'EPIPE') { readerClosed = true; return; } throw error; });
|
|
289
277
|
|
|
290
|
-
function toolingCommand(chosen) {
|
|
278
|
+
async function toolingCommand(chosen) {
|
|
279
|
+
const { open } = await import('./lib/pull-request.mjs');
|
|
280
|
+
const { write: writeTooling, changes: toolingChanges, ToolingRefusal } = await import('./lib/tooling.mjs');
|
|
291
281
|
const root = rootOf(chosen);
|
|
292
282
|
const what = chosen._[1] ?? 'write';
|
|
293
283
|
const replaceUnknown = Boolean(chosen['replace-unknown']);
|
|
@@ -320,9 +310,11 @@ function toolingCommand(chosen) {
|
|
|
320
310
|
}
|
|
321
311
|
}
|
|
322
312
|
|
|
323
|
-
function statusCommand(chosen) {
|
|
313
|
+
async function statusCommand(chosen) {
|
|
314
|
+
const { statusMany } = await import('./lib/status.mjs');
|
|
315
|
+
const { mergeList } = await import('./lib/gate.mjs');
|
|
324
316
|
const roots = chosen._.slice(1).length ? chosen._.slice(1).map((one) => resolve(one)) : [rootOf(chosen)];
|
|
325
|
-
if (chosen.all || chosen.json) return copiesCommand(roots, { all: Boolean(chosen.all), json: Boolean(chosen.json) });
|
|
317
|
+
if (chosen.all || chosen.json) return await copiesCommand(roots, { all: Boolean(chosen.all), json: Boolean(chosen.json) });
|
|
326
318
|
const rows = statusMany(roots, PACKAGE_ROOT);
|
|
327
319
|
for (const row of rows) {
|
|
328
320
|
line(`${row.state.toUpperCase().padEnd(8)} ${row.root}`);
|
|
@@ -339,7 +331,8 @@ function statusCommand(chosen) {
|
|
|
339
331
|
|
|
340
332
|
// One row for each Atlas copy (roadmap item R19). Exit 1 when a copy is
|
|
341
333
|
// behind or broken, so a watcher can act on the exit alone.
|
|
342
|
-
function copiesCommand(roots, { all, json }) {
|
|
334
|
+
async function copiesCommand(roots, { all, json }) {
|
|
335
|
+
const { copies } = await import('./lib/status.mjs');
|
|
343
336
|
const repos = roots.map((root) => copies({ root, packageRoot: PACKAGE_ROOT, all }));
|
|
344
337
|
const decided = `Decided by the release lists in Atlas ${ATLAS_VERSION}, never by git history or the network.`;
|
|
345
338
|
if (json) {
|
|
@@ -360,7 +353,9 @@ function copiesCommand(roots, { all, json }) {
|
|
|
360
353
|
return repos.some((repo) => repo.rows.some((one) => bad.includes(one.state))) ? 1 : 0;
|
|
361
354
|
}
|
|
362
355
|
|
|
363
|
-
function steCommand(chosen) {
|
|
356
|
+
async function steCommand(chosen) {
|
|
357
|
+
const { checkPaths, LIMITS } = await import('./lib/ste.mjs');
|
|
358
|
+
const { loadPrivateTerms } = await import('./lib/privacy.mjs');
|
|
364
359
|
const paths = chosen._.slice(1);
|
|
365
360
|
if (!paths.length) throw new Error('give a file or a folder: atlas ste <path> [--share]');
|
|
366
361
|
const share = Boolean(chosen.share);
|
|
@@ -377,7 +372,10 @@ function steCommand(chosen) {
|
|
|
377
372
|
return count ? 1 : 0;
|
|
378
373
|
}
|
|
379
374
|
|
|
380
|
-
function designCommand(chosen) {
|
|
375
|
+
async function designCommand(chosen) {
|
|
376
|
+
const { checkDesignFolder, designFolderOf, repoRootFrom } = await import('./lib/design.mjs');
|
|
377
|
+
const { writeDesignPage, readTracker } = await import('./lib/design-build.mjs');
|
|
378
|
+
const { listDesigns, freezeDesign } = await import('./lib/design-freeze.mjs');
|
|
381
379
|
const what = chosen._[1];
|
|
382
380
|
const folders = chosen._.slice(2);
|
|
383
381
|
if (what === 'folder') {
|
|
@@ -443,7 +441,9 @@ function designCommand(chosen) {
|
|
|
443
441
|
return count ? 1 : 0;
|
|
444
442
|
}
|
|
445
443
|
|
|
446
|
-
function pageCommand(chosen) {
|
|
444
|
+
async function pageCommand(chosen) {
|
|
445
|
+
const { writePage } = await import('./lib/page.mjs');
|
|
446
|
+
const { checkPaths } = await import('./lib/ste.mjs');
|
|
447
447
|
const sources = chosen._.slice(2);
|
|
448
448
|
if (chosen._[1] !== 'build') throw new Error('use atlas page build <file or folder>');
|
|
449
449
|
if (sources.length !== 1) throw new Error('give one markdown file or one folder: atlas page build <file or folder>');
|
|
@@ -463,7 +463,8 @@ function pageCommand(chosen) {
|
|
|
463
463
|
return findings.length ? 1 : 0;
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
function viewCommand(chosen) {
|
|
466
|
+
async function viewCommand(chosen) {
|
|
467
|
+
const { writeViewPage, viewCapabilities } = await import('./lib/view.mjs');
|
|
467
468
|
if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
|
|
468
469
|
const product = chosen.product;
|
|
469
470
|
const out = resolve(chosen.out ?? join('docs', 'artifacts', `${product}-view.html`));
|
|
@@ -477,49 +478,56 @@ async function testsCommand(chosen) {
|
|
|
477
478
|
const root = rootOf(chosen);
|
|
478
479
|
const what = chosen._[1];
|
|
479
480
|
if (what === 'check') {
|
|
480
|
-
return
|
|
481
|
+
return (await import('./lib/tests.mjs')).checkCommand({ halves: chosen.halves, evidence: chosen.evidence, tests: chosen.tests, guardsFrom: chosen['guards-from'], run: chosen.run }, root, line, PACKAGE_ROOT);
|
|
481
482
|
}
|
|
482
|
-
if (what === 'write') return
|
|
483
|
-
if (what === 'proof') return
|
|
484
|
-
if (what === 'verdict') return
|
|
485
|
-
if (what === 'changed') return
|
|
486
|
-
if (what === 'after') return
|
|
487
|
-
if (what === 'before') return
|
|
488
|
-
if (what === 'named') return
|
|
489
|
-
if (what === 'sweep') return
|
|
490
|
-
if (what === 'covers') return
|
|
491
|
-
if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
492
|
-
if (what === 'rerun') return
|
|
493
|
-
if (what === 'issues') return
|
|
483
|
+
if (what === 'write') return (await import('./lib/tests.mjs')).writeCommand({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
|
|
484
|
+
if (what === 'proof') return (await import('./lib/proof.mjs')).proofCommand({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
485
|
+
if (what === 'verdict') return (await import('../tests/verdict.mjs')).verdictCommand({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
|
|
486
|
+
if (what === 'changed') return (await import('./lib/changed-tests.mjs')).changedCommand({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
487
|
+
if (what === 'after') return (await import('./lib/passes-after.mjs')).afterCommand({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
488
|
+
if (what === 'before') return (await import('./lib/fails-before.mjs')).beforeCommand({ 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);
|
|
489
|
+
if (what === 'named') return (await import('../tests/verdict.mjs')).namedCommand({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
490
|
+
if (what === 'sweep') return (await import('../tests/sweep.mjs')).sweepCommand({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
491
|
+
if (what === 'covers') return (await import('../tests/sweep.mjs')).coversCommand({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
|
|
492
|
+
if (what === 'kept') return (await (await import('../tests/kept.mjs')).runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
493
|
+
if (what === 'rerun') return (await import('../tests/verdict.mjs')).rerunCommand({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
|
|
494
|
+
if (what === 'issues') return (await import('./lib/issues.mjs')).issuesCommand({ 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);
|
|
494
495
|
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, after, sweep, covers, kept, issues or rerun.`);
|
|
495
496
|
}
|
|
496
497
|
|
|
497
|
-
function jobsVerb(chosen) {
|
|
498
|
+
async function jobsVerb(chosen) {
|
|
499
|
+
const { jobsCommand } = await import('./lib/jobs.mjs');
|
|
498
500
|
return jobsCommand({ what: chosen._[1], root: rootOf(chosen), repo: chosen.repo, branch: chosen.branch, out: chosen.out, findings: chosen.findings, max: chosen.max, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
499
501
|
}
|
|
500
502
|
|
|
501
|
-
function runnerVerb(chosen) {
|
|
503
|
+
async function runnerVerb(chosen) {
|
|
504
|
+
const { runnerCommand } = await import('./lib/runner.mjs');
|
|
502
505
|
return runnerCommand({ what: chosen._[1], repo: chosen.repo, issue: chosen.issue, holder: chosen.holder, from: chosen.from, comment: chosen.comment, notice: Boolean(chosen.notice), json: Boolean(chosen.json), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
503
506
|
}
|
|
504
507
|
|
|
505
|
-
function upkeepVerb(chosen) {
|
|
508
|
+
async function upkeepVerb(chosen) {
|
|
509
|
+
const { upkeepCommand } = await import('./lib/upkeep.mjs');
|
|
506
510
|
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last, base: chosen.base }, rootOf(chosen), line);
|
|
507
511
|
}
|
|
508
512
|
|
|
509
|
-
function gateVerb(chosen) {
|
|
513
|
+
async function gateVerb(chosen) {
|
|
514
|
+
const { gateCommand } = await import('./lib/gate.mjs');
|
|
510
515
|
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, from: chosen.from, base: chosen.base, baseRef: chosen['base-ref'], head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch, errors: Boolean(chosen.errors), forced: Boolean(chosen.forced), findings: chosen.findings }, line);
|
|
511
516
|
}
|
|
512
517
|
|
|
513
|
-
function approveVerb(chosen) {
|
|
518
|
+
async function approveVerb(chosen) {
|
|
519
|
+
const { approveCommand } = await import('./lib/approve.mjs');
|
|
514
520
|
const flags = { repo: chosen.repo, commit: chosen.commit, item: chosen.item, card: chosen.card, words: chosen.words, pr: chosen.pr, head: chosen.head, decision: chosen.decision, check: chosen.check };
|
|
515
|
-
return approveCommand({ words: chosen._.slice(1), repo: chosen.repo, root: chosen.root === undefined ? undefined : rootOf(chosen), key: chosen.key, provider: chosen.provider, yes: Boolean(chosen.yes), out: chosen.out, file: chosen.file, signers: chosen.signers, flags }, line);
|
|
521
|
+
return approveCommand({ words: chosen._.slice(1), repo: chosen.repo, root: chosen.root === undefined ? undefined : rootOf(chosen), key: chosen.key, provider: chosen.provider, yes: Boolean(chosen.yes), out: chosen.out, file: chosen.file, signers: chosen.signers, flags, hours: chosen.hours, list: Boolean(chosen.list) }, line);
|
|
516
522
|
}
|
|
517
523
|
|
|
518
|
-
function judgeVerb(chosen) {
|
|
524
|
+
async function judgeVerb(chosen) {
|
|
525
|
+
const { judgeCommand } = await import('./lib/judge.mjs');
|
|
519
526
|
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);
|
|
520
527
|
}
|
|
521
528
|
|
|
522
|
-
function checkCommand(chosen) {
|
|
529
|
+
async function checkCommand(chosen) {
|
|
530
|
+
const { mergeList } = await import('./lib/gate.mjs');
|
|
523
531
|
const root = rootOf(chosen);
|
|
524
532
|
const report = scan({ root, repoId: chosen.repo ?? null });
|
|
525
533
|
const decision = verdict(report, chosen['fail-on'] ?? 'error');
|
package/door/lib/approve.mjs
CHANGED
|
@@ -28,14 +28,15 @@
|
|
|
28
28
|
// a hardware key needs an ssh-keygen that reads it. No signing call runs in a
|
|
29
29
|
// test: each one takes `deps`, which a test replaces.
|
|
30
30
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
31
|
-
import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
31
|
+
import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs';
|
|
32
32
|
import { homedir, tmpdir, platform } from 'node:os';
|
|
33
33
|
import { join, resolve } from 'node:path';
|
|
34
34
|
import { createInterface } from 'node:readline/promises';
|
|
35
35
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
36
36
|
import { parseYaml } from '../../shape/check.mjs';
|
|
37
|
-
import { ghApi, pinnedRuns, commitAudit } from './gate.mjs';
|
|
38
|
-
import { GATE_CHECK } from './gate-check.mjs';
|
|
37
|
+
import { ghApi, pinnedRuns, commitAudit, prComments, windowComments } from './gate.mjs';
|
|
38
|
+
import { GATE_CHECK, judge } from './gate-check.mjs';
|
|
39
|
+
import { WINDOW_HOURS, WINDOW_LABEL, WINDOW_TITLE, windowActs, seenAt, holdsCommit } from './window.mjs';
|
|
39
40
|
import {
|
|
40
41
|
ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, AUDIT_CODE, AUDIT_CODE_TEST, AUDIT_WORKFLOW, auditCodeDiff, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
|
|
41
42
|
preKeyKeys,
|
|
@@ -610,12 +611,120 @@ export async function approveAuditCode({ prs = [], repo, root, key, provider, ye
|
|
|
610
611
|
return 0;
|
|
611
612
|
}
|
|
612
613
|
|
|
614
|
+
// ------------------------------------------------------------ atlas approve window
|
|
615
|
+
|
|
616
|
+
// `atlas approve window` (item 4af66381; design docs/design-docs/signing-window/).
|
|
617
|
+
// It runs on the owner's Mac, in a clone of the repo. It builds the morning
|
|
618
|
+
// list from git and the signed acts: each first-parent merge of the default
|
|
619
|
+
// branch after the newest seen commit, judged again with the code of this
|
|
620
|
+
// Mac's Atlas. It reads no issue for the list (rule 3). One touch then signs
|
|
621
|
+
// one window act that names the tip of the list as seen and an end time at
|
|
622
|
+
// most WINDOW_HOURS on (rule 2). With --hours 0 the touch only marks the list
|
|
623
|
+
// seen. With --list it signs nothing. Exit 0 when the act is posted.
|
|
624
|
+
export const LIST_MAX = 200;
|
|
625
|
+
export function windowRow(deps, { repo, root, sha }) {
|
|
626
|
+
const out = { sha };
|
|
627
|
+
let pull;
|
|
628
|
+
try { pull = (deps.gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each.merged_at && each.merge_commit_sha === sha); } catch { return { ...out, state: 'UNREAD', why: 'GitHub did not say which pull request merged it' }; }
|
|
629
|
+
if (!pull) return { ...out, state: 'PUSH', why: 'it reached the branch with no pull request: a push past the gate' };
|
|
630
|
+
Object.assign(out, { pr: Number(pull.number), title: plainText(pull.title).slice(0, 100), merged_at: String(pull.merged_at) });
|
|
631
|
+
let parent;
|
|
632
|
+
try { parent = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${sha}^1`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return { ...out, state: 'UNREAD', why: 'its parent is not in this clone' }; }
|
|
633
|
+
let texts = null;
|
|
634
|
+
try { texts = [...prComments(deps.gh, repo, pull.number), ...windowComments(deps.gh, repo)]; } catch { texts = null; }
|
|
635
|
+
let answer;
|
|
636
|
+
try { answer = judge({ root, base: parent, head: pull.head?.sha, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } }); } catch (error) { return { ...out, state: 'UNREAD', why: plainText(error?.message ?? error).slice(0, 160) }; }
|
|
637
|
+
const shown = (kind) => answer.rows.filter((row) => row.kind === kind).slice(0, 4).map((row) => plainText(`${row.class}${row.path ? ` ${row.path}` : ''}`).slice(0, 160));
|
|
638
|
+
if (answer.result === 'red') return { ...out, state: 'RED', rows: [...shown('red'), ...shown('owner')] };
|
|
639
|
+
if (answer.result === 'owner') return { ...out, state: 'OWNER, NO ACT', rows: shown('owner') };
|
|
640
|
+
if (answer.signed?.window) return { ...out, state: 'WINDOW', rows: shown('signed') };
|
|
641
|
+
if (answer.signed) return { ...out, state: 'SIGNED', rows: shown('signed') };
|
|
642
|
+
return { ...out, state: 'GREEN', rows: [] };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function approveWindow({ repo, root, key, provider, yes = false, hours, list = false }, line, deps = DEPS) {
|
|
646
|
+
const name = repoOf({ repo, root });
|
|
647
|
+
const length = hours === undefined || hours === true ? WINDOW_HOURS : Number(hours);
|
|
648
|
+
if (!Number.isInteger(length) || length < 0 || length > WINDOW_HOURS) throw new ApproveError(`--hours must be a whole number from 0 to ${WINDOW_HOURS}.`);
|
|
649
|
+
const clone = resolve(root ?? '.');
|
|
650
|
+
const git = (args) => execFileSync('git', ['-C', clone, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
651
|
+
let origin = '';
|
|
652
|
+
try { origin = git(['remote', 'get-url', 'origin']); } catch { origin = ''; }
|
|
653
|
+
if (!new RegExp(`^(https://|ssh://git@|git@)github\\.com[:/]${name.replace(/[.]/g, '\\.')}(\\.git)?$`, 'i').test(origin)) throw new ApproveError(`${clone} is not a clone of ${name}; run the command in the clone.`);
|
|
654
|
+
const branch = String(deps.gh('GET', `repos/${name}`)?.default_branch ?? '');
|
|
655
|
+
if (!branch) throw new ApproveError(`GitHub gave no default branch of ${name}.`);
|
|
656
|
+
(deps.fetch ?? ((one) => git(['fetch', '--quiet', 'origin', one])))(branch);
|
|
657
|
+
const tip = git(['rev-parse', '--verify', `refs/remotes/origin/${branch}^{commit}`]);
|
|
658
|
+
const { keys } = signersOn(deps, name, branch);
|
|
659
|
+
const acts = windowActs(verifyTexts(windowComments(deps.gh, name), keys), { repo: name, branch });
|
|
660
|
+
const seen = seenAt(acts, (one) => holdsCommit(clone, one, tip));
|
|
661
|
+
let start = seen?.act.seen ?? null;
|
|
662
|
+
if (!start) {
|
|
663
|
+
const added = git(['log', '--first-parent', '--diff-filter=A', '--format=%H', tip, '--', 'packages/atlas/door/lib/window.mjs']).split('\n').filter(Boolean);
|
|
664
|
+
start = added.at(-1) ?? git(['rev-list', '--first-parent', '--max-count=1', '--skip=20', tip]) ?? null;
|
|
665
|
+
}
|
|
666
|
+
const commits = start ? git(['rev-list', '--first-parent', '--reverse', `${start}..${tip}`]).split('\n').filter(Boolean) : [];
|
|
667
|
+
line(`atlas approve window: ${name}, ${branch}. Merges since ${seen ? `your last seen commit ${start.slice(0, 7)} (${seen.signed_at})` : `${start ? start.slice(0, 7) : 'the start'}, with no window act before`}: ${commits.length}.`);
|
|
668
|
+
if (commits.length > LIST_MAX) line(` Only the newest ${LIST_MAX} are judged here; ${commits.length - LIST_MAX} older merge(s) are counted, not listed.`);
|
|
669
|
+
const rows = commits.slice(-LIST_MAX).map((sha) => windowRow(deps, { repo: name, root: clone, sha }));
|
|
670
|
+
const text = [];
|
|
671
|
+
for (const row of rows) {
|
|
672
|
+
text.push(` ${row.pr ? `#${row.pr}` : row.sha.slice(0, 7)}${row.merged_at ? ` merged ${row.merged_at}` : ''} ${row.state} ${row.title ?? ''}`.trimEnd());
|
|
673
|
+
for (const one of row.rows ?? []) text.push(` ${one}`);
|
|
674
|
+
if (row.why) text.push(` ${row.why}`);
|
|
675
|
+
}
|
|
676
|
+
for (const one of text) line(one);
|
|
677
|
+
const counts = ['WINDOW', 'SIGNED', 'GREEN', 'OWNER, NO ACT', 'RED', 'PUSH', 'UNREAD'].map((state) => [state, rows.filter((row) => row.state === state).length]).filter(([, n]) => n);
|
|
678
|
+
line(` ${counts.map(([state, n]) => `${n} ${state}`).join(', ') || 'no merge'}.`);
|
|
679
|
+
const at = deps.now();
|
|
680
|
+
const stamp = new Date(at).toISOString().slice(0, 16).replace(':', '-');
|
|
681
|
+
const folder = deps.listFolder ?? join(homedir(), '.atlas', 'window');
|
|
682
|
+
try {
|
|
683
|
+
mkdirSync(folder, { recursive: true });
|
|
684
|
+
const file = join(folder, `${name.replace('/', '-')}-${stamp}.md`);
|
|
685
|
+
writeFileSync(file, [`# ${name} ${branch}: merges up to ${tip}`, '', `Built ${new Date(at).toISOString()} from git and the signed acts.`, '', '```text', ...text, '```', ''].join('\n'));
|
|
686
|
+
line(`The list is in ${file}`);
|
|
687
|
+
} catch (error) { line(`The list could not be written to ${folder} (${plainText(error?.message ?? error).slice(0, 120)}).`); }
|
|
688
|
+
if (list) { line('Nothing signed: --list only shows the list.'); return 0; }
|
|
689
|
+
const ends = new Date(new Date(at).getTime() + length * 3600 * 1000).toISOString();
|
|
690
|
+
const question = length ? `Mark ${tip.slice(0, 7)} as seen, and open a window for ${name} ${branch} until ${ends}?` : `Mark ${tip.slice(0, 7)} as seen, with no window?`;
|
|
691
|
+
if (!yes && !(await deps.ask(question))) { line('Nothing signed.'); return 1; }
|
|
692
|
+
const setup = (deps.setup ?? signingSetup)({ key, provider });
|
|
693
|
+
const trusted = (deps.trusted ?? trustedKeys)(setup);
|
|
694
|
+
const strange = keys.filter((one) => !trusted.has(one.fingerprint));
|
|
695
|
+
if (strange.length) {
|
|
696
|
+
line(`STOP: ${SIGNERS_FILE} on ${branch} holds a key that is not yours: ${strange.map((one) => one.fingerprint).join(', ')}.`);
|
|
697
|
+
line('Nothing was signed. Remove that key in a pull request, or pin it in ~/.config/atlas/signing.json if it is yours.');
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
700
|
+
const payload = payloadText([{ kind: 'window', repo: name, branch, seen: tip, ends }], at);
|
|
701
|
+
line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
|
|
702
|
+
const signature = deps.sign(payload, setup);
|
|
703
|
+
const proof = verifyBlock({ payload, signature }, keys);
|
|
704
|
+
if (proof.error) { line(`The signature does not count on ${branch}: ${proof.error}`); line('Nothing was posted.'); return 1; }
|
|
705
|
+
let issue = null;
|
|
706
|
+
try {
|
|
707
|
+
const open = deps.gh('GET', `repos/${name}/issues?labels=${WINDOW_LABEL}&state=open&per_page=5`) ?? [];
|
|
708
|
+
issue = (Array.isArray(open) ? open : []).find((one) => !one?.pull_request)?.number ?? null;
|
|
709
|
+
if (!issue) issue = deps.gh('POST', `repos/${name}/issues`, { title: WINDOW_TITLE, labels: [WINDOW_LABEL], body: 'Each comment here holds one window act, signed with `atlas approve window` on the owner\'s Mac. The gate reads them. A deleted comment only ends a window sooner.' })?.number ?? null;
|
|
710
|
+
if (!issue) throw new Error('GitHub gave no issue number');
|
|
711
|
+
const summary = counts.map(([state, n]) => `${n} ${state}`).join(', ') || 'no merge';
|
|
712
|
+
deps.gh('POST', `repos/${name}/issues/${issue}/comments`, { body: `${blockText(payload, signature)}\nThe owner saw the list up to ${tip.slice(0, 7)} (${summary})${length ? ` and opened a window until ${ends}` : ''}, with \`atlas approve window\`.\n` });
|
|
713
|
+
} catch (error) { line(`GitHub refused a write (${plainText(error?.message ?? error).slice(0, 200)}). Nothing was posted; run the command again.`); return 1; }
|
|
714
|
+
line(` posted on issue #${issue} (${WINDOW_LABEL})`);
|
|
715
|
+
// `atlas upgrade` trusts only a push run of the release, so the command
|
|
716
|
+
// starts no run: the next merge into the branch publishes what was held.
|
|
717
|
+
line(` the next merge into ${branch} publishes what the release held`);
|
|
718
|
+
return 0;
|
|
719
|
+
}
|
|
720
|
+
|
|
613
721
|
// The act that `atlas approve act <kind>` builds from its flags.
|
|
614
722
|
export function actFrom(kind, flags, read = (path) => readFileSync(resolve(path), 'utf8')) {
|
|
615
|
-
if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => !['merge', 'accept', 'accept-code'].includes(each)).join(', ')}; a pull request takes atlas approve <pr>.`);
|
|
723
|
+
if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => !['merge', 'accept', 'accept-code', 'window'].includes(each)).join(', ')}; a pull request takes atlas approve <pr>.`);
|
|
616
724
|
if (kind === 'merge') throw new ApproveError('a pull request takes atlas approve <pr>, which reads its gate first.');
|
|
617
725
|
if (kind === 'accept') throw new ApproveError('an audit red takes atlas approve audit, which reads the audit issues first.');
|
|
618
726
|
if (kind === 'accept-code') throw new ApproveError('the audit red of an audit-code merge takes atlas approve audit-code <pr>, which shows its diff first.');
|
|
727
|
+
if (kind === 'window') throw new ApproveError('a signing window takes atlas approve window, which shows the list of the last window first.');
|
|
619
728
|
const need = (flag) => {
|
|
620
729
|
const value = flags[flag];
|
|
621
730
|
if (value === undefined || value === true || value === '') throw new ApproveError(`atlas approve act ${kind} needs --${flag}.`);
|
|
@@ -683,11 +792,12 @@ export async function keyCheck({ key, provider }, line, deps = DEPS) {
|
|
|
683
792
|
|
|
684
793
|
// The verb. `words` are the words after "approve"; `flags` are the fields of
|
|
685
794
|
// an act.
|
|
686
|
-
export async function approveCommand({ words = [], repo, root, key, provider, yes = false, out, file, signers, flags = {} }, line, deps = DEPS) {
|
|
795
|
+
export async function approveCommand({ words = [], repo, root, key, provider, yes = false, out, file, signers, flags = {}, hours, list = false }, line, deps = DEPS) {
|
|
687
796
|
const common = { key, provider, yes };
|
|
688
797
|
try {
|
|
689
798
|
if (words[0] === 'act') return await approveAct({ ...common, kind: words[1], flags, out }, line, deps);
|
|
690
799
|
if (words[0] === 'audit-code') return await approveAuditCode({ ...common, prs: words.slice(1), repo, root }, line, deps);
|
|
800
|
+
if (words[0] === 'window') return await approveWindow({ ...common, repo, root, hours, list }, line, deps);
|
|
691
801
|
if (words[0] === 'audit') return await approveAudit({ ...common, prs: words.slice(1), repo, root }, line, deps);
|
|
692
802
|
if (words[0] === 'verify') return approveVerify({ file, signers, root }, line);
|
|
693
803
|
if (words[0] === 'key-check') return await keyCheck(common, line, deps);
|
package/door/lib/gate-audit.mjs
CHANGED
|
@@ -134,6 +134,7 @@ export const GATE_FILE_GROUPS = Object.freeze([
|
|
|
134
134
|
['packages/atlas/door/lib/gate.mjs', 'door/lib/gate.mjs'],
|
|
135
135
|
['packages/atlas/door/lib/gate-check.mjs', 'door/lib/gate-check.mjs'],
|
|
136
136
|
['packages/atlas/door/lib/owner-only.mjs', 'door/lib/owner-only.mjs'],
|
|
137
|
+
['packages/atlas/door/lib/window.mjs', 'door/lib/window.mjs'],
|
|
137
138
|
['packages/atlas/owner-only.yaml', 'owner-only.yaml'],
|
|
138
139
|
] },
|
|
139
140
|
{ group: 'audit', files: [
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
|
44
44
|
import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
|
|
45
45
|
import { brakeState, undoOf, tripText } from './brake.mjs';
|
|
46
46
|
import { kindsAt, nameKinds, kindRows } from './kinds.mjs';
|
|
47
|
+
import { windowOn, protectedSet, clearable, windowActs, openAt, holdsCommit, WINDOW_KEY } from './window.mjs';
|
|
47
48
|
|
|
48
49
|
export const VERSION = 1;
|
|
49
50
|
export const GATE_CHECK = 'gate';
|
|
@@ -313,7 +314,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
313
314
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
314
315
|
if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
|
|
315
316
|
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules: ruleRef, undo }));
|
|
316
|
-
if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef);
|
|
317
|
+
if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef, now);
|
|
317
318
|
return finish(answer);
|
|
318
319
|
}
|
|
319
320
|
|
|
@@ -324,7 +325,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
324
325
|
// the base after the touch does not carry it (security review S1). A head job
|
|
325
326
|
// named like a check is never cleared: the owner's view of the gate could be
|
|
326
327
|
// that job's forgery (security review S1).
|
|
327
|
-
function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer, ruleRef = baseSha) {
|
|
328
|
+
function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer, ruleRef = baseSha, now = new Date()) {
|
|
328
329
|
if (!answer.rows.some((row) => row.kind === 'owner') || answer.rows.some((row) => row.kind === 'red')) return;
|
|
329
330
|
const note = (cause) => answer.rows.push({ kind: 'note', class: 'signed', cause });
|
|
330
331
|
if (texts === null) { note('the comments of the pull request could not be read, so no signed act counts'); return; }
|
|
@@ -340,6 +341,7 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
|
|
|
340
341
|
carried = carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef, ruleRef }, answer);
|
|
341
342
|
if (carried?.found) found = carried.found;
|
|
342
343
|
}
|
|
344
|
+
if (!found && windowClear(root, baseSha, verified, { repo, baseRef, now }, answer)) return;
|
|
343
345
|
if (!found) {
|
|
344
346
|
for (const row of verified.filter((each) => each.error).slice(0, 3)) note(`a signed act that does not count: ${row.error}`);
|
|
345
347
|
if (carried?.why) note(`a signed act for an older head does not carry: ${carried.why}`);
|
|
@@ -355,6 +357,43 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
|
|
|
355
357
|
else note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
|
|
356
358
|
}
|
|
357
359
|
|
|
360
|
+
// The signing window (item 4af66381; design docs/design-docs/signing-window/).
|
|
361
|
+
// With no merge act for this head, a window act that is open now clears the
|
|
362
|
+
// owner rows, but only when each one is a row of the repo's own list, of a
|
|
363
|
+
// class that the window names, on a path outside the protected set (rule 1).
|
|
364
|
+
// One row that it does not cover keeps the pull request OWNER: a window never
|
|
365
|
+
// clears part of a pull request. The brake, a head job named like a check, a
|
|
366
|
+
// floor path and a sign are never covered. Returns true when it cleared.
|
|
367
|
+
function windowClear(root, baseSha, verified, { repo, baseRef, now }, answer) {
|
|
368
|
+
const note = (cause) => answer.rows.push({ kind: 'note', class: 'window', cause });
|
|
369
|
+
const acts = windowActs(verified, { repo, branch: baseRef });
|
|
370
|
+
if (!acts.length) return false;
|
|
371
|
+
if (!windowOn(root, baseSha)) { note(`a window act counts only where atlas.yaml on the base says ${WINDOW_KEY}: on`); return false; }
|
|
372
|
+
const got = openAt(acts, { now, isSeen: (sha) => holdsCommit(root, sha, baseSha) });
|
|
373
|
+
if (!got.open) { note(`no window counts: ${got.why}`); return false; }
|
|
374
|
+
// A window never clears while the brake is on: only the owner's signed
|
|
375
|
+
// clear ends a trip (rule 2; security review of 31 October, S2).
|
|
376
|
+
if (answer.brake?.on) { note('no window counts: the brake is on'); return false; }
|
|
377
|
+
const guard = protectedSet(root, baseSha);
|
|
378
|
+
if (guard.why) { note(`no window counts: ${guard.why}`); return false; }
|
|
379
|
+
let floorRules;
|
|
380
|
+
try { floorRules = rulesAt({ root, ref: baseSha }).rules.filter((rule) => String(rule.cause).startsWith('floor: ')); } catch (error) { note(`no window counts: the owner-only floor at the base cannot be read (${String(error?.message ?? error).split('\n')[0].slice(0, 120)})`); return false; }
|
|
381
|
+
guard.floor = (path) => onList(floorRules, path) !== null;
|
|
382
|
+
const owner = answer.rows.filter((row) => row.kind === 'owner');
|
|
383
|
+
const kept = owner.filter((row) => !clearable(row, guard));
|
|
384
|
+
const { act, signed_at: signedAt, key, verified: touched } = got.open;
|
|
385
|
+
if (kept.length) {
|
|
386
|
+
const first = kept[0];
|
|
387
|
+
const why = first.path && guard.paths.has(first.path) ? 'a protected file' : `${first.cause}`;
|
|
388
|
+
note(`the window open until ${act.ends} does not cover ${kept.length} owner row(s); the first is ${first.class}${first.path ? ` ${first.path}` : ''}: ${why}`);
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
for (const row of owner) row.kind = 'signed';
|
|
392
|
+
answer.signed = { key, signed_at: signedAt, verified: touched, window: { seen: act.seen, ends: act.ends } };
|
|
393
|
+
note(`the owner's window, signed ${signedAt} with key ${key}, is open until ${act.ends}; each owner row is a row of the repo's list outside the protected set`);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
358
397
|
// At most this many new commits between a signed head and this head.
|
|
359
398
|
export const CARRY_LIMIT = 20;
|
|
360
399
|
// At most this many signed heads are tried, so copies of one block cost little.
|
|
@@ -572,6 +611,7 @@ export function renderText(answer) {
|
|
|
572
611
|
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
573
612
|
else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
|
|
574
613
|
else if (answer.signed?.carried_from) out.push(`SUCCESS: the owner signed head ${oneLine(answer.signed.carried_from).slice(0, 7)}, and this head only merges in the base.`);
|
|
614
|
+
else if (answer.signed?.window) out.push(`SUCCESS: the owner's signing window covers this pull request until ${oneLine(answer.signed.window.ends)}.`);
|
|
575
615
|
else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
|
|
576
616
|
else out.push('SUCCESS: no rule holds this pull request.');
|
|
577
617
|
return out;
|
package/door/lib/gate.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { parseYaml } from '../../shape/check.mjs';
|
|
|
23
23
|
import { checkCommand, replayCommand, judge, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
24
24
|
import { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit.mjs';
|
|
25
25
|
import { MARKER as SIGNED_MARKER, SIGNERS_FILE, AUDIT_CHECK, parseSigners, verifyTexts, findAct, auditCodeDiff } from './signed-act.mjs';
|
|
26
|
+
import { WINDOW_LABEL } from './window.mjs';
|
|
26
27
|
import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
|
|
27
28
|
|
|
28
29
|
export const BRANCH_RULESET = 'atlas gate';
|
|
@@ -601,7 +602,7 @@ export function ownerUnsigned({ root, repo, pull, parent }, gh) {
|
|
|
601
602
|
const has = (sha) => { try { execFileSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
602
603
|
if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
|
|
603
604
|
let texts = null;
|
|
604
|
-
try { texts = prComments(gh, repo, pull.number); } catch { texts = null; }
|
|
605
|
+
try { texts = [...prComments(gh, repo, pull.number), ...windowComments(gh, repo)]; } catch { texts = null; }
|
|
605
606
|
const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
|
|
606
607
|
return answer.result === 'owner' ? 'owner' : null;
|
|
607
608
|
}
|
|
@@ -708,6 +709,28 @@ export function prComments(gh, repo, pr) {
|
|
|
708
709
|
return kept;
|
|
709
710
|
}
|
|
710
711
|
|
|
712
|
+
// The comment texts of the window issues that carry the signed-act marker:
|
|
713
|
+
// where `atlas approve window` posts each window act (item 4af66381). The
|
|
714
|
+
// newest five issues with the label, open or closed, and three pages of
|
|
715
|
+
// comments of each. A read that fails gives no text, so no window counts;
|
|
716
|
+
// a hidden or deleted act only makes the gate and the hold stricter.
|
|
717
|
+
export const WINDOW_ISSUES = 5;
|
|
718
|
+
export function windowComments(gh, repo) {
|
|
719
|
+
const texts = [];
|
|
720
|
+
let issues;
|
|
721
|
+
try { issues = gh('GET', `repos/${repo}/issues?labels=${WINDOW_LABEL}&state=all&sort=created&direction=desc&per_page=${WINDOW_ISSUES}`); } catch { return []; }
|
|
722
|
+
for (const issue of (Array.isArray(issues) ? issues : []).filter((one) => !one?.pull_request).slice(0, WINDOW_ISSUES)) {
|
|
723
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
724
|
+
let rows;
|
|
725
|
+
try { rows = gh('GET', `repos/${repo}/issues/${issue.number}/comments?per_page=100&page=${page}`); } catch { break; }
|
|
726
|
+
if (!Array.isArray(rows)) break;
|
|
727
|
+
for (const row of rows) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
|
|
728
|
+
if (rows.length < 100) break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return texts;
|
|
732
|
+
}
|
|
733
|
+
|
|
711
734
|
// `atlas gate hold --root <clone> --commit <sha> --from <sha>` (item 4af66381,
|
|
712
735
|
// the release hold). The release job runs it from the trusted copy, the
|
|
713
736
|
// version that npm holds as latest, never from the commit that it judges.
|
|
@@ -768,6 +791,11 @@ export function holdRow({ repo, root, sha }, gh, git) {
|
|
|
768
791
|
try { git(['cat-file', '-e', `${head}^{commit}`]); } catch { return `#${pull.number}: its head ${head.slice(0, 7)} is not in this clone`; }
|
|
769
792
|
let texts;
|
|
770
793
|
try { texts = prComments(gh, repo, pull.number); } catch { return `#${pull.number}: GitHub did not give its comments, so no act of the owner can be read`; }
|
|
794
|
+
// The window acts too (S1 of 31 October, hole 1). The judge below counts a
|
|
795
|
+
// window act only when it was open at the merge time, on the rules of the
|
|
796
|
+
// parent. So a window clears in the hold just the merges that it cleared at
|
|
797
|
+
// the gate. An act that GitHub hides or that an agent deletes only holds more.
|
|
798
|
+
texts = [...texts, ...windowComments(gh, repo)];
|
|
771
799
|
let answer;
|
|
772
800
|
try {
|
|
773
801
|
const at = new Date(pull.merged_at);
|
|
@@ -857,7 +885,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
|
|
|
857
885
|
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
858
886
|
if (what === 'check') {
|
|
859
887
|
const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
|
|
860
|
-
return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } } }) : null, named && number ? () => prComments(gh, repo, number) : null);
|
|
888
|
+
return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } } }) : null, named && number ? () => [...prComments(gh, repo, number), ...windowComments(gh, repo)] : null);
|
|
861
889
|
}
|
|
862
890
|
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
863
891
|
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|