@arjunkhera/atlas 0.4.19 → 0.4.21
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 +22 -2
- package/door/lib/approve.mjs +311 -0
- package/door/lib/gate-check.mjs +59 -10
- package/door/lib/gate.mjs +27 -3
- package/door/lib/signed-act.mjs +332 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -0
- package/skills/upkeep/SKILL.md +4 -5
- package/work/lib/activity.mjs +9 -4
- package/work/lib/delivery.mjs +5 -1
- package/work/lib/questions.mjs +5 -19
- package/work/lib/verbs.mjs +13 -19
- package/work/mcp.mjs +1 -4
- package/work/lib/sweep.mjs +0 -189
package/door/cli.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { afterCommand as testsAfter } from './lib/passes-after.mjs';
|
|
|
40
40
|
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
41
41
|
import { gateCommand, mergeList } from './lib/gate.mjs';
|
|
42
42
|
import { judgeCommand } from './lib/judge.mjs';
|
|
43
|
+
import { approveCommand } from './lib/approve.mjs';
|
|
43
44
|
|
|
44
45
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
45
46
|
const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
|
|
@@ -153,6 +154,7 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
153
154
|
atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
|
|
154
155
|
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
|
|
155
156
|
atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
|
|
157
|
+
--pr <n> --base-ref <branch> with --repo: read the comments of the pull request, and let the owner's signed act for this base and head clear owner
|
|
156
158
|
--root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
|
|
157
159
|
--body <file> the pull request text, for the "behaviour change: no" of a refactor
|
|
158
160
|
--json print the answer as JSON
|
|
@@ -162,6 +164,17 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
162
164
|
--branch <name> the branch; default origin/HEAD
|
|
163
165
|
--json print the answer as JSON
|
|
164
166
|
|
|
167
|
+
atlas approve <pr>... the owner's signed act, on his Mac: show the gate's class and cause for each pull request, then one touch signs every one the gate sent to the owner, and the block goes on each as a comment
|
|
168
|
+
--repo <owner/name> the repo; default the repo that atlas.yaml names
|
|
169
|
+
--key <file> the security key; default ~/.ssh/atlas_signing_key, or the key in ~/.config/atlas/signing.json
|
|
170
|
+
--provider <file> the security key provider; default /usr/lib/ssh-keychain.dylib on a Mac (Touch ID)
|
|
171
|
+
--yes sign with no question; the key still needs the touch
|
|
172
|
+
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
|
|
173
|
+
--out <file> write the block here; default the terminal
|
|
174
|
+
atlas approve verify --file <file> check each signed block of a file against atlas/owner-signers; exit 0 when one counts
|
|
175
|
+
--signers <file> the key file; default atlas/owner-signers under --root
|
|
176
|
+
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
|
|
177
|
+
|
|
165
178
|
atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
|
|
166
179
|
atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
|
|
167
180
|
atlas judge run <tasks.md> <id> --out <dir> one fresh agent does one sealed task, in a clean clone at the sealed commit
|
|
@@ -202,7 +215,8 @@ export const FLAGS = Object.freeze({
|
|
|
202
215
|
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' },
|
|
203
216
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
204
217
|
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value' },
|
|
205
|
-
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' },
|
|
218
|
+
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value' },
|
|
219
|
+
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' },
|
|
206
220
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
207
221
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
208
222
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -451,7 +465,12 @@ function upkeepVerb(chosen) {
|
|
|
451
465
|
}
|
|
452
466
|
|
|
453
467
|
function gateVerb(chosen) {
|
|
454
|
-
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);
|
|
468
|
+
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, 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 }, line);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function approveVerb(chosen) {
|
|
472
|
+
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 };
|
|
473
|
+
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);
|
|
455
474
|
}
|
|
456
475
|
|
|
457
476
|
function judgeVerb(chosen) {
|
|
@@ -497,6 +516,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
497
516
|
case 'upkeep': return upkeepVerb(chosen);
|
|
498
517
|
case 'gate': return gateVerb(chosen);
|
|
499
518
|
case 'judge': return judgeVerb(chosen);
|
|
519
|
+
case 'approve': return approveVerb(chosen);
|
|
500
520
|
default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
|
|
501
521
|
}
|
|
502
522
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// `atlas approve`: the owner's signed act (roadmap R72, tracker item 0c68a105;
|
|
2
|
+
// design docs/design-docs/signed-act/). It runs on the owner's Mac. One touch
|
|
3
|
+
// of his security key signs a batch.
|
|
4
|
+
//
|
|
5
|
+
// atlas approve <pr>... show each pull request with the class and
|
|
6
|
+
// the cause from its gate run, sign the ones
|
|
7
|
+
// the gate sent to the owner, and post the
|
|
8
|
+
// signed block on each
|
|
9
|
+
// atlas approve act <kind> ... sign one other act: a brake clear, a ready
|
|
10
|
+
// mark, a lock, a decision or a waiver; print
|
|
11
|
+
// the block
|
|
12
|
+
// atlas approve verify --file f verify the blocks of a file against the
|
|
13
|
+
// key file of a repo
|
|
14
|
+
// atlas approve key-check sign a test text and verify it with the
|
|
15
|
+
// key's own public half: does it need a touch?
|
|
16
|
+
//
|
|
17
|
+
// The signature comes from `ssh-keygen -Y sign` with the owner's security key.
|
|
18
|
+
// A Touch ID key on macOS 26 needs the provider /usr/lib/ssh-keychain.dylib;
|
|
19
|
+
// a hardware key needs an ssh-keygen that reads it. No signing call runs in a
|
|
20
|
+
// test: each one takes `deps`, which a test replaces.
|
|
21
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
22
|
+
import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
23
|
+
import { homedir, tmpdir, platform } from 'node:os';
|
|
24
|
+
import { join, resolve } from 'node:path';
|
|
25
|
+
import { createInterface } from 'node:readline/promises';
|
|
26
|
+
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
27
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
28
|
+
import { ghApi, pinnedRuns } from './gate.mjs';
|
|
29
|
+
import { GATE_CHECK } from './gate-check.mjs';
|
|
30
|
+
import {
|
|
31
|
+
ACTS, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, SignedActError,
|
|
32
|
+
} from './signed-act.mjs';
|
|
33
|
+
|
|
34
|
+
export class ApproveError extends Error {}
|
|
35
|
+
|
|
36
|
+
const ACTIONS_APP_ID = 15368;
|
|
37
|
+
export const TOUCH_ID_PROVIDER = '/usr/lib/ssh-keychain.dylib';
|
|
38
|
+
export const DEFAULT_KEY = join('.ssh', 'atlas_signing_key');
|
|
39
|
+
// The programs and providers that may sign, by full path only, so a program
|
|
40
|
+
// early on PATH or a provider that an agent wrote cannot see the touch
|
|
41
|
+
// (security review S2). A Homebrew path is writable by the user: the
|
|
42
|
+
// remaining risk is in the design, Security.
|
|
43
|
+
export const SSH_KEYGEN = Object.freeze(['/usr/bin/ssh-keygen', '/opt/homebrew/bin/ssh-keygen', '/usr/local/bin/ssh-keygen']);
|
|
44
|
+
export const PROVIDERS = Object.freeze([TOUCH_ID_PROVIDER, 'internal']);
|
|
45
|
+
|
|
46
|
+
// No control character, no C1 character and no bidi mark of a remote string
|
|
47
|
+
// reaches the owner's terminal (security review S1).
|
|
48
|
+
export const plainText = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ');
|
|
49
|
+
|
|
50
|
+
// The key, the provider, the program and the pinned keys: the flags first,
|
|
51
|
+
// then ~/.config/atlas/signing.json, then the defaults. On a Mac with the
|
|
52
|
+
// Touch ID provider, the provider is set. Only a provider and a program from
|
|
53
|
+
// the lists above may sign.
|
|
54
|
+
export function signingSetup({ key, provider, home = homedir(), os = platform(), exists = existsSync } = {}) {
|
|
55
|
+
let saved = {};
|
|
56
|
+
const file = join(home, '.config', 'atlas', 'signing.json');
|
|
57
|
+
if (exists(file)) {
|
|
58
|
+
try { saved = JSON.parse(readFileSync(file, 'utf8')) ?? {}; } catch { throw new ApproveError(`${file} is not JSON.`); }
|
|
59
|
+
}
|
|
60
|
+
const keyPath = typeof key === 'string' ? key : typeof saved.key === 'string' ? saved.key : join(home, DEFAULT_KEY);
|
|
61
|
+
let chosen = typeof provider === 'string' ? provider : typeof saved.provider === 'string' ? saved.provider : null;
|
|
62
|
+
if (chosen === null && os === 'darwin' && exists(TOUCH_ID_PROVIDER)) chosen = TOUCH_ID_PROVIDER;
|
|
63
|
+
if (chosen && !PROVIDERS.includes(chosen)) throw new ApproveError(`the provider ${plainText(chosen)} is not one of ${PROVIDERS.join(', ')}.`);
|
|
64
|
+
const program = typeof saved.ssh_keygen === 'string' ? saved.ssh_keygen : SSH_KEYGEN.find((path) => exists(path)) ?? SSH_KEYGEN[0];
|
|
65
|
+
if (!SSH_KEYGEN.includes(program)) throw new ApproveError(`ssh_keygen ${plainText(program)} is not one of ${SSH_KEYGEN.join(', ')}.`);
|
|
66
|
+
const pinned = Array.isArray(saved.pinned) ? saved.pinned.filter((one) => typeof one === 'string') : [];
|
|
67
|
+
return { key: resolve(keyPath.replace(/^~(?=\/)/, home)), provider: chosen || null, program, pinned };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The fingerprints the owner trusts on this Mac: his own key's public half,
|
|
71
|
+
// and the keys he pinned in signing.json.
|
|
72
|
+
export function trustedKeys(setup, read = (path) => readFileSync(path, 'utf8')) {
|
|
73
|
+
const out = new Set(setup.pinned ?? []);
|
|
74
|
+
try {
|
|
75
|
+
const own = parseSigners(`owner ${read(`${setup.key}.pub`).trim()}`).keys[0];
|
|
76
|
+
if (own) out.add(own.fingerprint);
|
|
77
|
+
} catch { /* no public half here */ }
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One signature of `payload` with the owner's key. ssh-keygen asks for the
|
|
82
|
+
// touch; its prompt goes to this terminal.
|
|
83
|
+
export function sshSign(payload, { key, provider, program = SSH_KEYGEN[0] }) {
|
|
84
|
+
const folder = mkdtempSync(join(tmpdir(), 'atlas-approve-'));
|
|
85
|
+
try {
|
|
86
|
+
const file = join(folder, 'act.txt');
|
|
87
|
+
writeFileSync(file, payload);
|
|
88
|
+
const env = { ...childEnvironment(), ...(provider ? { SSH_SK_PROVIDER: provider } : {}) };
|
|
89
|
+
const run = spawnSync(program, ['-Y', 'sign', '-n', NAMESPACE, '-f', key, file], { env, stdio: ['inherit', 'inherit', 'inherit'] });
|
|
90
|
+
if (run.error) throw new ApproveError(`ssh-keygen cannot run: ${run.error.message}`);
|
|
91
|
+
if (run.status !== 0) throw new ApproveError('ssh-keygen did not sign. Touch the key when it asks, and check the key path.');
|
|
92
|
+
return readFileSync(`${file}.sig`, 'utf8');
|
|
93
|
+
} finally { rmSync(folder, { recursive: true, force: true }); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Raw text from `gh api`, for a job log.
|
|
97
|
+
export function ghText(path) {
|
|
98
|
+
return execFileSync('gh', ['api', path], { encoding: 'utf8', env: childEnvironment(), stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function askYes(question) {
|
|
102
|
+
if (!process.stdin.isTTY) return false;
|
|
103
|
+
const reader = createInterface({ input: process.stdin, output: process.stdout });
|
|
104
|
+
try { return /^y(es)?$/i.test((await reader.question(`${question} [y/N] `)).trim()); } finally { reader.close(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const DEPS = { gh: ghApi, text: ghText, sign: sshSign, ask: askYes, now: () => new Date() };
|
|
108
|
+
|
|
109
|
+
const repoOk = (repo) => typeof repo === 'string' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
110
|
+
|
|
111
|
+
function repoOf({ repo, root }) {
|
|
112
|
+
if (repo !== undefined && repo !== true) {
|
|
113
|
+
if (!repoOk(repo)) throw new ApproveError('--repo must be <owner>/<name>.');
|
|
114
|
+
return repo;
|
|
115
|
+
}
|
|
116
|
+
const file = join(resolve(root ?? '.'), 'atlas.yaml');
|
|
117
|
+
const facts = existsSync(file) ? parseYaml(readFileSync(file, 'utf8')) ?? {} : {};
|
|
118
|
+
if (!repoOk(facts.repo)) throw new ApproveError('name the repo with --repo <owner>/<name>, or run this in a repo whose atlas.yaml names it.');
|
|
119
|
+
return facts.repo;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The lines of a gate job log that say what the gate found. Each log line
|
|
123
|
+
// starts with a time, which goes.
|
|
124
|
+
export function gateLines(log) {
|
|
125
|
+
const lines = String(log ?? '').split('\n').map((line) => line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z ?/, '').replace(/\r$/, ''));
|
|
126
|
+
const rows = lines.filter((line) => /^ {2}(red|owner|signed|note) /.test(line));
|
|
127
|
+
const verdict = lines.find((line) => /^(RED|OWNER|SUCCESS): /.test(line)) ?? null;
|
|
128
|
+
return { rows, verdict };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// What the gate said about one pull request at its head: state, class and
|
|
132
|
+
// cause. The gate run is pinned the way `atlas gate ready` pins it, so a head
|
|
133
|
+
// job named "gate" cannot show the owner a false cause (security review S1).
|
|
134
|
+
// The pull request must go into the default branch, and the act names its
|
|
135
|
+
// merge base.
|
|
136
|
+
export function gateResult(deps, repo, pr) {
|
|
137
|
+
const pull = deps.gh('GET', `repos/${repo}/pulls/${pr}`);
|
|
138
|
+
if (pull?.state !== 'open') return { pr, state: 'closed', why: `#${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}` };
|
|
139
|
+
const head = String(pull.head?.sha ?? '');
|
|
140
|
+
if (!/^[0-9a-f]{40}$/.test(head)) throw new ApproveError(`GitHub gave no head for #${pr}.`);
|
|
141
|
+
const baseRef = String(pull.base?.ref ?? '');
|
|
142
|
+
const base = { pr: Number(pr), head, title: plainText(pull.title).slice(0, 120), base_ref: baseRef };
|
|
143
|
+
const main = String(deps.gh('GET', `repos/${repo}`)?.default_branch ?? '');
|
|
144
|
+
if (!main || baseRef !== main) return { ...base, state: 'other-base', why: `#${pr} goes into ${plainText(baseRef)}, not the default branch ${plainText(main)}: never signed` };
|
|
145
|
+
const mergeBase = String(deps.gh('GET', `repos/${repo}/compare/${encodeURIComponent(baseRef)}...${head}`)?.merge_base_commit?.sha ?? '');
|
|
146
|
+
if (!/^[0-9a-f]{40}$/.test(mergeBase)) throw new ApproveError(`GitHub gave no merge base for #${pr}.`);
|
|
147
|
+
base.merge_base = mergeBase;
|
|
148
|
+
const { kept, refused } = pinnedRuns(deps.gh, repo, { pr: Number(pr), sha: head, baseRef, head: pull.head, checks: [GATE_CHECK] });
|
|
149
|
+
const runs = kept.filter((run) => run.name === GATE_CHECK && run.app?.id === ACTIONS_APP_ID)
|
|
150
|
+
.sort((a, b) => String(b.started_at ?? '').localeCompare(String(a.started_at ?? '')));
|
|
151
|
+
base.ignored = refused.length;
|
|
152
|
+
const run = runs[0];
|
|
153
|
+
if (!run) return { ...base, state: 'missing', why: `the base gate has not run on this head${refused.length ? `; ${refused.length} other run(s) named gate were ignored` : ''}` };
|
|
154
|
+
if (run.status !== 'completed') return { ...base, state: 'pending', why: 'the gate is still running on this head' };
|
|
155
|
+
if (run.conclusion === 'success') return { ...base, state: 'success', why: 'the gate is green: nothing to sign' };
|
|
156
|
+
let found = { rows: [], verdict: null };
|
|
157
|
+
try { found = gateLines(deps.text(`repos/${repo}/actions/jobs/${run.id}/logs`)); } catch { /* the log is gone or not ready */ }
|
|
158
|
+
const rows = found.rows.map((row) => plainText(row).slice(0, 300));
|
|
159
|
+
const verdict = found.verdict ? plainText(found.verdict).slice(0, 300) : null;
|
|
160
|
+
if (!verdict) return { ...base, state: 'unknown', why: 'the gate failed and its log names no result; open the run' };
|
|
161
|
+
if (verdict.startsWith('RED:')) return { ...base, state: 'red', why: verdict, rows };
|
|
162
|
+
if (verdict.startsWith('OWNER:')) {
|
|
163
|
+
if (rows.some((row) => /^ {2}owner {2}gate-checks /.test(row))) return { ...base, state: 'job-name', why: 'the head adds a job named like a check; a signed act never clears that', rows };
|
|
164
|
+
return { ...base, state: 'owner', why: verdict, rows: rows.filter((row) => /^ {2}owner /.test(row)) };
|
|
165
|
+
}
|
|
166
|
+
return { ...base, state: 'unknown', why: verdict };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The owner's signers of a repo, from the key file on one branch.
|
|
170
|
+
export function signersOn(deps, repo, ref) {
|
|
171
|
+
let file = null;
|
|
172
|
+
try { file = deps.gh('GET', `repos/${repo}/contents/${SIGNERS_FILE}?ref=${encodeURIComponent(ref)}`); } catch { file = null; }
|
|
173
|
+
const text = typeof file?.content === 'string' ? Buffer.from(file.content, 'base64').toString('utf8') : '';
|
|
174
|
+
return parseSigners(text);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// `atlas approve <pr>...`. Exit 0 when each signable pull request got its block.
|
|
178
|
+
export async function approvePulls({ prs, repo, root, key, provider, yes = false }, line, deps = DEPS) {
|
|
179
|
+
if (!prs.length) throw new ApproveError('name at least one pull request: atlas approve <pr>...');
|
|
180
|
+
const bad = prs.find((pr) => !/^[1-9][0-9]{0,8}$/.test(String(pr)));
|
|
181
|
+
if (bad !== undefined) throw new ApproveError(`"${bad}" is not a pull request number.`);
|
|
182
|
+
const name = repoOf({ repo, root });
|
|
183
|
+
const results = [...new Set(prs.map(Number))].map((pr) => gateResult(deps, name, pr));
|
|
184
|
+
line(`atlas approve: ${name}, ${results.length} pull request(s). The gate's own words for each:`);
|
|
185
|
+
for (const one of results) {
|
|
186
|
+
line(` #${one.pr}${one.head ? ` at ${one.head.slice(0, 7)}` : ''}${one.base_ref ? ` into ${plainText(one.base_ref)}` : ''} ${one.state.toUpperCase()} ${one.title ?? ''}`.trimEnd());
|
|
187
|
+
line(` ${one.why}`);
|
|
188
|
+
for (const row of (one.rows ?? []).slice(0, 8)) line(` ${row.trim()}`);
|
|
189
|
+
}
|
|
190
|
+
const sign = results.filter((one) => one.state === 'owner');
|
|
191
|
+
if (!sign.length) { line('Nothing to sign: no pull request above waits for the owner.'); return 1; }
|
|
192
|
+
const skipped = results.length - sign.length;
|
|
193
|
+
if (skipped) line(`${skipped} pull request(s) above are not signed: only an OWNER result takes the signed act.`);
|
|
194
|
+
if (!yes && !(await deps.ask(`Sign ${sign.map((one) => `#${one.pr}`).join(', ')} with one touch?`))) { line('Nothing signed.'); return 1; }
|
|
195
|
+
const setup = (deps.setup ?? signingSetup)({ key, provider });
|
|
196
|
+
// Every key on each base must be one the owner trusts on this Mac, so a key
|
|
197
|
+
// that an agent merged shows at the owner's next touch (security review S2).
|
|
198
|
+
const trusted = (deps.trusted ?? trustedKeys)(setup);
|
|
199
|
+
for (const ref of new Set(sign.map((one) => one.base_ref))) {
|
|
200
|
+
const strange = signersOn(deps, name, ref).keys.filter((one) => !trusted.has(one.fingerprint));
|
|
201
|
+
if (strange.length) {
|
|
202
|
+
line(`STOP: ${SIGNERS_FILE} on ${plainText(ref)} holds a key that is not yours: ${strange.map((one) => one.fingerprint).join(', ')}.`);
|
|
203
|
+
line('Nothing was signed. Remove that key in a pull request, or pin it in ~/.config/atlas/signing.json if it is yours.');
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const acts = sign.map((one) => ({ kind: 'merge', repo: name, pr: one.pr, base: one.base_ref, merge_base: one.merge_base, head: one.head }));
|
|
208
|
+
const payload = payloadText(acts, deps.now());
|
|
209
|
+
line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
|
|
210
|
+
const signature = deps.sign(payload, setup);
|
|
211
|
+
const block = { payload, signature };
|
|
212
|
+
// Prove the block against the key file of each base before it is posted.
|
|
213
|
+
for (const ref of new Set(sign.map((one) => one.base_ref))) {
|
|
214
|
+
const { keys } = signersOn(deps, name, ref);
|
|
215
|
+
const proof = verifyBlock(block, keys);
|
|
216
|
+
if (proof.error) { line(`The signature does not count on ${ref}: ${proof.error}`); line(`Nothing was posted. Is your public key in ${SIGNERS_FILE} on ${ref}?`); return 1; }
|
|
217
|
+
line(`The signature counts on ${ref}: key ${proof.key}, touch ${proof.touched ? 'yes' : 'no'}, verified ${proof.verified ? 'yes' : 'no'}.`);
|
|
218
|
+
}
|
|
219
|
+
const text = `${blockText(payload, signature)}\nThe owner signed ${sign.map((one) => `#${one.pr} at ${one.head.slice(0, 7)}`).join(', ')} with \`atlas approve\`. The gate reads this comment and reruns.\n`;
|
|
220
|
+
for (const one of sign) {
|
|
221
|
+
deps.gh('POST', `repos/${name}/issues/${one.pr}/comments`, { body: text });
|
|
222
|
+
line(` posted on #${one.pr}`);
|
|
223
|
+
}
|
|
224
|
+
line('Done. The gate reruns on each comment. Then `atlas gate ready` says whether each one may merge.');
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The act that `atlas approve act <kind>` builds from its flags.
|
|
229
|
+
export function actFrom(kind, flags, read = (path) => readFileSync(resolve(path), 'utf8')) {
|
|
230
|
+
if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => each !== 'merge').join(', ')}; a pull request takes atlas approve <pr>.`);
|
|
231
|
+
if (kind === 'merge') throw new ApproveError('a pull request takes atlas approve <pr>, which reads its gate first.');
|
|
232
|
+
const need = (flag) => {
|
|
233
|
+
const value = flags[flag];
|
|
234
|
+
if (value === undefined || value === true || value === '') throw new ApproveError(`atlas approve act ${kind} needs --${flag}.`);
|
|
235
|
+
return String(value);
|
|
236
|
+
};
|
|
237
|
+
if (kind === 'brake-clear') return { kind, repo: need('repo'), commit: need('commit') };
|
|
238
|
+
if (kind === 'ready') return { kind, repo: need('repo'), item: need('item') };
|
|
239
|
+
if (kind === 'lock') return { kind, item: need('item'), card: fingerprint(read(need('card'))) };
|
|
240
|
+
if (kind === 'decision') return { kind, item: need('item'), words: fingerprint(read(need('words'))) };
|
|
241
|
+
return { kind, repo: need('repo'), pr: Number(need('pr')), head: need('head'), decision: need('decision'), check: need('check') };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// `atlas approve act <kind>`: one act, one touch; the block goes to --out or
|
|
245
|
+
// to the terminal.
|
|
246
|
+
export async function approveAct({ kind, flags, key, provider, yes = false, out }, line, deps = DEPS) {
|
|
247
|
+
const act = actFrom(kind, flags);
|
|
248
|
+
const payload = payloadText([act], deps.now());
|
|
249
|
+
line('atlas approve: this text will be signed:');
|
|
250
|
+
for (const each of payload.trimEnd().split('\n')) line(` ${each}`);
|
|
251
|
+
if (!yes && !(await deps.ask('Sign it with one touch?'))) { line('Nothing signed.'); return 1; }
|
|
252
|
+
const setup = (deps.setup ?? signingSetup)({ key, provider });
|
|
253
|
+
line(`Touch the key now (${setup.key}).`);
|
|
254
|
+
const text = blockText(payload, deps.sign(payload, setup));
|
|
255
|
+
if (out && out !== true) { writeFileSync(resolve(out), text); line(`The signed block is in ${out}.`); } else process.stdout.write(text);
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// `atlas approve verify --file <f>`: the blocks of a file against a key file.
|
|
260
|
+
export function approveVerify({ file, signers, root }, line) {
|
|
261
|
+
if (!file || file === true) throw new ApproveError('atlas approve verify needs --file <block file>.');
|
|
262
|
+
const keyFile = signers && signers !== true ? resolve(signers) : join(resolve(root ?? '.'), SIGNERS_FILE);
|
|
263
|
+
if (!existsSync(keyFile)) throw new ApproveError(`there is no key file at ${keyFile}.`);
|
|
264
|
+
const { keys, refused } = parseSigners(readFileSync(keyFile, 'utf8'));
|
|
265
|
+
for (const one of refused) line(` ${keyFile} line ${one.line} does not count: ${one.why}`);
|
|
266
|
+
const rows = verifyTexts([readFileSync(resolve(file), 'utf8')], keys);
|
|
267
|
+
if (!rows.length) { line('atlas approve verify: the file holds no signed block.'); return 1; }
|
|
268
|
+
for (const row of rows) {
|
|
269
|
+
if (row.error) { line(` ✗ ${row.error}`); continue; }
|
|
270
|
+
line(` ✓ key ${row.key}, signed ${row.signed_at}, touch yes, verified ${row.verified ? 'yes' : 'no'}`);
|
|
271
|
+
for (const act of row.acts) line(` ${Object.entries(act).map(([field, value]) => `${field} ${value}`).join(', ')}`);
|
|
272
|
+
}
|
|
273
|
+
return rows.some((row) => !row.error) ? 0 : 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// `atlas approve key-check`: one touch signs a test text, and the key's own
|
|
277
|
+
// public half verifies it. It says whether the key counts for the gate.
|
|
278
|
+
export async function keyCheck({ key, provider }, line, deps = DEPS) {
|
|
279
|
+
const setup = (deps.setup ?? signingSetup)({ key, provider });
|
|
280
|
+
const publicFile = `${setup.key}.pub`;
|
|
281
|
+
if (!existsSync(publicFile)) throw new ApproveError(`there is no public key at ${publicFile}.`);
|
|
282
|
+
const { keys, refused } = parseSigners(`owner ${readFileSync(publicFile, 'utf8').trim()}`);
|
|
283
|
+
if (!keys.length) { line(`The key does not count: ${refused[0]?.why ?? 'it does not read'}.`); return 1; }
|
|
284
|
+
const payload = payloadText([{ kind: 'ready', repo: 'atlas/key-check', item: ['00000000', '0000', '0000', '0000', '000000000000'].join('-') }], deps.now());
|
|
285
|
+
line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
|
|
286
|
+
const proof = verifyBlock({ payload, signature: deps.sign(payload, setup) }, keys);
|
|
287
|
+
if (proof.error) { line(`The key does not count: ${proof.error}`); return 1; }
|
|
288
|
+
// A Touch ID key sets its flags in software, so the flag alone proves no
|
|
289
|
+
// prompt. Ask the owner (security review S2).
|
|
290
|
+
if (!(await deps.ask('Did Touch ID or the key ask for your touch just now?'))) { line('The key does not count: it signed with no prompt. Make it again with -t bio (Touch ID), or use a hardware key.'); return 1; }
|
|
291
|
+
line(`The key counts: ${proof.key}, touch yes, verified ${proof.verified ? 'yes' : 'no'}.`);
|
|
292
|
+
line(`Its line for ${SIGNERS_FILE}:`);
|
|
293
|
+
line(`owner ${keys[0].type} ${keys[0].blob.toString('base64')}`);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The verb. `words` are the words after "approve"; `flags` are the fields of
|
|
298
|
+
// an act.
|
|
299
|
+
export async function approveCommand({ words = [], repo, root, key, provider, yes = false, out, file, signers, flags = {} }, line, deps = DEPS) {
|
|
300
|
+
const common = { key, provider, yes };
|
|
301
|
+
try {
|
|
302
|
+
if (words[0] === 'act') return await approveAct({ ...common, kind: words[1], flags, out }, line, deps);
|
|
303
|
+
if (words[0] === 'verify') return approveVerify({ file, signers, root }, line);
|
|
304
|
+
if (words[0] === 'key-check') return await keyCheck(common, line, deps);
|
|
305
|
+
return await approvePulls({ ...common, prs: words, repo, root }, line, deps);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error instanceof ApproveError || error instanceof SignedActError) { line(`atlas approve: ${error.message}`); return 2; }
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
// owner a path or a sign of the owner-only list; a head job named like a
|
|
16
16
|
// check; a refactor claim (until the review job, roadmap R28); a
|
|
17
17
|
// file too large to scan; the brake (roadmap R13, brake.mjs)
|
|
18
|
-
// success no rule holds the pull request
|
|
18
|
+
// success no rule holds the pull request, or the owner signed this head
|
|
19
|
+
//
|
|
20
|
+
// An owner result turns into success when a comment of the pull request holds
|
|
21
|
+
// the owner's signed act for this repo, this pull request and this head
|
|
22
|
+
// (roadmap R72, signed-act.mjs). The key comes from atlas/owner-signers on the
|
|
23
|
+
// base, never from the head.
|
|
19
24
|
//
|
|
20
25
|
// Red wins over owner: the pull request must be fixed before the owner signs.
|
|
21
26
|
// Rules that hold everywhere here:
|
|
@@ -29,6 +34,7 @@ import { resolve } from 'node:path';
|
|
|
29
34
|
import { classify, changedPaths, compile, matcher, fakeSecret } from './owner-only.mjs';
|
|
30
35
|
import { listChanged } from './changed-tests.mjs';
|
|
31
36
|
import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
37
|
+
import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
|
|
32
38
|
import { brakeState, undoOf, tripText } from './brake.mjs';
|
|
33
39
|
|
|
34
40
|
export const VERSION = 1;
|
|
@@ -36,7 +42,7 @@ export const GATE_CHECK = 'gate';
|
|
|
36
42
|
export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
|
|
37
43
|
export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
|
|
38
44
|
export const RESULTS = Object.freeze(['success', 'owner', 'red']);
|
|
39
|
-
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake']);
|
|
45
|
+
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed']);
|
|
40
46
|
export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
|
|
41
47
|
// A file over this size is not scanned for secrets, and comes to the owner.
|
|
42
48
|
export const SCAN_LIMIT = 1024 * 1024;
|
|
@@ -221,13 +227,15 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
|
|
|
221
227
|
// are red on the merge base: [{ check, green }], where `green` is the newest
|
|
222
228
|
// commit of main on which the check was green. `brake` holds the trips that
|
|
223
229
|
// GitHub gives ({ ci: [...] }, see brake.mjs); false leaves the brake out, as
|
|
224
|
-
// the replay does, because the brake depends on the day of the run.
|
|
225
|
-
|
|
230
|
+
// the replay does, because the brake depends on the day of the run. `signed`
|
|
231
|
+
// is { repo, pr, baseRef, texts }: the texts are the comments of the pull
|
|
232
|
+
// request, read for a signed act, and baseRef is the name of its base branch.
|
|
233
|
+
export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null }) {
|
|
226
234
|
const top = resolve(root);
|
|
227
235
|
const baseSha = commitOf(top, base);
|
|
228
236
|
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
229
237
|
const headSha = commitOf(top, head);
|
|
230
|
-
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null };
|
|
238
|
+
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null };
|
|
231
239
|
if (!headSha) {
|
|
232
240
|
answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
|
|
233
241
|
return finish(answer);
|
|
@@ -250,9 +258,40 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
250
258
|
answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
|
|
251
259
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
252
260
|
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
|
|
261
|
+
if (signed) signedRows(top, baseSha, headSha, signed, answer);
|
|
253
262
|
return finish(answer);
|
|
254
263
|
}
|
|
255
264
|
|
|
265
|
+
// Done line 4 of R72: a valid signed act for this head clears each owner row.
|
|
266
|
+
// It never clears red: the pull request must be fixed first. The key file is
|
|
267
|
+
// read from the base commit, so a key that the pull request adds does not count.
|
|
268
|
+
// The act must name this base branch and this merge base too, so a change of
|
|
269
|
+
// the base after the touch does not carry it (security review S1). A head job
|
|
270
|
+
// named like a check is never cleared: the owner's view of the gate could be
|
|
271
|
+
// that job's forgery (security review S1).
|
|
272
|
+
function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer) {
|
|
273
|
+
if (!answer.rows.some((row) => row.kind === 'owner') || answer.rows.some((row) => row.kind === 'red')) return;
|
|
274
|
+
const note = (cause) => answer.rows.push({ kind: 'note', class: 'signed', cause });
|
|
275
|
+
if (texts === null) { note('the comments of the pull request could not be read, so no signed act counts'); return; }
|
|
276
|
+
if (!BRANCH_FORM.test(String(baseRef ?? ''))) { note('the base branch of the pull request is not named, so no signed act counts'); return; }
|
|
277
|
+
const { keys, refused } = parseSigners(textAt(root, baseSha, SIGNERS_FILE) ?? '');
|
|
278
|
+
for (const one of refused.slice(0, 3)) note(`${SIGNERS_FILE} line ${one.line} does not count: ${one.why}`);
|
|
279
|
+
if (!keys.length) { note(`no owner key in ${SIGNERS_FILE} on the base, so no signed act counts`); return; }
|
|
280
|
+
note(`${keys.length} owner key(s) in ${SIGNERS_FILE} on the base: ${keys.map((key) => key.fingerprint).join(', ')}`);
|
|
281
|
+
const verified = verifyTexts(texts, keys);
|
|
282
|
+
const found = findAct(verified, { kind: 'merge', repo, pr: Number(pr), base: baseRef, merge_base: answer.merge_base, head: headSha });
|
|
283
|
+
if (!found) {
|
|
284
|
+
for (const row of verified.filter((each) => each.error).slice(0, 3)) note(`a signed act that does not count: ${row.error}`);
|
|
285
|
+
if (verified.some((each) => !each.error)) note('a valid signed act names another pull request, base, merge base or head; sign this head');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const locked = answer.rows.filter((row) => row.kind === 'owner' && row.class === 'gate-checks');
|
|
289
|
+
if (locked.length) { note('a signed act never clears a head job named like a check: remove that job'); return; }
|
|
290
|
+
for (const row of answer.rows) if (row.kind === 'owner') row.kind = 'signed';
|
|
291
|
+
answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified };
|
|
292
|
+
note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
256
295
|
// Line 1: while the brake is on, every pull request of the repo comes to the
|
|
257
296
|
// owner, except the exact undo of a trip (line 4).
|
|
258
297
|
function brakeRows(state, undo) {
|
|
@@ -281,30 +320,40 @@ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, '
|
|
|
281
320
|
export function renderText(answer) {
|
|
282
321
|
const named = new Set(answer.rows.filter((row) => row.path).map((row) => row.path)).size;
|
|
283
322
|
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`];
|
|
284
|
-
const order = { red: 0, owner: 1,
|
|
323
|
+
const order = { red: 0, owner: 1, signed: 2, note: 3 };
|
|
285
324
|
for (const row of [...answer.rows].sort((a, b) => order[a.kind] - order[b.kind])) {
|
|
286
325
|
const where = row.path ? ` ${oneLine(row.path)}${row.line ? `:${row.line}` : ''}` : '';
|
|
287
326
|
out.push(` ${row.kind.padEnd(6)} ${oneLine(row.class).padEnd(12)}${where} ${oneLine(row.cause)}`);
|
|
288
327
|
}
|
|
289
328
|
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
290
329
|
else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
|
|
330
|
+
else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
|
|
291
331
|
else out.push('SUCCESS: no rule holds this pull request.');
|
|
292
332
|
return out;
|
|
293
333
|
}
|
|
294
334
|
|
|
295
335
|
// ------------------------------------------------------------------ the command
|
|
296
336
|
|
|
297
|
-
// `atlas gate check`. `read` gives
|
|
298
|
-
// with --repo.
|
|
299
|
-
|
|
337
|
+
// `atlas gate check`. `read` gives the red main checks from GitHub when the
|
|
338
|
+
// job runs with --repo. `comments` gives the comment texts of the pull request
|
|
339
|
+
// when it also runs with --pr, so a signed act can clear owner. Exit 0 on
|
|
340
|
+
// success, 1 on owner or red, 2 on a usage error.
|
|
341
|
+
export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output, repo = null, pr = null, baseRef = null }, line, read = null, comments = null) {
|
|
300
342
|
try {
|
|
301
343
|
if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
|
|
302
344
|
if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
|
|
303
345
|
const body = bodyFile && bodyFile !== true ? readFileSync(resolve(bodyFile), 'utf8') : '';
|
|
346
|
+
if (pr !== null && pr !== undefined && !/^[1-9][0-9]{0,8}$/.test(String(pr))) throw Object.assign(new GateCheckError('--pr must be a pull request number.'), { usage: true });
|
|
304
347
|
// A reader gives the red main checks, or { redMain, brake } with the brake trips too.
|
|
305
348
|
const got = read ? read() : [];
|
|
306
349
|
const redMain = Array.isArray(got) ? got : got.redMain ?? [];
|
|
307
|
-
|
|
350
|
+
let signed = null;
|
|
351
|
+
if (comments && repo && pr) {
|
|
352
|
+
let texts = null;
|
|
353
|
+
try { texts = comments(); } catch { texts = null; }
|
|
354
|
+
signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
|
|
355
|
+
}
|
|
356
|
+
const answer = judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed });
|
|
308
357
|
const text = renderText(answer);
|
|
309
358
|
if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
|
|
310
359
|
else for (const each of text) line(each);
|
package/door/lib/gate.mjs
CHANGED
|
@@ -21,6 +21,7 @@ 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
23
|
import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
24
|
+
import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
|
|
24
25
|
import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
|
|
25
26
|
|
|
26
27
|
export const BRANCH_RULESET = 'atlas gate';
|
|
@@ -287,7 +288,7 @@ export function jobRefusal(job, check, run) {
|
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
// The check runs that may count for this pull request, and the ones refused.
|
|
290
|
-
function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
|
|
291
|
+
export function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
|
|
291
292
|
const homes = homesAt(gh, repo, baseRef, checks);
|
|
292
293
|
// Every pull request, open or closed, from the head branch into another
|
|
293
294
|
// branch. Read once, when the first run needs it.
|
|
@@ -474,6 +475,26 @@ export function redMain({ root, base }, gh, repo, depth = 30) {
|
|
|
474
475
|
return found;
|
|
475
476
|
}
|
|
476
477
|
|
|
478
|
+
// The comment texts of one pull request that carry the signed-act marker
|
|
479
|
+
// (roadmap R72). At most ten pages of a hundred, newest kept first, and at most
|
|
480
|
+
// 2 MB of text in all; the gate reads them as data only.
|
|
481
|
+
export function prComments(gh, repo, pr) {
|
|
482
|
+
const texts = [];
|
|
483
|
+
for (let page = 1; page <= 10; page += 1) {
|
|
484
|
+
const rows = gh('GET', `repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`) ?? [];
|
|
485
|
+
for (const row of Array.isArray(rows) ? rows : []) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
|
|
486
|
+
if (!Array.isArray(rows) || rows.length < 100) break;
|
|
487
|
+
}
|
|
488
|
+
const kept = [];
|
|
489
|
+
let bytes = 0;
|
|
490
|
+
for (const text of texts.reverse()) {
|
|
491
|
+
bytes += text.length;
|
|
492
|
+
if (bytes > 2 * 1024 * 1024) break;
|
|
493
|
+
kept.push(text);
|
|
494
|
+
}
|
|
495
|
+
return kept;
|
|
496
|
+
}
|
|
497
|
+
|
|
477
498
|
// The base workflow files that define a job of one of `names`, read from git.
|
|
478
499
|
function definedAt(git, sha, names) {
|
|
479
500
|
const files = new Set();
|
|
@@ -516,11 +537,14 @@ export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3)
|
|
|
516
537
|
}
|
|
517
538
|
|
|
518
539
|
// `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
|
|
519
|
-
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) {
|
|
540
|
+
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, baseRef, head, body, json = false, summary, output, days, branch, home = homedir() }, line, gh = ghApi) {
|
|
520
541
|
if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
|
|
521
542
|
// With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
|
|
522
543
|
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
523
|
-
if (what === 'check')
|
|
544
|
+
if (what === 'check') {
|
|
545
|
+
const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
|
|
546
|
+
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) } }) : null, named && number ? () => prComments(gh, repo, number) : null);
|
|
547
|
+
}
|
|
524
548
|
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
525
549
|
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
|
526
550
|
if (what === 'ready') {
|