@arjunkhera/atlas 0.3.44 → 0.3.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.44",
3
+ "version": "0.3.45",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -36,6 +36,7 @@ import { issuesCommand as testsIssues } from './lib/issues.mjs';
36
36
  import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
37
37
  import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
38
38
  import { upkeepCommand } from './lib/upkeep.mjs';
39
+ import { gateCommand } from './lib/gate.mjs';
39
40
  import { judgeCommand } from './lib/judge.mjs';
40
41
 
41
42
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -124,6 +125,11 @@ const HELP = `atlas — the door into a repo's Atlas files
124
125
  --event <file> the GitHub pull request event; adds the item check
125
126
  --json print JSON
126
127
 
128
+ atlas gate apply --repo <owner/name> set the gate: two rulesets from atlas.yaml required_checks, and auto-merge
129
+ --root <repo> the checkout to read atlas.yaml from (default: this folder)
130
+ --dry-run print each call; change nothing
131
+ --off | --on break glass: turn both rulesets off, or back on
132
+
127
133
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
128
134
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
129
135
  atlas judge run <tasks.md> <id> --out <dir> one fresh agent does one sealed task, in a clean clone at the sealed commit
@@ -161,6 +167,7 @@ export const FLAGS = Object.freeze({
161
167
  view: { product: 'value', name: 'value', out: 'value' },
162
168
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
163
169
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
170
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch' },
164
171
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
165
172
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
166
173
  upgrade: { version: 'optional', yes: 'switch' },
@@ -371,6 +378,10 @@ function upkeepVerb(chosen) {
371
378
  return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json) }, rootOf(chosen), line);
372
379
  }
373
380
 
381
+ function gateVerb(chosen) {
382
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on) }, line);
383
+ }
384
+
374
385
  function judgeVerb(chosen) {
375
386
  return judgeCommand({ what: chosen._[1], file: chosen._[2], task: chosen._[3], tasks: chosen.tasks, verdict: chosen.verdict, seal: chosen.seal, runs: chosen.runs, out: chosen.out, repo: chosen.repo, budget: chosen.budget, model: chosen.model }, line, PACKAGE_ROOT);
376
387
  }
@@ -408,6 +419,7 @@ export async function main(argv = process.argv.slice(2)) {
408
419
  case 'view': return viewCommand(chosen);
409
420
  case 'tests': return testsCommand(chosen);
410
421
  case 'upkeep': return upkeepVerb(chosen);
422
+ case 'gate': return gateVerb(chosen);
411
423
  case 'judge': return judgeVerb(chosen);
412
424
  default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
413
425
  }
@@ -0,0 +1,193 @@
1
+ // `atlas gate apply`: the setup command of the gate (roadmap R9, design
2
+ // docs/design-docs/gate-apply/). The owner runs it with his own GitHub login.
3
+ // It sets two rulesets from the repo's atlas.yaml and allows auto-merge:
4
+ // 1. "atlas gate" on the default branch: a pull request with no approval,
5
+ // each required check from GitHub Actions, no force push, no delete;
6
+ // 2. "atlas release tags" on v* tags: no update, no delete.
7
+ // The bypass list stays empty. --off and --on are the break glass.
8
+ //
9
+ // The plan is built by pure functions, so a test reads it without GitHub. The
10
+ // gh calls sit in one place, behind `gh`, which a test replaces.
11
+ import { execFileSync } from 'node:child_process';
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { childEnvironment } from '../../work/lib/child-env.mjs';
15
+ import { parseYaml } from '../../shape/check.mjs';
16
+
17
+ export const BRANCH_RULESET = 'atlas gate';
18
+ export const TAG_RULESET = 'atlas release tags';
19
+ // The app that posts GitHub Actions check runs. A status that another app posts
20
+ // under the same name does not count.
21
+ export const ACTIONS_APP_ID = 15368;
22
+
23
+ export class GateError extends Error {}
24
+
25
+ // What the gate reads from atlas.yaml.
26
+ export function gateFacts(root) {
27
+ const path = join(root, 'atlas.yaml');
28
+ if (!existsSync(path)) throw new GateError(`there is no atlas.yaml in ${root}. Nothing was changed.`);
29
+ const facts = parseYaml(readFileSync(path, 'utf8')) ?? {};
30
+ const checks = facts.required_checks;
31
+ if (!Array.isArray(checks) || checks.length === 0 || checks.some((name) => typeof name !== 'string' || !name.trim())) {
32
+ throw new GateError('atlas.yaml lists no required_checks. Nothing was changed.');
33
+ }
34
+ if (facts.up_to_date !== undefined && typeof facts.up_to_date !== 'boolean') throw new GateError('atlas.yaml up_to_date must be true or false. Nothing was changed.');
35
+ return { checks: [...checks], upToDate: facts.up_to_date === true };
36
+ }
37
+
38
+ // The two ruleset bodies, as the GitHub rulesets API takes them.
39
+ export function rulesets({ checks, upToDate }, enforcement = 'active') {
40
+ return [
41
+ {
42
+ name: BRANCH_RULESET,
43
+ target: 'branch',
44
+ enforcement,
45
+ bypass_actors: [],
46
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
47
+ rules: [
48
+ {
49
+ type: 'pull_request',
50
+ parameters: {
51
+ required_approving_review_count: 0,
52
+ dismiss_stale_reviews_on_push: false,
53
+ require_code_owner_review: false,
54
+ require_last_push_approval: false,
55
+ required_review_thread_resolution: false,
56
+ },
57
+ },
58
+ {
59
+ type: 'required_status_checks',
60
+ parameters: {
61
+ strict_required_status_checks_policy: upToDate,
62
+ required_status_checks: checks.map((context) => ({ context, integration_id: ACTIONS_APP_ID })),
63
+ },
64
+ },
65
+ { type: 'non_fast_forward' },
66
+ { type: 'deletion' },
67
+ ],
68
+ },
69
+ {
70
+ name: TAG_RULESET,
71
+ target: 'tag',
72
+ enforcement,
73
+ bypass_actors: [],
74
+ conditions: { ref_name: { include: ['refs/tags/v*'], exclude: [] } },
75
+ rules: [{ type: 'update' }, { type: 'deletion' }],
76
+ },
77
+ ];
78
+ }
79
+
80
+ // The calls of one run. `existing` is the list GitHub gives for the repo's
81
+ // rulesets: [{ id, name }]. A ruleset that exists is updated, never doubled.
82
+ // In a dry run, a ruleset that does not exist yet shows as <id>.
83
+ export function plan({ repo, facts, existing = [], mode = 'apply', dryRun = false }) {
84
+ const ids = new Map(existing.map((ruleset) => [ruleset.name, ruleset.id]));
85
+ if (mode === 'off' || mode === 'on') {
86
+ const enforcement = mode === 'off' ? 'disabled' : 'active';
87
+ return [BRANCH_RULESET, TAG_RULESET].map((name) => {
88
+ if (!ids.has(name) && !dryRun) throw new GateError(`the repo has no ruleset "${name}". Run "atlas gate apply" first.`);
89
+ return { method: 'PUT', path: `repos/${repo}/rulesets/${ids.get(name) ?? '<id>'}`, name, body: { enforcement } };
90
+ });
91
+ }
92
+ const calls = rulesets(facts).map((body) => (ids.has(body.name)
93
+ ? { method: 'PUT', path: `repos/${repo}/rulesets/${ids.get(body.name)}`, name: body.name, body }
94
+ : { method: 'POST', path: `repos/${repo}/rulesets`, name: body.name, body }));
95
+ calls.push({ method: 'PATCH', path: `repos/${repo}`, name: 'allow auto-merge', body: { allow_auto_merge: true } });
96
+ return calls;
97
+ }
98
+
99
+ // One readable block for each call, for the dry run and the log.
100
+ export function describe(call) {
101
+ const lines = [`${call.method.padEnd(6)} ${call.path} ${call.name}`];
102
+ const body = call.body;
103
+ if (body.rules) {
104
+ const on = body.target === 'branch' ? `branch ${body.conditions.ref_name.include.join(', ')}` : `tag ${body.conditions.ref_name.include.join(', ')}`;
105
+ lines.push(` ${on}, enforcement ${body.enforcement}, bypass ${body.bypass_actors.length ? body.bypass_actors.length : 'none'}`);
106
+ for (const rule of body.rules) {
107
+ if (rule.type === 'pull_request') lines.push(` pull_request: ${rule.parameters.required_approving_review_count} approvals`);
108
+ else if (rule.type === 'required_status_checks') {
109
+ lines.push(` required_status_checks (strict ${rule.parameters.strict_required_status_checks_policy}), from GitHub Actions (${ACTIONS_APP_ID}):`);
110
+ for (const check of rule.parameters.required_status_checks) lines.push(` ${check.context}`);
111
+ } else lines.push(` ${rule.type}`);
112
+ }
113
+ } else {
114
+ lines.push(` ${Object.entries(body).map(([key, value]) => `${key} ${value}`).join(', ')}`);
115
+ }
116
+ return lines.join('\n');
117
+ }
118
+
119
+ // What a read-back must hold, compared key by key. Returns the differences.
120
+ export function compare(wanted, got) {
121
+ const problems = [];
122
+ if (got.enforcement !== wanted.enforcement) problems.push(`${wanted.name}: enforcement is ${got.enforcement}, not ${wanted.enforcement}`);
123
+ if ((got.bypass_actors ?? []).length !== 0) problems.push(`${wanted.name}: the bypass list is not empty`);
124
+ if (JSON.stringify(got.conditions?.ref_name?.include ?? []) !== JSON.stringify(wanted.conditions.ref_name.include)) problems.push(`${wanted.name}: it holds for ${JSON.stringify(got.conditions?.ref_name?.include)}`);
125
+ for (const rule of wanted.rules) {
126
+ const found = (got.rules ?? []).find((each) => each.type === rule.type);
127
+ if (!found) { problems.push(`${wanted.name}: the rule ${rule.type} is missing`); continue; }
128
+ if (rule.type === 'pull_request' && found.parameters?.required_approving_review_count !== 0) problems.push(`${wanted.name}: pull_request asks for ${found.parameters?.required_approving_review_count} approvals`);
129
+ if (rule.type === 'required_status_checks') {
130
+ const key = (check) => `${check.context}|${check.integration_id}`;
131
+ const want = rule.parameters.required_status_checks.map(key).sort();
132
+ const have = (found.parameters?.required_status_checks ?? []).map(key).sort();
133
+ if (JSON.stringify(want) !== JSON.stringify(have)) problems.push(`${wanted.name}: the required checks are ${JSON.stringify(have)}, not ${JSON.stringify(want)}`);
134
+ if (Boolean(found.parameters?.strict_required_status_checks_policy) !== rule.parameters.strict_required_status_checks_policy) problems.push(`${wanted.name}: up to date is ${found.parameters?.strict_required_status_checks_policy}`);
135
+ }
136
+ }
137
+ return problems;
138
+ }
139
+
140
+ // The gh door. Each call is `gh api` with a JSON body on stdin.
141
+ export function ghApi(method, path, body) {
142
+ const args = ['api', '--method', method, path, '-H', 'Accept: application/vnd.github+json'];
143
+ if (body !== undefined) args.push('--input', '-');
144
+ const out = execFileSync('gh', args, { encoding: 'utf8', input: body === undefined ? undefined : JSON.stringify(body), env: childEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] });
145
+ return out.trim() ? JSON.parse(out) : null;
146
+ }
147
+
148
+ const explain = (error) => {
149
+ const text = `${error.stderr ?? ''}${error.message ?? ''}`;
150
+ if (/HTTP 403|Upgrade to GitHub Pro|403/.test(text)) return 'GitHub refused the ruleset (403). A private repo needs GitHub Pro for rulesets, and only a repo admin may set one.';
151
+ return text.trim().split('\n').slice(-3).join(' ');
152
+ };
153
+
154
+ // `atlas gate apply`. Returns the exit code.
155
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false }, line, gh = ghApi) {
156
+ if (what !== 'apply') throw new GateError(`there is no gate verb "${what ?? ''}". Use apply.`);
157
+ if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError('atlas gate apply needs --repo <owner/name>.');
158
+ if (off && on) throw new GateError('pick --off or --on, not both.');
159
+ const mode = off ? 'off' : on ? 'on' : 'apply';
160
+ const facts = mode === 'apply' ? gateFacts(root) : null;
161
+ if (dryRun) line('atlas gate apply: dry run, nothing changes.');
162
+ if (facts) line(`atlas.yaml read from ${root}: ${facts.checks.length} required check(s), up_to_date ${facts.upToDate}.`);
163
+ line(`GET repos/${repo}/rulesets`);
164
+ let existing;
165
+ try { existing = gh('GET', `repos/${repo}/rulesets`) ?? []; } catch (error) {
166
+ if (!dryRun) { line(`atlas gate apply: ${explain(error)} Nothing was changed.`); return 1; }
167
+ line(` (the read failed: ${explain(error)} The plan below assumes no ruleset exists.)`);
168
+ existing = [];
169
+ }
170
+ const calls = plan({ repo, facts, existing, mode, dryRun });
171
+ for (const call of calls) line(describe(call));
172
+ if (dryRun) return 0;
173
+ const written = [];
174
+ for (const call of calls) {
175
+ try { written.push({ call, answer: gh(call.method, call.path, call.body) }); } catch (error) {
176
+ line(`atlas gate apply: ${call.method} ${call.path} failed: ${explain(error)}`);
177
+ line(written.length ? `Done before the failure: ${written.map((each) => each.call.name).join(', ')}.` : 'Nothing was changed.');
178
+ return 1;
179
+ }
180
+ }
181
+ if (mode !== 'apply') { line(`atlas gate apply: both rulesets are ${mode === 'off' ? 'disabled' : 'active'}.`); return 0; }
182
+ const problems = [];
183
+ for (const wanted of rulesets(facts)) {
184
+ const id = written.find((each) => each.call.name === wanted.name)?.answer?.id ?? existing.find((ruleset) => ruleset.name === wanted.name)?.id;
185
+ const got = gh('GET', `repos/${repo}/rulesets/${id}`);
186
+ problems.push(...compare(wanted, got ?? {}));
187
+ }
188
+ const settings = gh('GET', `repos/${repo}`);
189
+ if (settings?.allow_auto_merge !== true) problems.push('the repo does not allow auto-merge');
190
+ if (problems.length) { for (const problem of problems) line(` ✗ ${problem}`); line('atlas gate apply: the read-back differs. Look at the repo settings.'); return 1; }
191
+ line(`atlas gate apply: the gate is on for ${repo}. Read back: ${BRANCH_RULESET}, ${TAG_RULESET}, allow_auto_merge true.`);
192
+ return 0;
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.44",
3
+ "version": "0.3.45",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -105,6 +105,7 @@ set. A test keeps the two lists equal.
105
105
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
106
106
  | `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
107
107
  | `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
108
+ | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass |
108
109
  | `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
109
110
 
110
111
  A person merges every file that `atlas tooling` writes.