@arjunkhera/atlas 0.4.2 → 0.4.4
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 +15 -0
- package/door/jobs/red-main.yaml +15 -0
- package/door/kit-releases.json +4 -0
- package/door/lib/gate.mjs +37 -5
- package/door/lib/issues.mjs +27 -13
- package/door/lib/jobs.mjs +241 -0
- package/door/lib/pull-request.mjs +1 -1
- package/door/lib/tooling.mjs +42 -3
- package/door/templates/atlas-jobs.yml +73 -0
- package/hooks/door.mjs +387 -0
- package/hooks/hooks.json +13 -1
- package/hooks/lead.sh +1 -1
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -0
- package/skills/sdlc-task/SKILL.md +5 -0
- package/skills/upkeep/SKILL.md +4 -2
- package/tests/sweep.mjs +24 -11
package/door/cli.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunComman
|
|
|
33
33
|
import { runKept } from '../tests/kept.mjs';
|
|
34
34
|
import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
|
|
35
35
|
import { issuesCommand as testsIssues } from './lib/issues.mjs';
|
|
36
|
+
import { jobsCommand } from './lib/jobs.mjs';
|
|
36
37
|
import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
37
38
|
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
38
39
|
import { afterCommand as testsAfter } from './lib/passes-after.mjs';
|
|
@@ -129,6 +130,14 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
129
130
|
--tests <dir> the test folder of the area; default test. The test files are in <dir>/scenarios
|
|
130
131
|
--evidence <dir> where the evidence files are; default <area>/test/evidence
|
|
131
132
|
|
|
133
|
+
atlas jobs check --root <repo> --repo <owner/name> the watcher: run the check of each Atlas job that atlas.yaml names; findings as JSON
|
|
134
|
+
--branch <name> the default branch; default GitHub's answer
|
|
135
|
+
--out <file> write the JSON here; default the terminal
|
|
136
|
+
atlas jobs file --findings <file> --repo <owner/name> open one atlas-job issue for each new finding of a jobs check; never closes one
|
|
137
|
+
--max <n> open at most n issues in one run; default 10. The rest are listed, and the exit is 1
|
|
138
|
+
--dry-run print what it would open; change nothing
|
|
139
|
+
The token is only GITHUB_TOKEN. The atlas-jobs workflow runs both, once a day
|
|
140
|
+
|
|
132
141
|
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, and in a pull request its tracker item
|
|
133
142
|
--event <file> the GitHub pull request event; adds the item check
|
|
134
143
|
--json print JSON
|
|
@@ -180,6 +189,7 @@ export const FLAGS = Object.freeze({
|
|
|
180
189
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
181
190
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
182
191
|
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
|
|
192
|
+
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
183
193
|
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
184
194
|
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
|
|
185
195
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
@@ -413,6 +423,10 @@ async function testsCommand(chosen) {
|
|
|
413
423
|
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, after, sweep, covers, kept, issues or rerun.`);
|
|
414
424
|
}
|
|
415
425
|
|
|
426
|
+
function jobsVerb(chosen) {
|
|
427
|
+
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);
|
|
428
|
+
}
|
|
429
|
+
|
|
416
430
|
function upkeepVerb(chosen) {
|
|
417
431
|
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json) }, rootOf(chosen), line);
|
|
418
432
|
}
|
|
@@ -457,6 +471,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
457
471
|
case 'design': return designCommand(chosen);
|
|
458
472
|
case 'view': return viewCommand(chosen);
|
|
459
473
|
case 'tests': return testsCommand(chosen);
|
|
474
|
+
case 'jobs': return jobsVerb(chosen);
|
|
460
475
|
case 'upkeep': return upkeepVerb(chosen);
|
|
461
476
|
case 'gate': return gateVerb(chosen);
|
|
462
477
|
case 'judge': return judgeVerb(chosen);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# An Atlas job (the routines design, records part). A job does nothing until a
|
|
2
|
+
# repo names it in its atlas.yaml:
|
|
3
|
+
#
|
|
4
|
+
# jobs:
|
|
5
|
+
# atlas: [red-main]
|
|
6
|
+
#
|
|
7
|
+
# The check is Atlas code (door/lib/jobs.mjs). It reads the runs of the main
|
|
8
|
+
# branch only, never a run from a fork. `fields` are the typed fields that a
|
|
9
|
+
# finding of this job may hold. Atlas code drops every other field.
|
|
10
|
+
title: The newest CI run on the main branch passes
|
|
11
|
+
when: red-main
|
|
12
|
+
needs: [tests]
|
|
13
|
+
act: atlas:red-main
|
|
14
|
+
may: pull-request
|
|
15
|
+
fields: [workflow, run_id, head_sha, conclusion]
|
package/door/kit-releases.json
CHANGED
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
{
|
|
48
48
|
"kit_sha256": "8d6ae3c4dede74bafe292a992de3ef650e5c5566fb282081a9de23b8d30d9025",
|
|
49
49
|
"first_package": "0.3.39"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"kit_sha256": "41d64800daf644174dd1daef1c95ccc2ca7a8460dbe3e36bcf2625d05fb0479e",
|
|
53
|
+
"first_package": "0.3.42"
|
|
50
54
|
}
|
|
51
55
|
]
|
|
52
56
|
}
|
package/door/lib/gate.mjs
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
// The plan is built by pure functions, so a test reads it without GitHub. The
|
|
16
16
|
// gh calls sit in one place, behind `gh`, which a test replaces.
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
-
import {
|
|
18
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
20
21
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
21
22
|
import { parseYaml } from '../../shape/check.mjs';
|
|
22
23
|
|
|
@@ -195,9 +196,10 @@ const checkLine = (row) => ` ${row.state === 'green' ? '✓' : '✗'} ${row.sta
|
|
|
195
196
|
|
|
196
197
|
// `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
|
|
197
198
|
// every required check is green on its head commit, 1 when not.
|
|
198
|
-
export function readyCommand({ repo, pr }, line, gh = ghApi) {
|
|
199
|
+
export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
199
200
|
if (!pr || pr === true || !/^\d+$/.test(String(pr))) throw new GateError('atlas gate ready needs --pr <number>.');
|
|
200
201
|
const pull = gh('GET', `repos/${repo}/pulls/${pr}`);
|
|
202
|
+
if (answer && typeof pull?.head?.sha === 'string') answer.sha = pull.head.sha;
|
|
201
203
|
if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
|
|
202
204
|
const sha = pull.head.sha;
|
|
203
205
|
const checks = checksAt(gh, repo, pull.base.ref);
|
|
@@ -209,6 +211,24 @@ export function readyCommand({ repo, pr }, line, gh = ghApi) {
|
|
|
209
211
|
return ready ? 0 : 1;
|
|
210
212
|
}
|
|
211
213
|
|
|
214
|
+
// The merge door hook (packages/atlas/hooks/door.mjs) reads this file before
|
|
215
|
+
// a merge. It holds the last READY of one pull request: the repo, the number,
|
|
216
|
+
// the head commit and the time. Any answer but READY removes the file, so an
|
|
217
|
+
// old READY never outlives a newer NOT READY. The hook keeps its own copy of
|
|
218
|
+
// the path, because its bytes are pinned.
|
|
219
|
+
export const readyFile = (repo, pr, home = homedir()) => join(home, '.config', 'atlas', 'gate-ready', ...repo.toLowerCase().split('/'), String(pr));
|
|
220
|
+
|
|
221
|
+
export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Date() }) {
|
|
222
|
+
if (repo.split('/').some((part) => /^\.+$/.test(part))) return null;
|
|
223
|
+
const path = readyFile(repo, pr, home);
|
|
224
|
+
if (code !== 0 || !/^[0-9a-f]{40}$/i.test(String(sha ?? ''))) { rmSync(path, { force: true }); return null; }
|
|
225
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
226
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
227
|
+
writeFileSync(temp, `${JSON.stringify({ repo: repo.toLowerCase(), pr: Number(pr), sha: sha.toLowerCase(), at: now.toISOString() })}\n`, { mode: 0o600 });
|
|
228
|
+
renameSync(temp, path);
|
|
229
|
+
return path;
|
|
230
|
+
}
|
|
231
|
+
|
|
212
232
|
// `atlas gate audit --since <date>`: each pull request merged since the date
|
|
213
233
|
// whose required checks were not all green at the moment of the merge.
|
|
214
234
|
export function auditCommand({ repo, since, commit }, line, gh = ghApi) {
|
|
@@ -275,10 +295,22 @@ function commitAudit({ repo, commit }, line, gh) {
|
|
|
275
295
|
}
|
|
276
296
|
|
|
277
297
|
// `atlas gate apply|ready|audit`. Returns the exit code.
|
|
278
|
-
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit }, line, gh = ghApi) {
|
|
298
|
+
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, home = homedir() }, line, gh = ghApi) {
|
|
279
299
|
if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
|
|
280
300
|
if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
|
281
|
-
if (what === 'ready')
|
|
301
|
+
if (what === 'ready') {
|
|
302
|
+
const answer = {};
|
|
303
|
+
let code = 1;
|
|
304
|
+
try { code = readyCommand({ repo, pr, answer }, line, gh); } finally {
|
|
305
|
+
if (/^\d+$/.test(String(pr ?? ''))) {
|
|
306
|
+
try {
|
|
307
|
+
const kept = keepReady({ repo, pr, sha: answer.sha, code, home });
|
|
308
|
+
if (kept) line(`The merge door may merge #${pr} at this head for ten minutes.`);
|
|
309
|
+
} catch (error) { line(`The merge door could not keep this answer (${error.code ?? error.message}), so it will refuse the merge. Run ready again outside the sandbox.`); }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return code;
|
|
313
|
+
}
|
|
282
314
|
if (what === 'audit') return auditCommand({ repo, since, commit }, line, gh);
|
|
283
315
|
if (off && on) throw new GateError('pick --off or --on, not both.');
|
|
284
316
|
const mode = off ? 'off' : on ? 'on' : 'apply';
|
package/door/lib/issues.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { resolve } from 'node:path';
|
|
|
22
22
|
import { LABEL } from '../../tests/sweep.mjs';
|
|
23
23
|
|
|
24
24
|
const usage = (message) => Object.assign(new Error(message), { usage: true });
|
|
25
|
-
const TOKEN_SHAPE = /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\b[0-9a-f]{40}\b)/;
|
|
25
|
+
export const TOKEN_SHAPE = /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\b[0-9a-f]{40}\b)/;
|
|
26
26
|
const SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
27
27
|
const PAGE = 100;
|
|
28
28
|
export const DEFAULT_BOT = 'github-actions[bot]';
|
|
@@ -43,7 +43,7 @@ export function apiBase(env) {
|
|
|
43
43
|
return text.replace(/\/+$/, '');
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
async function api(base, key, method, path, body) {
|
|
46
|
+
export async function api(base, key, method, path, body) {
|
|
47
47
|
const response = await fetch(`${base}${path}`, {
|
|
48
48
|
method,
|
|
49
49
|
headers: {
|
|
@@ -63,6 +63,12 @@ async function api(base, key, method, path, body) {
|
|
|
63
63
|
|
|
64
64
|
export const LABEL_COLOR = 'c5def5';
|
|
65
65
|
export const LABEL_TEXT = 'Opened by the daily sweep of the scenario tests (atlas tests sweep)';
|
|
66
|
+
// Each label an issue may carry, and its line. A label with no line here gets a plain one.
|
|
67
|
+
const LABEL_TEXTS = {
|
|
68
|
+
[LABEL]: LABEL_TEXT,
|
|
69
|
+
'atlas-job': 'Opened by the Atlas watcher (atlas-jobs.yml); a runner acts on it',
|
|
70
|
+
};
|
|
71
|
+
const labelText = (label) => LABEL_TEXTS[label] ?? (label.startsWith('job:') ? `The Atlas job ${label.slice(4)}` : 'Opened by Atlas');
|
|
66
72
|
|
|
67
73
|
// The label must exist before the first issue: a token that may only write issues may not create a label by
|
|
68
74
|
// naming it in an issue. GET the label; on 404, create it. A 422 means it exists by now (a race), which is fine.
|
|
@@ -72,7 +78,7 @@ async function ensureLabel(base, key, repo, label, line) {
|
|
|
72
78
|
const have = await api(base, key, 'GET', `/repos/${repo}/labels/${name}`);
|
|
73
79
|
if (have.ok) return;
|
|
74
80
|
if (have.status !== 404) { line(`label GitHub answered ${have.status} when the label ${label} was read; going on`); return; }
|
|
75
|
-
const made = await api(base, key, 'POST', `/repos/${repo}/labels`, { name: label, color: LABEL_COLOR, description:
|
|
81
|
+
const made = await api(base, key, 'POST', `/repos/${repo}/labels`, { name: label, color: LABEL_COLOR, description: labelText(label) });
|
|
76
82
|
if (made.ok || made.status === 422) line(`label ${made.ok ? 'created' : 'already exists'}: ${label}`);
|
|
77
83
|
else line(`label GitHub answered ${made.status} when the label ${label} was created; going on`);
|
|
78
84
|
}
|
|
@@ -99,42 +105,50 @@ export function readFindings(text) {
|
|
|
99
105
|
return data.findings;
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
// An issue of the bot that holds the marker and stops a new one.
|
|
103
|
-
|
|
108
|
+
// An issue of the bot that holds the marker and stops a new one. `closedStops` says which closed issue
|
|
109
|
+
// still stops one: for the sweep, one closed as not_planned.
|
|
110
|
+
const sweepClosedStops = (issue) => issue.state_reason === 'not_planned';
|
|
111
|
+
const stops = (issue, marker, bot, closedStops) => !issue.pull_request
|
|
104
112
|
&& issue.user?.login === bot
|
|
105
113
|
&& typeof issue.body === 'string' && issue.body.includes(marker)
|
|
106
|
-
&& (issue.state === 'open' || (issue.state === 'closed' && issue
|
|
114
|
+
&& (issue.state === 'open' || (issue.state === 'closed' && closedStops(issue)));
|
|
107
115
|
|
|
108
|
-
|
|
116
|
+
// `label` is the label whose issues are read for markers: the sweep's own by default. Every label of a
|
|
117
|
+
// finding is made before its first issue, and the issue must carry `label`.
|
|
118
|
+
export async function openIssues({ findings, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, label = LABEL, closedStops = sweepClosedStops }) {
|
|
109
119
|
const base = apiBase(env);
|
|
110
120
|
const key = env.GITHUB_TOKEN || '';
|
|
111
121
|
let known = [];
|
|
112
122
|
if (!dryRun || key) {
|
|
113
123
|
if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
|
|
114
|
-
known = await labelled(base, key, repo,
|
|
124
|
+
known = await labelled(base, key, repo, label);
|
|
115
125
|
} else line('dry run with no GITHUB_TOKEN: the existing issues are not read, so every finding shows as new.');
|
|
116
126
|
const opened = [];
|
|
117
127
|
const skipped = [];
|
|
118
128
|
const deferred = [];
|
|
119
129
|
const failed = [];
|
|
120
130
|
const seen = new Set();
|
|
121
|
-
|
|
131
|
+
const ensured = new Set();
|
|
122
132
|
for (const f of findings) {
|
|
123
133
|
const { title, body, marker, labels } = f.issue;
|
|
124
|
-
const hit = known.find((i) => stops(i, marker, bot));
|
|
134
|
+
const hit = known.find((i) => stops(i, marker, bot, closedStops));
|
|
125
135
|
if (hit || seen.has(marker)) { skipped.push(f.key); line(`skip ${f.key}: ${hit ? `issue #${hit.number} (${hit.state}) of ${bot} holds the marker` : 'a finding of this file has the same marker'}`); continue; }
|
|
126
136
|
seen.add(marker);
|
|
127
137
|
if (opened.length >= max) { deferred.push(f.key); line(`later ${f.key}: the limit of ${max} issue(s) for one run is reached`); continue; }
|
|
128
138
|
const cut = String(title).slice(0, 200);
|
|
129
139
|
if (dryRun) { opened.push(f.key); line(`would open ${f.key}: ${cut} [${labels.join(', ')}]`); continue; }
|
|
130
140
|
try {
|
|
131
|
-
|
|
141
|
+
for (const name of [label, ...labels]) {
|
|
142
|
+
if (ensured.has(name)) continue;
|
|
143
|
+
ensured.add(name);
|
|
144
|
+
await ensureLabel(base, key, repo, name, line);
|
|
145
|
+
}
|
|
132
146
|
const r = await api(base, key, 'POST', `/repos/${repo}/issues`, { title: cut, body, labels });
|
|
133
147
|
if (!r.ok) throw new Error(`GitHub answered ${r.status}`);
|
|
134
148
|
opened.push(f.key);
|
|
135
149
|
line(`opened #${r.data?.number ?? '?'} ${f.key}: ${cut}${r.data?.html_url ? ` ${r.data.html_url}` : ''}`);
|
|
136
|
-
const has = (r.data?.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) ===
|
|
137
|
-
if (!has) { failed.push(f.key); line(`label ${f.key}: the new issue does not carry the label ${
|
|
150
|
+
const has = (r.data?.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === label);
|
|
151
|
+
if (!has) { failed.push(f.key); line(`label ${f.key}: the new issue does not carry the label ${label}; the token may not create labels. Create the label by hand.`); }
|
|
138
152
|
} catch (error) {
|
|
139
153
|
failed.push(f.key);
|
|
140
154
|
line(`failed ${f.key}: ${String(error.message).split(key).join('(token)')}`);
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// `atlas jobs check` and `atlas jobs file`: the watcher of the routines design
|
|
2
|
+
// (its records and security parts).
|
|
3
|
+
//
|
|
4
|
+
// atlas jobs check --root <repo> --repo <owner/name> [--branch <name>] [--out <file>]
|
|
5
|
+
// atlas jobs file --findings <file> --repo <owner/name> [--max <n>] [--dry-run]
|
|
6
|
+
//
|
|
7
|
+
// The workflow `.github/workflows/atlas-jobs.yml` runs both, from the default branch, once a day.
|
|
8
|
+
// `atlas tooling` writes it from door/templates/atlas-jobs.yml.
|
|
9
|
+
//
|
|
10
|
+
// check reads `jobs: { atlas: [...] }` from atlas.yaml and runs the check of each Atlas job it
|
|
11
|
+
// names. It reads; it writes no issue. Its job in the workflow holds read permissions only.
|
|
12
|
+
// file reads the JSON of the check, keeps the typed fields of each finding and drops the rest, and
|
|
13
|
+
// opens one `atlas-job` issue for each new finding, through openIssues (issues.mjs).
|
|
14
|
+
//
|
|
15
|
+
// The check envelope (records part, "The check envelope"):
|
|
16
|
+
// { "jobs": 1, "state": "none" | "found" | "broken", "findings": [ { "job", "key", <typed fields> } ] }
|
|
17
|
+
// A finding holds ids, paths, versions and numbers only. No prose reaches an issue: every line of an
|
|
18
|
+
// issue is Atlas code, and the finding goes into it as one JSON block.
|
|
19
|
+
//
|
|
20
|
+
// A finding is new when no issue of the bot holds its marker, except for one that is closed. A closed
|
|
21
|
+
// issue with the label `handed-over` still stops a new one for 30 days: the runner gave the finding to
|
|
22
|
+
// the tracker. The token is only GITHUB_TOKEN, as for `atlas tests issues`.
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
27
|
+
import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
|
|
28
|
+
|
|
29
|
+
export const JOBS_LABEL = 'atlas-job';
|
|
30
|
+
export const HANDED_OVER = 'handed-over';
|
|
31
|
+
export const HANDED_OVER_DAYS = 30;
|
|
32
|
+
export const JOBS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'jobs');
|
|
33
|
+
export const ENVELOPE = 1;
|
|
34
|
+
export const STATES = Object.freeze(['none', 'found', 'broken']);
|
|
35
|
+
export const MAX_FINDINGS = 50;
|
|
36
|
+
|
|
37
|
+
const usage = (message) => Object.assign(new Error(message), { usage: true });
|
|
38
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// A job id, a key, and each typed value. A value is an id, a path, a version or a number: no space and
|
|
41
|
+
// no prose. A key may hold a path, so it takes a slash and a dot.
|
|
42
|
+
const JOB_ID = /^[a-z][a-z0-9-]{0,40}$/;
|
|
43
|
+
const KEY = /^[A-Za-z0-9._/:@+-]{1,200}$/;
|
|
44
|
+
const VALUE = /^[A-Za-z0-9._/:@+-]{1,200}$/;
|
|
45
|
+
const FIELD = /^[a-z][a-z0-9_]{0,40}$/;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------- the Atlas jobs
|
|
48
|
+
|
|
49
|
+
// Every Atlas job of this package: door/jobs/<id>.yaml.
|
|
50
|
+
export function atlasJobs(dir = JOBS_DIR) {
|
|
51
|
+
const out = new Map();
|
|
52
|
+
if (!existsSync(dir)) return out;
|
|
53
|
+
for (const name of readdirSync(dir).sort()) {
|
|
54
|
+
if (!name.endsWith('.yaml')) continue;
|
|
55
|
+
const id = name.slice(0, -'.yaml'.length);
|
|
56
|
+
const job = parseYaml(readFileSync(join(dir, name), 'utf8')) ?? {};
|
|
57
|
+
out.set(id, { id, ...job, fields: Array.isArray(job.fields) ? job.fields.map(String).filter((f) => FIELD.test(f)) : [] });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The Atlas jobs that the repo's atlas.yaml names, read from the checkout of the default branch.
|
|
63
|
+
export function namedJobs(root) {
|
|
64
|
+
const file = join(root, 'atlas.yaml');
|
|
65
|
+
if (!existsSync(file)) throw usage(`there is no atlas.yaml in ${root}`);
|
|
66
|
+
const facts = parseYaml(readFileSync(file, 'utf8')) ?? {};
|
|
67
|
+
const named = facts.jobs?.atlas;
|
|
68
|
+
if (named === undefined || named === null) return [];
|
|
69
|
+
if (!Array.isArray(named)) throw usage('jobs.atlas in atlas.yaml must be a list of job ids');
|
|
70
|
+
return named.map(String);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------- the check of red-main
|
|
74
|
+
|
|
75
|
+
// A red run: it ended and did not pass. A cancelled run is not red: a newer push cancels an older run.
|
|
76
|
+
const RED = new Set(['failure', 'timed_out', 'startup_failure']);
|
|
77
|
+
|
|
78
|
+
// The newest finished run of each workflow on the default branch, from a push to this repo. A run of a
|
|
79
|
+
// pull request, a schedule, or a fork is never read: its event is not push, or its head repo is another.
|
|
80
|
+
export function newestMainRuns(runs, { repo, branch }) {
|
|
81
|
+
const newest = new Map();
|
|
82
|
+
for (const run of runs) {
|
|
83
|
+
if (run?.event !== 'push' || run?.head_branch !== branch || run?.status !== 'completed') continue;
|
|
84
|
+
if (run?.head_repository?.full_name !== repo || run?.repository?.full_name !== repo) continue;
|
|
85
|
+
const path = String(run.path ?? '').replace(/@.*$/, '');
|
|
86
|
+
if (!path || !VALUE.test(path)) continue;
|
|
87
|
+
const have = newest.get(path);
|
|
88
|
+
if (!have || Date.parse(run.created_at) > Date.parse(have.created_at)) newest.set(path, run);
|
|
89
|
+
}
|
|
90
|
+
return newest;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function checkRedMain({ repo, branch, env }) {
|
|
94
|
+
const base = apiBase(env);
|
|
95
|
+
const key = env.GITHUB_TOKEN || '';
|
|
96
|
+
if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
|
|
97
|
+
const query = `branch=${encodeURIComponent(branch)}&event=push&status=completed&per_page=100`;
|
|
98
|
+
const r = await api(base, key, 'GET', `/repos/${repo}/actions/runs?${query}`);
|
|
99
|
+
if (!r.ok || !Array.isArray(r.data?.workflow_runs)) throw new Error(`GitHub answered ${r.status} when the runs of ${branch} were listed`);
|
|
100
|
+
const findings = [];
|
|
101
|
+
for (const [path, run] of [...newestMainRuns(r.data.workflow_runs, { repo, branch })].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
102
|
+
if (!RED.has(run.conclusion)) continue;
|
|
103
|
+
findings.push({ job: 'red-main', key: path, workflow: path, run_id: Number(run.id), head_sha: String(run.head_sha), conclusion: String(run.conclusion) });
|
|
104
|
+
}
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
|
|
109
|
+
// says so in the notes; it never reads `found` from nothing.
|
|
110
|
+
const CHECKS = { 'red-main': checkRedMain };
|
|
111
|
+
|
|
112
|
+
export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs() }) {
|
|
113
|
+
const named = namedJobs(root);
|
|
114
|
+
const notes = [];
|
|
115
|
+
const ran = [];
|
|
116
|
+
const findings = [];
|
|
117
|
+
let state = 'none';
|
|
118
|
+
let mainBranch = branch;
|
|
119
|
+
for (const id of named) {
|
|
120
|
+
const job = jobs.get(id);
|
|
121
|
+
if (!job) { notes.push(`${JOB_ID.test(id) ? id : '(a bad id)'}: no Atlas job has this id`); continue; }
|
|
122
|
+
const check = CHECKS[job.when];
|
|
123
|
+
if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
|
|
124
|
+
try {
|
|
125
|
+
if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
|
|
126
|
+
const found = (await check({ repo, branch: mainBranch, env })).map((f) => ({ ...f, job: id }));
|
|
127
|
+
findings.push(...found);
|
|
128
|
+
ran.push({ job: id, state: found.length ? 'found' : 'none' });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error.usage) throw error;
|
|
131
|
+
// The message never holds the token: it is built from fixed words and statuses.
|
|
132
|
+
ran.push({ job: id, state: 'broken' });
|
|
133
|
+
notes.push(`${id}: broken: ${String(error.message).split(env.GITHUB_TOKEN || '\u0000').join('(token)')}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (ran.some((one) => one.state === 'broken')) state = 'broken';
|
|
137
|
+
else if (findings.length) state = 'found';
|
|
138
|
+
return { jobs: ENVELOPE, repo, branch: mainBranch, state, ran, notes, findings };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function defaultBranch({ repo, env }) {
|
|
142
|
+
const r = await api(apiBase(env), env.GITHUB_TOKEN || '', 'GET', `/repos/${repo}`);
|
|
143
|
+
const name = r.data?.default_branch;
|
|
144
|
+
if (!r.ok || typeof name !== 'string' || !VALUE.test(name)) throw new Error(`GitHub answered ${r.status} when the default branch was read`);
|
|
145
|
+
return name;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------- the file job
|
|
149
|
+
|
|
150
|
+
// Keep a finding only when its job is an Atlas job, its key is typed, and it has at least one field.
|
|
151
|
+
// Keep only the fields that the job lists, and only typed values. Count what is dropped.
|
|
152
|
+
export function typedFindings(envelope, jobs = atlasJobs()) {
|
|
153
|
+
if (!envelope || envelope.jobs !== ENVELOPE || !STATES.includes(envelope.state) || !Array.isArray(envelope.findings)) {
|
|
154
|
+
throw usage('the findings file is not the output of `atlas jobs check`');
|
|
155
|
+
}
|
|
156
|
+
const kept = [];
|
|
157
|
+
let dropped = 0;
|
|
158
|
+
for (const f of envelope.findings.slice(0, MAX_FINDINGS)) {
|
|
159
|
+
const job = jobs.get(String(f?.job));
|
|
160
|
+
if (!job || !KEY.test(String(f?.key ?? ''))) { dropped += 1; continue; }
|
|
161
|
+
const one = { job: job.id, key: String(f.key) };
|
|
162
|
+
for (const field of job.fields) {
|
|
163
|
+
const value = f[field];
|
|
164
|
+
if (typeof value === 'number' && Number.isFinite(value)) one[field] = value;
|
|
165
|
+
else if (typeof value === 'string' && VALUE.test(value)) one[field] = value;
|
|
166
|
+
}
|
|
167
|
+
kept.push(one);
|
|
168
|
+
}
|
|
169
|
+
dropped += Math.max(0, envelope.findings.length - MAX_FINDINGS);
|
|
170
|
+
return { findings: kept, dropped };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// An HTML comment may not hold two hyphens in a row, so the marker spells them another way.
|
|
174
|
+
export const jobMarker = (job, key) => `<!-- atlas-job:${job}:${String(key).replace(/--/g, '-~')} -->`;
|
|
175
|
+
|
|
176
|
+
// The issue of one finding (records part, "The atlas-job issue").
|
|
177
|
+
export function jobIssue(finding) {
|
|
178
|
+
const marker = jobMarker(finding.job, finding.key);
|
|
179
|
+
const body = [
|
|
180
|
+
`The Atlas watcher found this in the job \`${finding.job}\`. Atlas code wrote this issue. It holds typed fields only.`,
|
|
181
|
+
'',
|
|
182
|
+
'```json',
|
|
183
|
+
JSON.stringify(finding, null, 2),
|
|
184
|
+
'```',
|
|
185
|
+
'',
|
|
186
|
+
'A runner acts on it later. The watcher never edits or closes this issue.',
|
|
187
|
+
'',
|
|
188
|
+
marker,
|
|
189
|
+
].join('\n');
|
|
190
|
+
return { title: `atlas-job: ${finding.job} ${finding.key}`.slice(0, 200), body, labels: [JOBS_LABEL, `job:${finding.job}`], marker };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A closed issue stops a new one only when the runner handed it over in the last 30 days.
|
|
194
|
+
export const handedOverStops = (now = Date.now()) => (issue) => (issue.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === HANDED_OVER)
|
|
195
|
+
&& Number.isFinite(Date.parse(issue.closed_at)) && now - Date.parse(issue.closed_at) <= HANDED_OVER_DAYS * DAY;
|
|
196
|
+
|
|
197
|
+
export async function fileJobs({ envelope, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, now = Date.now(), jobs = atlasJobs() }) {
|
|
198
|
+
const { findings, dropped } = typedFindings(envelope, jobs);
|
|
199
|
+
if (dropped) line(`dropped ${dropped} finding(s) that are not typed, or that name no Atlas job`);
|
|
200
|
+
if (envelope.state === 'broken') line('the check reads broken, so a check found nothing it can trust. The run ends red.');
|
|
201
|
+
const list = findings.map((f) => ({ key: `${f.job}:${f.key}`, issue: jobIssue(f) }));
|
|
202
|
+
const result = list.length
|
|
203
|
+
? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: handedOverStops(now) })
|
|
204
|
+
: (line('no finding: nothing to file.'), { opened: [], skipped: [], deferred: [], failed: [] });
|
|
205
|
+
return { ...result, broken: envelope.state === 'broken' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------- the command
|
|
209
|
+
|
|
210
|
+
export async function jobsCommand({ what, root, repo, branch, out, findings, max, dryRun = false, bot, args = [] }, line, env = process.env) {
|
|
211
|
+
try {
|
|
212
|
+
if ([...args, findings, repo, out, branch].some((a) => typeof a === 'string' && TOKEN_SHAPE.test(a))) throw usage('a token must never be an argument; put it in GITHUB_TOKEN');
|
|
213
|
+
if (!repoOk(repo)) throw usage('give the repo as --repo <owner/name>');
|
|
214
|
+
if (what === 'check') {
|
|
215
|
+
if (branch !== undefined && (typeof branch !== 'string' || !VALUE.test(branch))) throw usage('--branch must name a branch');
|
|
216
|
+
const result = await jobsCheck({ root, repo, branch: branch ?? null, env });
|
|
217
|
+
const json = `${JSON.stringify(result, null, 2)}\n`;
|
|
218
|
+
if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(resolve(out), json); }
|
|
219
|
+
line(`jobs check: ${result.state}. ${result.ran.length} job(s) ran, ${result.findings.length} finding(s).${out ? ` Written to ${out}.` : ''}`);
|
|
220
|
+
for (const one of result.ran) line(` ${one.job}: ${one.state}`);
|
|
221
|
+
for (const note of result.notes) line(` note: ${note}`);
|
|
222
|
+
if (!out) line(json.trimEnd());
|
|
223
|
+
return result.state === 'broken' ? 1 : 0;
|
|
224
|
+
}
|
|
225
|
+
if (what === 'file') {
|
|
226
|
+
if (!findings) throw usage('give the check file: --findings <file>');
|
|
227
|
+
const limit = max === undefined ? DEFAULT_MAX : Number(max);
|
|
228
|
+
if (!Number.isInteger(limit) || limit < 0) throw usage('--max must be a whole number, 0 or more');
|
|
229
|
+
const file = resolve(findings);
|
|
230
|
+
if (!existsSync(file)) throw usage(`the findings file ${file} does not exist`);
|
|
231
|
+
let envelope;
|
|
232
|
+
try { envelope = JSON.parse(readFileSync(file, 'utf8')); } catch { throw usage('the findings file is not JSON'); }
|
|
233
|
+
const r = await fileJobs({ envelope, repo, dryRun: Boolean(dryRun), max: limit, bot: bot || DEFAULT_BOT, env, line });
|
|
234
|
+
return r.broken || r.deferred.length || r.failed.length ? 1 : 0;
|
|
235
|
+
}
|
|
236
|
+
throw usage(`there is no jobs verb "${what ?? ''}". Use check or file.`);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
line(`jobs: ${String(error.message).split(env.GITHUB_TOKEN || '\u0000').join('(token)')}`);
|
|
239
|
+
return error.usage ? 2 : 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -23,7 +23,7 @@ export const TOOLING_LABEL = 'atlas-helper';
|
|
|
23
23
|
|
|
24
24
|
// The only files the tooling writer may write: the check, its workflow, and
|
|
25
25
|
// the pull request template when the repo has none.
|
|
26
|
-
export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/pull_request_template.md']);
|
|
26
|
+
export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/pull_request_template.md']);
|
|
27
27
|
|
|
28
28
|
export class ChangeError extends Error {}
|
|
29
29
|
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -24,6 +24,32 @@ import { PR_TEMPLATE_PATH } from './upkeep.mjs';
|
|
|
24
24
|
|
|
25
25
|
export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
|
|
26
26
|
|
|
27
|
+
// The watcher of the routines design (roadmap R24). The writer puts it into each
|
|
28
|
+
// repo, with the version of this package as its pin. It refreshes a copy whose pin
|
|
29
|
+
// is older than this package, and keeps any other copy: a refresh never moves a
|
|
30
|
+
// repo backwards, and never replaces a hand edit at the same version.
|
|
31
|
+
export const JOBS_PATH = '.github/workflows/atlas-jobs.yml';
|
|
32
|
+
export const PACKAGE_JOBS_SOURCE = 'door/templates/atlas-jobs.yml';
|
|
33
|
+
const VERSION_SLOT = '__ATLAS_VERSION__';
|
|
34
|
+
export function packagedJobsWorkflow(packageRoot) {
|
|
35
|
+
const full = join(packageRoot, PACKAGE_JOBS_SOURCE);
|
|
36
|
+
return existsSync(full) ? readFileSync(full, 'utf8').split(VERSION_SLOT).join(packagePackageVersion(packageRoot)) : null;
|
|
37
|
+
}
|
|
38
|
+
const jobsPin = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
|
|
39
|
+
const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
|
|
40
|
+
// written, refreshed, unchanged or kept: what the writer does with the watcher.
|
|
41
|
+
function jobsState(root, packageRoot) {
|
|
42
|
+
const wanted = packagedJobsWorkflow(packageRoot);
|
|
43
|
+
if (wanted === null) return null;
|
|
44
|
+
const full = join(root, JOBS_PATH);
|
|
45
|
+
if (!existsSync(full)) return 'written';
|
|
46
|
+
const was = readFileSync(full, 'utf8');
|
|
47
|
+
if (was === wanted) return 'unchanged';
|
|
48
|
+
const have = jobsPin(was);
|
|
49
|
+
return have && older(have, jobsPin(wanted)) ? 'refreshed' : 'kept';
|
|
50
|
+
}
|
|
51
|
+
const jobsNeedWrite = (root, packageRoot) => ['written', 'refreshed'].includes(jobsState(root, packageRoot));
|
|
52
|
+
|
|
27
53
|
// The pull request template (roadmap R8). The writer puts it into a repo that
|
|
28
54
|
// has none, and never replaces a template the repo keeps.
|
|
29
55
|
export const PACKAGE_TEMPLATE_SOURCE = 'door/templates/pull_request_template.md';
|
|
@@ -75,7 +101,7 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
|
|
|
75
101
|
throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
|
|
76
102
|
}
|
|
77
103
|
const now = status({ root, packageRoot });
|
|
78
|
-
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot)) {
|
|
104
|
+
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot)) {
|
|
79
105
|
throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
|
|
80
106
|
}
|
|
81
107
|
if (now.state === 'unknown' && !replaceUnknown && !own) {
|
|
@@ -101,6 +127,17 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
|
|
|
101
127
|
}
|
|
102
128
|
rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
|
|
103
129
|
}
|
|
130
|
+
const jobs = workflowOnly ? null : jobsState(root, packageRoot);
|
|
131
|
+
if (jobs !== null) {
|
|
132
|
+
const contents = packagedJobsWorkflow(packageRoot);
|
|
133
|
+
const full = join(root, JOBS_PATH);
|
|
134
|
+
const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
|
|
135
|
+
if ((jobs === 'written' || jobs === 'refreshed') && !dryRun) {
|
|
136
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
137
|
+
writeFileSync(full, contents);
|
|
138
|
+
}
|
|
139
|
+
rows.push({ path: JOBS_PATH, state: jobs, sha256: sha256(jobs === 'kept' ? was : contents), was: was === null ? null : sha256(was) });
|
|
140
|
+
}
|
|
104
141
|
const template = workflowOnly ? null : packagedTemplate(packageRoot);
|
|
105
142
|
if (template !== null) {
|
|
106
143
|
const contents = template;
|
|
@@ -125,7 +162,7 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
|
|
|
125
162
|
if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
|
|
126
163
|
throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
|
|
127
164
|
}
|
|
128
|
-
const wanted = { ...packagedFiles(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
|
|
165
|
+
const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
|
|
129
166
|
return rows
|
|
130
167
|
.filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
|
|
131
168
|
.map((row) => ({
|
|
@@ -137,7 +174,9 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
|
|
|
137
174
|
? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
|
|
138
175
|
: row.path === PR_TEMPLATE_PATH
|
|
139
176
|
? `copied from ${PACKAGE_TEMPLATE_SOURCE}, Atlas ${version}; the repo had no pull request template`
|
|
140
|
-
:
|
|
177
|
+
: row.path === JOBS_PATH
|
|
178
|
+
? `written from ${PACKAGE_JOBS_SOURCE}, with its pin set to Atlas ${version}`
|
|
179
|
+
: `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
|
|
141
180
|
},
|
|
142
181
|
}));
|
|
143
182
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
name: atlas-jobs
|
|
2
|
+
# The Atlas watcher: plain code, no model and no secret. `atlas tooling` wrote
|
|
3
|
+
# this file from the Atlas package; do not edit it by hand.
|
|
4
|
+
#
|
|
5
|
+
# It runs once a day from the default branch, and never on a pull request, so
|
|
6
|
+
# a pull request cannot change what it runs. It has two jobs:
|
|
7
|
+
# check reads the repo and the CI runs of the default branch. It holds only
|
|
8
|
+
# read permissions and the run's own token, and no repo secret.
|
|
9
|
+
# file holds only issues write. It runs Atlas code on the JSON of the
|
|
10
|
+
# check job, and files one `atlas-job` issue for each new finding.
|
|
11
|
+
# A job does nothing until atlas.yaml names it under `jobs: { atlas: [...] }`.
|
|
12
|
+
on:
|
|
13
|
+
schedule:
|
|
14
|
+
- cron: "17 4 * * *"
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
permissions: {}
|
|
18
|
+
|
|
19
|
+
concurrency:
|
|
20
|
+
group: atlas-jobs
|
|
21
|
+
cancel-in-progress: false
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
check:
|
|
25
|
+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
timeout-minutes: 10
|
|
28
|
+
permissions:
|
|
29
|
+
contents: read
|
|
30
|
+
actions: read
|
|
31
|
+
outputs:
|
|
32
|
+
findings: ${{ steps.check.outputs.findings }}
|
|
33
|
+
steps:
|
|
34
|
+
# actions/checkout v7.0.1
|
|
35
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
36
|
+
with:
|
|
37
|
+
persist-credentials: false
|
|
38
|
+
# actions/setup-node v7.0.0
|
|
39
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
40
|
+
with:
|
|
41
|
+
node-version: "22.22.0"
|
|
42
|
+
- name: Run the check of each job that atlas.yaml names
|
|
43
|
+
id: check
|
|
44
|
+
env:
|
|
45
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
46
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
47
|
+
run: |
|
|
48
|
+
set -euo pipefail
|
|
49
|
+
# A broken check still hands its JSON on, so the file job reads it and ends the run red.
|
|
50
|
+
status=0
|
|
51
|
+
npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ jobs check --root . --repo "$GITHUB_REPOSITORY" --branch "$DEFAULT_BRANCH" --out "$RUNNER_TEMP/jobs.json" | tee -a "$GITHUB_STEP_SUMMARY" || status=$?
|
|
52
|
+
test -s "$RUNNER_TEMP/jobs.json" || { echo "::error::the check wrote no JSON (exit $status)"; exit 1; }
|
|
53
|
+
echo "findings=$(base64 -w0 < "$RUNNER_TEMP/jobs.json")" >> "$GITHUB_OUTPUT"
|
|
54
|
+
|
|
55
|
+
file:
|
|
56
|
+
needs: check
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
timeout-minutes: 5
|
|
59
|
+
permissions:
|
|
60
|
+
issues: write
|
|
61
|
+
steps:
|
|
62
|
+
# actions/setup-node v7.0.0
|
|
63
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
64
|
+
with:
|
|
65
|
+
node-version: "22.22.0"
|
|
66
|
+
- name: File one issue for each new finding
|
|
67
|
+
env:
|
|
68
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
69
|
+
FINDINGS: ${{ needs.check.outputs.findings }}
|
|
70
|
+
run: |
|
|
71
|
+
set -euo pipefail
|
|
72
|
+
printf '%s' "$FINDINGS" | base64 -d > "$RUNNER_TEMP/jobs.json"
|
|
73
|
+
npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ jobs file --findings "$RUNNER_TEMP/jobs.json" --repo "$GITHUB_REPOSITORY" | tee -a "$GITHUB_STEP_SUMMARY"
|
package/hooks/door.mjs
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// The merge door: the second hook Atlas ships. Claude Code runs it before a
|
|
2
|
+
// tool call that can merge, push or write a file. On a plan with no
|
|
3
|
+
// rulesets GitHub blocks nothing, so this hook holds the gate in a session
|
|
4
|
+
// (design docs/design-docs/gate-advisory/, done line 8).
|
|
5
|
+
//
|
|
6
|
+
// It refuses five things, and lets every other call through:
|
|
7
|
+
// 1. A merge or an auto-merge, through a GitHub tool or `gh pr merge`, unless
|
|
8
|
+
// `atlas gate ready` said READY for that pull request in the last ten
|
|
9
|
+
// minutes, and the merge names the same head commit. The auto-merge tool
|
|
10
|
+
// cannot name a head, so it is always refused.
|
|
11
|
+
// 2. A merge through another door: `gh api` on a merge path, a GraphQL merge,
|
|
12
|
+
// `gh pr merge --admin`, or a plain HTTP call to a GitHub merge path.
|
|
13
|
+
// 3. A push to the default branch, a force push, and a push of all branches.
|
|
14
|
+
// 4. A file write to the default branch through a GitHub tool.
|
|
15
|
+
// 5. A change to the READY answers, a change to a Claude settings file by a
|
|
16
|
+
// command or to its hook and plugin switches, and turning the plugin off.
|
|
17
|
+
//
|
|
18
|
+
// Its limits:
|
|
19
|
+
// 1. It makes no network call and starts no program. It reads the tool call,
|
|
20
|
+
// the READY answer file, and the git folder of the repo it pushes from.
|
|
21
|
+
// 2. It writes nothing. It never answers "allow": a call it lets through
|
|
22
|
+
// still goes to the person's own permission rules.
|
|
23
|
+
// 3. It matches text. A command it cannot read as a push or a merge goes
|
|
24
|
+
// through. The push audit job and `atlas gate audit` stay the backstop.
|
|
25
|
+
// 4. ATLAS_HOOKS=off, set outside the session, turns it off.
|
|
26
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
export const FRESH_MS = 10 * 60 * 1000;
|
|
32
|
+
export const DEFAULTS = ['main', 'master'];
|
|
33
|
+
const SHA = /^[0-9a-f]{40}$/i;
|
|
34
|
+
|
|
35
|
+
// The folder where `atlas gate ready` keeps each READY answer.
|
|
36
|
+
export const readyFolder = (home = homedir()) => join(home, '.config', 'atlas', 'gate-ready');
|
|
37
|
+
export const readyPath = (repo, pr, home = homedir()) => join(readyFolder(home), ...String(repo).toLowerCase().split('/'), String(pr));
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------------ the door
|
|
40
|
+
|
|
41
|
+
// One tool call in, one answer out: null lets it through, a string refuses it.
|
|
42
|
+
export function judge(call, { home = homedir(), now = Date.now() } = {}) {
|
|
43
|
+
const tool = String(call?.tool_name ?? '');
|
|
44
|
+
const input = call?.tool_input ?? {};
|
|
45
|
+
const cwd = typeof call?.cwd === 'string' ? call.cwd : process.cwd();
|
|
46
|
+
if (tool === 'Bash') return judgeCommand(String(input.command ?? ''), { cwd, home, now });
|
|
47
|
+
if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) return judgeWrite(input, { cwd, home });
|
|
48
|
+
if (!tool.startsWith('mcp__')) return null;
|
|
49
|
+
const verb = tool.slice(tool.lastIndexOf('__') + 2);
|
|
50
|
+
const repo = `${input.owner ?? ''}/${input.repo ?? ''}`;
|
|
51
|
+
if (verb === 'merge_pull_request') return needReady({ repo, pr: input.pullNumber, sha: input.expectedHeadSha, home, now, how: 'the expectedHeadSha argument' });
|
|
52
|
+
// This tool cannot name the head, so a later push could merge with no READY.
|
|
53
|
+
if (verb === 'enable_pr_auto_merge') return 'the merge door refuses enable_pr_auto_merge: it cannot name the head that ready read. Run "atlas gate ready", then merge with the head SHA, or use "gh pr merge --auto --match-head-commit <sha>".';
|
|
54
|
+
if (['push_files', 'create_or_update_file', 'delete_file'].includes(verb)) {
|
|
55
|
+
const branch = String(input.branch ?? '').replace(/^refs\/heads\//, '');
|
|
56
|
+
if (!branch) return `the merge door refuses ${verb} with no branch: GitHub would write to the default branch. Name a work branch.`;
|
|
57
|
+
if (defaultsFor(repo, cwd).has(branch)) return `the merge door refuses ${verb} on ${branch}, the default branch. Write to a work branch and open a pull request.`;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A merge waits for a fresh READY at the same head.
|
|
63
|
+
function needReady({ repo, pr, sha, home, now, how }) {
|
|
64
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.split('/').some((part) => /^\.+$/.test(part)) || !/^\d+$/.test(String(pr ?? ''))) return 'the merge door refuses a merge that does not name its repo and pull request number.';
|
|
65
|
+
const run = `atlas gate ready --repo ${repo} --pr ${pr}`;
|
|
66
|
+
let answer;
|
|
67
|
+
try { answer = JSON.parse(readFileSync(readyPath(repo, pr, home), 'utf8')); } catch {
|
|
68
|
+
return `the merge door refuses the merge of ${repo}#${pr}: there is no READY answer for it. Run "${run}" right before the merge.`;
|
|
69
|
+
}
|
|
70
|
+
const at = Date.parse(answer?.at ?? '');
|
|
71
|
+
if (String(answer?.repo ?? '').toLowerCase() !== repo.toLowerCase() || String(answer?.pr) !== String(pr) || !SHA.test(String(answer?.sha ?? '')) || Number.isNaN(at)) {
|
|
72
|
+
return `the merge door refuses the merge of ${repo}#${pr}: its READY answer is not well formed. Run "${run}" again.`;
|
|
73
|
+
}
|
|
74
|
+
if (now - at > FRESH_MS || at - now > 60 * 1000) return `the merge door refuses the merge of ${repo}#${pr}: its READY answer is older than ten minutes. Run "${run}" again.`;
|
|
75
|
+
if (!sha) return `the merge door refuses the merge of ${repo}#${pr}: name the head that ready read (${answer.sha}) in ${how}.`;
|
|
76
|
+
if (String(sha).toLowerCase() !== answer.sha.toLowerCase()) return `the merge door refuses the merge of ${repo}#${pr}: the merge names ${String(sha).slice(0, 7)}, but ready read ${answer.sha.slice(0, 7)}. Run "${run}" on the new head.`;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ------------------------------------------------------------- file writes
|
|
81
|
+
|
|
82
|
+
const SWITCH = /ATLAS_HOOKS|disableAllHooks|enabledPlugins|atlas@|"hooks"\s*:/;
|
|
83
|
+
// A command with one of these words can write a file.
|
|
84
|
+
const WRITES = /(>|\b(tee|cp|mv|touch|rm|mkdir|ln|chmod|install|rsync|dd|truncate|node|python3?|perl|ruby|jq)\b|\bsed\b[^|;&]*\s-i)/;
|
|
85
|
+
const SETTINGS = /(^|[\\/])\.claude[\\/]settings(\.local)?\.json$/;
|
|
86
|
+
|
|
87
|
+
function judgeWrite(input, { cwd, home }) {
|
|
88
|
+
const raw = String(input.file_path ?? input.notebook_path ?? '');
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
const path = resolve(cwd, raw.replace(/^~(?=[\\/])/, home));
|
|
91
|
+
const ready = readyFolder(home);
|
|
92
|
+
if (path === ready || path.startsWith(ready + '/') || path.startsWith(ready + '\\')) return 'the merge door refuses a write to the READY answers (~/.config/atlas/gate-ready): only "atlas gate ready" writes them.';
|
|
93
|
+
if (SETTINGS.test(path)) {
|
|
94
|
+
// The old text counts too: an edit of true to false names no switch word.
|
|
95
|
+
const text = [input.content, input.old_string, input.new_string, ...(input.edits ?? []).flatMap((edit) => [edit?.old_string, edit?.new_string])].filter((part) => typeof part === 'string').join('\n');
|
|
96
|
+
if (SWITCH.test(text)) return 'the merge door refuses a change to the hooks or the plugins in a Claude settings file. Only the person turns the Atlas hooks off, outside the session.';
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------- commands
|
|
102
|
+
|
|
103
|
+
// Split a command line into simple commands, and each one into words. Quotes
|
|
104
|
+
// and backslashes are read the way the shell reads them. A `(` ends a command
|
|
105
|
+
// too. judgeCommand reads the inside of each `$(...)` and backtick pair again,
|
|
106
|
+
// since a shell runs it even inside double quotes.
|
|
107
|
+
export function commandsOf(text) {
|
|
108
|
+
const out = [];
|
|
109
|
+
let words = [];
|
|
110
|
+
let word = null;
|
|
111
|
+
const endWord = () => { if (word !== null) words.push(word); word = null; };
|
|
112
|
+
const endCommand = (op) => { endWord(); if (words.length) out.push({ words, op }); words = []; };
|
|
113
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
114
|
+
const c = text[i];
|
|
115
|
+
if (c === "'") { const end = text.indexOf("'", i + 1); word = (word ?? '') + text.slice(i + 1, end < 0 ? text.length : end); i = end < 0 ? text.length : end; continue; }
|
|
116
|
+
if (c === '"') {
|
|
117
|
+
word = word ?? '';
|
|
118
|
+
for (i += 1; i < text.length && text[i] !== '"'; i += 1) word += text[i] === '\\' && i + 1 < text.length ? text[++i] : text[i];
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (c === '\\' && i + 1 < text.length) { if (text[i + 1] !== '\n') word = (word ?? '') + text[i + 1]; i += 1; continue; }
|
|
122
|
+
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')') { endCommand(c); continue; }
|
|
123
|
+
if (c === ' ' || c === '\t') { endWord(); continue; }
|
|
124
|
+
if (c === '#' && word === null) { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end - 1; continue; }
|
|
125
|
+
word = (word ?? '') + c;
|
|
126
|
+
}
|
|
127
|
+
endCommand('');
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Leading words that run the next word as the command. `timeout` also takes
|
|
132
|
+
// a time. A command named by its full path counts by its last name.
|
|
133
|
+
const WRAPPERS = new Set(['sudo', 'command', 'exec', 'nohup', 'time', 'env', 'builtin', 'xargs', 'nice', 'timeout', 'stdbuf']);
|
|
134
|
+
const baseName = (word) => String(word ?? '').replace(/^.*\//, '');
|
|
135
|
+
function commandWords(words) {
|
|
136
|
+
let i = 0;
|
|
137
|
+
for (;;) {
|
|
138
|
+
const word = words[i];
|
|
139
|
+
if (word === undefined) break;
|
|
140
|
+
if (WRAPPERS.has(baseName(word))) {
|
|
141
|
+
i += 1;
|
|
142
|
+
while (words[i]?.startsWith('-')) i += 1;
|
|
143
|
+
if (baseName(word) === 'timeout' && /^\d/.test(words[i] ?? '')) i += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) { i += 1; continue; }
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const out = words.slice(i);
|
|
150
|
+
if (out.length) out[0] = baseName(out[0]);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The text a shell reads for `$(...)` and backticks: without single-quoted
|
|
155
|
+
// strings and without here-documents whose end word is quoted.
|
|
156
|
+
const unquoted = (text) => text.replace(/<<-?\s*(['"])(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\n|$)/g, ' ').replace(/'[^']*'/g, "''");
|
|
157
|
+
// The command lines only: a here-document body is data for its command.
|
|
158
|
+
const noHereDocs = (text) => text.replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2(?=\n|$)/g, ' $3');
|
|
159
|
+
|
|
160
|
+
const GH_MERGE_PATH = /(^|\/)repos\/[^/\s]+\/[^/\s]+\/(pulls\/\d+\/merge|merges)\b|(^|\/)pulls\/\d+\/merge\b/;
|
|
161
|
+
const GH_REFS_PATH = /(^|\/)git\/refs\b/;
|
|
162
|
+
const GQL_MERGE = /\b(mergePullRequest|enablePullRequestAutoMerge|mergeBranch|updateRefs?|deleteRef)\b/;
|
|
163
|
+
const HTTP_MERGE = /api\.github\.com\/repos\/[^\s'"]+\/(pulls\/\d+\/merge|merges|git\/refs)/;
|
|
164
|
+
|
|
165
|
+
function judgeCommand(text, { cwd, home, now }) {
|
|
166
|
+
// 5. The READY answers, the key and the hook switch are not the session's
|
|
167
|
+
// to change, by any command.
|
|
168
|
+
if (/gate-ready/.test(text) && WRITES.test(text)) return 'the merge door refuses a command that names the READY answers (~/.config/atlas/gate-ready) and can write a file: only "atlas gate ready" writes them.';
|
|
169
|
+
if (/\.claude[\\/]settings(\.local)?\.json/.test(text) && (SWITCH.test(text) || WRITES.test(text))) return 'the merge door refuses a command that changes a Claude settings file. The person changes the hooks and the plugins, outside the session.';
|
|
170
|
+
if (/\bclaude\s+plugins?\s+(disable|uninstall|remove)\b[^|;&]*\batlas\b/.test(text)) return 'the merge door refuses to turn the Atlas plugin off. The person runs that command, outside the session.';
|
|
171
|
+
if (HTTP_MERGE.test(text) && /\b(curl|wget|fetch|http|requests|urllib)\b/.test(text)) return 'the merge door refuses a merge or a branch write through a plain call to the GitHub API. Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.';
|
|
172
|
+
for (const [, inner] of [...unquoted(text).matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)].map((match) => [match[0], match[1] ?? match[2]])) {
|
|
173
|
+
const why = judgeCommand(inner, { cwd, home, now });
|
|
174
|
+
if (why) return why;
|
|
175
|
+
}
|
|
176
|
+
let here = cwd;
|
|
177
|
+
for (const { words: all } of commandsOf(noHereDocs(text))) {
|
|
178
|
+
const words = commandWords(all);
|
|
179
|
+
if (words[0] === 'cd' && words[1] && words[1] !== '-') { here = resolve(here, words[1].replace(/^~(?=\/|$)/, home)); continue; }
|
|
180
|
+
// A command line handed to a shell or to eval is read the same way.
|
|
181
|
+
const flag = words.findIndex((word, k) => k > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
|
|
182
|
+
const inner = ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(words[0]) && flag > 0 ? words[flag + 1] : words[0] === 'eval' ? words.slice(1).join(' ') : null;
|
|
183
|
+
if (inner) {
|
|
184
|
+
const why = judgeCommand(inner, { cwd: here, home, now });
|
|
185
|
+
if (why) return why;
|
|
186
|
+
}
|
|
187
|
+
if (words[0] === 'git') {
|
|
188
|
+
const why = judgeGit(words, here, home);
|
|
189
|
+
if (why) return why;
|
|
190
|
+
}
|
|
191
|
+
if (words[0] === 'gh') {
|
|
192
|
+
const why = judgeGh(words, { home, now, cwd: here });
|
|
193
|
+
if (why) return why;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// -------------------------------------------------------------------- git
|
|
200
|
+
|
|
201
|
+
function judgeGit(words, cwd, home) {
|
|
202
|
+
let i = 1;
|
|
203
|
+
let dir = cwd;
|
|
204
|
+
while (i < words.length && words[i].startsWith('-')) {
|
|
205
|
+
if (words[i] === '-C') { dir = resolve(dir, String(words[i + 1] ?? '').replace(/^~(?=\/|$)/, home)); i += 2; continue; }
|
|
206
|
+
if (words[i] === '-c') { i += 2; continue; }
|
|
207
|
+
i += 1;
|
|
208
|
+
}
|
|
209
|
+
if (words[i] !== 'push') return null;
|
|
210
|
+
const args = words.slice(i + 1);
|
|
211
|
+
const spots = [];
|
|
212
|
+
let deleting = false;
|
|
213
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
214
|
+
const arg = args[k];
|
|
215
|
+
if (/^\d*[<>]/.test(arg) || /^&>/.test(arg)) { if (/^\d*[<>]+&?$|^&>$/.test(arg)) k += 1; continue; }
|
|
216
|
+
if (arg === '--') { spots.push(...args.slice(k + 1)); break; }
|
|
217
|
+
if (/^--(force|force-with-lease|force-if-includes|mirror)(=|$)/.test(arg)) return `the merge door refuses a force push (${arg.split('=')[0]}). Merge the main branch into your branch, and push again.`;
|
|
218
|
+
if (/^--(all|branches)$/.test(arg)) return `the merge door refuses "git push ${arg}": it pushes the default branch too. Push one work branch.`;
|
|
219
|
+
if (arg === '--delete') { deleting = true; continue; }
|
|
220
|
+
if (/^--(repo|receive-pack|exec|push-option|signed)$/.test(arg) || arg === '-o') { k += 1; continue; }
|
|
221
|
+
if (arg.startsWith('--')) continue;
|
|
222
|
+
if (/^-[A-Za-z]+$/.test(arg)) {
|
|
223
|
+
if (arg.includes('f')) return 'the merge door refuses a force push (-f). Merge the main branch into your branch, and push again.';
|
|
224
|
+
if (arg.includes('d')) deleting = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
spots.push(arg);
|
|
228
|
+
}
|
|
229
|
+
const git = gitFolder(dir);
|
|
230
|
+
const defaults = new Set([...DEFAULTS, ...(git ? remoteDefaults(git.common) : [])]);
|
|
231
|
+
const current = () => (git ? currentBranch(git.dir) : null);
|
|
232
|
+
const refs = spots.slice(1);
|
|
233
|
+
if (!refs.length) {
|
|
234
|
+
const branch = current();
|
|
235
|
+
if (!deleting && branch && defaults.has(branch)) return `the merge door refuses a push from ${branch}, the default branch. Make a work branch and open a pull request.`;
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
for (const ref of refs) {
|
|
239
|
+
if (ref.startsWith('+')) return `the merge door refuses a force push ("${ref}"). Merge the main branch into your branch, and push again.`;
|
|
240
|
+
const colon = ref.indexOf(':');
|
|
241
|
+
const source = colon >= 0 ? ref.slice(0, colon) : ref;
|
|
242
|
+
let target = colon >= 0 ? ref.slice(colon + 1) || source : ref;
|
|
243
|
+
if (target === 'HEAD' || target === '@') target = current() ?? '';
|
|
244
|
+
target = target.replace(/^refs\/heads\//, '');
|
|
245
|
+
if (!target || !defaults.has(target)) continue;
|
|
246
|
+
if (deleting || source === '') return `the merge door refuses a push that deletes ${target}, the default branch.`;
|
|
247
|
+
return `the merge door refuses a push to ${target}, the default branch. Push a work branch and open a pull request.`;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The git folder of a work tree: { dir, common }. A linked work tree keeps
|
|
253
|
+
// HEAD in its own folder and the remotes in the common one.
|
|
254
|
+
export function gitFolder(start) {
|
|
255
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
256
|
+
const dot = join(dir, '.git');
|
|
257
|
+
try {
|
|
258
|
+
if (statSync(dot).isDirectory()) return { dir: dot, common: dot };
|
|
259
|
+
const link = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf8'))?.[1]?.trim();
|
|
260
|
+
if (link) {
|
|
261
|
+
const own = isAbsolute(link) ? link : resolve(dir, link);
|
|
262
|
+
let common = own;
|
|
263
|
+
try { const shared = readFileSync(join(own, 'commondir'), 'utf8').trim(); common = isAbsolute(shared) ? shared : resolve(own, shared); } catch { /* not a linked work tree */ }
|
|
264
|
+
return { dir: own, common };
|
|
265
|
+
}
|
|
266
|
+
} catch { /* no .git here */ }
|
|
267
|
+
if (dirname(dir) === dir) return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function currentBranch(gitDir) {
|
|
272
|
+
try { return /^ref:\s*refs\/heads\/(.+)$/m.exec(readFileSync(join(gitDir, 'HEAD'), 'utf8'))?.[1]?.trim() ?? null; } catch { return null; }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The default branch names for one repo: main, master, and the remote HEAD of
|
|
276
|
+
// the session's own copy when its origin is that repo.
|
|
277
|
+
function defaultsFor(repo, cwd) {
|
|
278
|
+
const out = new Set(DEFAULTS);
|
|
279
|
+
const git = gitFolder(cwd);
|
|
280
|
+
if (git && String(originRepo(cwd) ?? '').toLowerCase() === String(repo).toLowerCase()) for (const name of remoteDefaults(git.common)) out.add(name);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// owner/name of the origin remote, from the git config, when it is on GitHub.
|
|
285
|
+
function originRepo(cwd) {
|
|
286
|
+
const git = gitFolder(cwd);
|
|
287
|
+
if (!git) return null;
|
|
288
|
+
try {
|
|
289
|
+
const config = readFileSync(join(git.common, 'config'), 'utf8');
|
|
290
|
+
const block = /\[remote "origin"\]([^[]*)/.exec(config)?.[1] ?? '';
|
|
291
|
+
const url = /^\s*url\s*=\s*(.+)$/m.exec(block)?.[1]?.trim() ?? '';
|
|
292
|
+
return /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(url)?.[1] ?? null;
|
|
293
|
+
} catch { return null; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function remoteDefaults(common) {
|
|
297
|
+
const out = [];
|
|
298
|
+
for (const remote of ['origin', 'upstream']) {
|
|
299
|
+
try {
|
|
300
|
+
const head = /^ref:\s*refs\/remotes\/[^/]+\/(.+)$/m.exec(readFileSync(join(common, 'refs', 'remotes', remote, 'HEAD'), 'utf8'))?.[1]?.trim();
|
|
301
|
+
if (head) out.push(head);
|
|
302
|
+
} catch { /* no such remote */ }
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --------------------------------------------------------------------- gh
|
|
308
|
+
|
|
309
|
+
function judgeGh(words, { home, now, cwd }) {
|
|
310
|
+
const [, group, verb] = words;
|
|
311
|
+
if (group === 'api') {
|
|
312
|
+
let method = null;
|
|
313
|
+
let path = null;
|
|
314
|
+
let fields = false;
|
|
315
|
+
const values = [];
|
|
316
|
+
const rest = words.slice(2);
|
|
317
|
+
for (let k = 0; k < rest.length; k += 1) {
|
|
318
|
+
const arg = rest[k];
|
|
319
|
+
const [name, joined] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, null];
|
|
320
|
+
const value = () => String(joined !== null ? joined : rest[++k] ?? '');
|
|
321
|
+
if (name === '-X' || name === '--method') { method = value().toUpperCase(); continue; }
|
|
322
|
+
if (/^-X[A-Za-z]+$/.test(name)) { method = name.slice(2).toUpperCase(); continue; }
|
|
323
|
+
if (['-f', '-F', '--field', '--raw-field', '--input'].includes(name)) { fields = true; values.push(value()); continue; }
|
|
324
|
+
if (['-H', '--header', '-q', '--jq', '-t', '--template', '--hostname', '--cache', '-p', '--preview'].includes(name)) { value(); continue; }
|
|
325
|
+
if (name.startsWith('-')) continue;
|
|
326
|
+
if (path === null) path = arg;
|
|
327
|
+
}
|
|
328
|
+
method = method ?? (fields ? 'POST' : 'GET');
|
|
329
|
+
path = String(path ?? '').split('?')[0];
|
|
330
|
+
if (path === 'graphql' || /\/graphql$/.test(path)) return GQL_MERGE.test(values.join(' ')) ? 'the merge door refuses a merge or a branch write through GraphQL. Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.' : null;
|
|
331
|
+
if (method === 'GET') return null;
|
|
332
|
+
const newBranch = method === 'POST' && /(^|\/)git\/refs$/.test(path);
|
|
333
|
+
if (GH_MERGE_PATH.test(path) || (GH_REFS_PATH.test(path) && !newBranch)) return `the merge door refuses "gh api --method ${method} ${path}". Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.`;
|
|
334
|
+
if (/\/contents\//.test(path)) {
|
|
335
|
+
const branch = values.map((each) => /^branch=(.+)$/.exec(each)?.[1]).find(Boolean);
|
|
336
|
+
if (!branch || defaultsFor(/^\/?repos\/([^/]+\/[^/]+)\//.exec(path)?.[1] ?? '', cwd).has(branch)) return 'the merge door refuses a file write through "gh api" to the default branch. Name a work branch with -f branch=<name>.';
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
if (group !== 'pr' || verb !== 'merge') return null;
|
|
341
|
+
const args = words.slice(3);
|
|
342
|
+
let repo = null;
|
|
343
|
+
let pr = null;
|
|
344
|
+
let sha = null;
|
|
345
|
+
let undo = false;
|
|
346
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
347
|
+
const arg = args[k];
|
|
348
|
+
const [name, joined] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, null];
|
|
349
|
+
const value = () => (joined !== null ? joined : args[++k]);
|
|
350
|
+
if (name === '--admin') return 'the merge door refuses "gh pr merge --admin": it skips the checks.';
|
|
351
|
+
if (name === '--disable-auto') { undo = true; continue; }
|
|
352
|
+
if (name === '-R' || name === '--repo') { repo = value(); continue; }
|
|
353
|
+
if (name === '--match-head-commit') { sha = value(); continue; }
|
|
354
|
+
if (['-b', '--body', '-F', '--body-file', '-t', '--subject', '-A', '--author-email'].includes(name)) { value(); continue; }
|
|
355
|
+
if (name.startsWith('-')) continue;
|
|
356
|
+
const url = /github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/.exec(arg);
|
|
357
|
+
if (url) { repo = repo ?? url[1]; pr = url[2]; continue; }
|
|
358
|
+
if (/^#?\d+$/.test(arg)) pr = arg.replace('#', '');
|
|
359
|
+
}
|
|
360
|
+
if (undo) return null;
|
|
361
|
+
repo = repo ?? originRepo(cwd);
|
|
362
|
+
if (!repo || !pr) return 'the merge door refuses "gh pr merge" without a pull request number, and a --repo <owner/name> or a GitHub origin. It cannot match the merge to a READY answer otherwise.';
|
|
363
|
+
repo = repo.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
364
|
+
return needReady({ repo, pr, sha, home, now, how: '--match-head-commit' });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ------------------------------------------------------------------- main
|
|
368
|
+
|
|
369
|
+
function main(input) {
|
|
370
|
+
if (process.env.ATLAS_HOOKS === 'off') return;
|
|
371
|
+
let call;
|
|
372
|
+
try { call = JSON.parse(input); } catch { return; }
|
|
373
|
+
let why;
|
|
374
|
+
try { why = judge(call); } catch (error) {
|
|
375
|
+
// A merge or push that the door cannot read is refused; anything else goes on.
|
|
376
|
+
if (!/merge|push|create_or_update_file|delete_file/.test(String(call?.tool_name ?? '') + JSON.stringify(call?.tool_input ?? {}))) return;
|
|
377
|
+
why = `the merge door could not read this call (${error.message}), so it refuses it.`;
|
|
378
|
+
}
|
|
379
|
+
if (!why) return;
|
|
380
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `Atlas: ${why}` } }));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
384
|
+
const chunks = [];
|
|
385
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
386
|
+
process.stdin.on('end', () => { main(Buffer.concat(chunks).toString('utf8')); process.exit(0); });
|
|
387
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas.",
|
|
2
|
+
"description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas, and keeps the merge door: no merge without a fresh READY, no push to the default branch, no force push.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
}
|
|
14
|
+
],
|
|
15
|
+
"PreToolUse": [
|
|
16
|
+
{
|
|
17
|
+
"matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*(merge_pull_request|enable_pr_auto_merge|push_files|create_or_update_file|delete_file)",
|
|
18
|
+
"hooks": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/door.mjs\"",
|
|
22
|
+
"timeout": 5
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
14
26
|
]
|
|
15
27
|
}
|
|
16
28
|
}
|
package/hooks/lead.sh
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# The
|
|
2
|
+
# The first hook Atlas ships. At the start of a session it prints the lead
|
|
3
3
|
# skill, so a repo that runs Atlas gets the lead with no step from its people.
|
|
4
4
|
#
|
|
5
5
|
# Its limits are the reason the package may ship it:
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -104,6 +104,7 @@ set. A test keeps the two lists equal.
|
|
|
104
104
|
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `changed`, `before` and `after` run the test checks of the gate: changed tests, new cases fail on old code, old cases pass on new code. `sweep` and `issues` run the daily sweep |
|
|
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
|
+
| `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
|
|
107
108
|
| `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
109
|
| `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
109
110
|
| `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 |
|
|
@@ -153,6 +153,11 @@ A lock is the owner's approval of a short design.
|
|
|
153
153
|
the merge call, so a later push cannot slip in.
|
|
154
154
|
4. Where the verb `apply` set the rulesets, the branch rule holds the
|
|
155
155
|
merge. Turn on auto-merge there, and never press merge.
|
|
156
|
+
5. The merge door hook holds this step in the session. It refuses a
|
|
157
|
+
merge with no READY from the last ten minutes, or at another head.
|
|
158
|
+
It refuses a push to the main branch and a force push. It refuses the
|
|
159
|
+
auto-merge tool; `gh pr merge --auto --match-head-commit` names the
|
|
160
|
+
head. When it refuses, do what its reason says. Never work around it.
|
|
156
161
|
|
|
157
162
|
## pause
|
|
158
163
|
|
package/skills/upkeep/SKILL.md
CHANGED
|
@@ -94,8 +94,10 @@ If the tracker cannot be reached, skip this half and say so.
|
|
|
94
94
|
"Upkeep <date>: <what changed>". The body lists each change on one line.
|
|
95
95
|
It ends with "no item: the daily upkeep run".
|
|
96
96
|
7. Wait for every check on the pull request. When all are green and the
|
|
97
|
-
diff changes only index lines and link targets,
|
|
98
|
-
|
|
97
|
+
diff changes only index lines and link targets, run `atlas gate` with
|
|
98
|
+
the verb `ready`, `--repo` and `--pr`. On READY, merge it with a merge
|
|
99
|
+
commit at the head SHA that ready read. The merge door hook refuses any
|
|
100
|
+
other merge. Otherwise leave it open and say why in the report.
|
|
99
101
|
|
|
100
102
|
## The run line
|
|
101
103
|
|
package/tests/sweep.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// 1 flaky each scenario/assertion with a `flaky` result, and on how many commits
|
|
11
11
|
// 2 stand-ins each contract scenario whose newest result is `blocked` (no test instance)
|
|
12
12
|
// 3 no scenario a pull request (a first-parent change of the base) changed a path that a scenario `covers`,
|
|
13
|
-
// and no scenario that covers that path changed in the same pull request
|
|
13
|
+
// and no scenario that covers that path changed in the same pull request, or in a later
|
|
14
|
+
// pull request of the window
|
|
14
15
|
// 4 quarantine NOT BUILT. The kit has no quarantine field and no `quarantined` result yet
|
|
15
16
|
// (RESULTS in evidence.mjs), so this check reports none and says so. No format is invented.
|
|
16
17
|
// 5 map facts NOT BUILT. link-check.mjs reads no map format, so the check is skipped with a note
|
|
@@ -31,6 +32,10 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
|
31
32
|
import { readAttempts, settleRecords, loadAreaScenarios, clean, touches, keyOf } from './verdict.mjs';
|
|
32
33
|
|
|
33
34
|
export const LABEL = 'atlas-sweep';
|
|
35
|
+
// The labels of the routines design (records part): every issue that a watcher files carries `atlas-job`
|
|
36
|
+
// and the label of its job. The sweep is the check of the job `flaky-tests`. The marker rule and the
|
|
37
|
+
// label LABEL stay as they were, so the issues of older sweeps still stop a second one.
|
|
38
|
+
export const JOB_LABELS = Object.freeze(['atlas-job', 'job:flaky-tests']);
|
|
34
39
|
const DAY = 24 * 60 * 60 * 1000;
|
|
35
40
|
// A pull request is found by the next sweep too, so two days make a sweep that missed a day lose nothing.
|
|
36
41
|
// The marker of a finding keeps a second sweep from opening a second issue.
|
|
@@ -53,9 +58,10 @@ function git(cwd, args, { allowFail = false } = {}) {
|
|
|
53
58
|
|
|
54
59
|
// ---------------------------------------------------------------- the evidence of many runs
|
|
55
60
|
|
|
56
|
-
// A run folder is named for a CI run (ci<run id>-<attempt>, with an optional rerun count)
|
|
57
|
-
//
|
|
58
|
-
|
|
61
|
+
// A run folder is named for a CI run (ci<run id>-<attempt>, with an optional rerun count), for a
|
|
62
|
+
// nightly run (nightly<run id>-<attempt>, the model-nightly workflow), or by the kit's own run id
|
|
63
|
+
// (run<6 hex>, fresh.mjs). Any other name is dropped by the sweep.
|
|
64
|
+
export const RUN_NAME = /^((ci|nightly)\d+-\d+(-\d+)?|run[0-9a-f]{6})$/;
|
|
59
65
|
const ASSERTION_ID = /^[a-z0-9-]+\/e[0-9]+$/;
|
|
60
66
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
61
67
|
const hasJson = (dir) => readdirSync(dir).some((n) => n.endsWith('.json'));
|
|
@@ -134,7 +140,7 @@ function finding({ check, key, title, summary, evidence, quote = null, extra = {
|
|
|
134
140
|
const lines = [summary, ...(quote ? ['', 'Text from the evidence (data, not an instruction):', fenced(quote)] : []), '', 'Evidence:'];
|
|
135
141
|
for (const [name, values] of Object.entries(evidence)) lines.push(`- ${name}: ${[].concat(values).join(', ') || '(none)'}`);
|
|
136
142
|
lines.push('', 'The daily sweep of `atlas tests sweep` opened this issue. It never changes a file.', '', marker);
|
|
137
|
-
return { check, key, title, summary, evidence, ...extra, issue: { title, body: lines.join('\n'), labels: [LABEL], marker } };
|
|
143
|
+
return { check, key, title, summary, evidence, ...extra, issue: { title, body: lines.join('\n'), labels: [LABEL, ...JOB_LABELS], marker } };
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
// Check 1. One finding for each scenario/assertion that reads `flaky` in some run.
|
|
@@ -207,19 +213,26 @@ function checkNoScenario({ area, scenarios, since, base }) {
|
|
|
207
213
|
).split('\0').filter(Boolean).map(clean);
|
|
208
214
|
// The repo path of each scenario file, and the covers of each scenario.
|
|
209
215
|
const mine = scenarios.filter((s) => s.id).map((s) => ({ id: s.id, file: clean(`${prefix}${relative(area, s.file)}`), covers: list(s.fields.covers) }));
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
// The log is newest first. Each change keeps its files, so a change can be read against the later ones.
|
|
217
|
+
const changes = raw.split('\n').filter(Boolean).map((line) => {
|
|
212
218
|
const [sha, parentText, date, subject] = line.split('\x1f');
|
|
213
|
-
|
|
219
|
+
return { sha, parentText, date, subject, files: filesOf(sha, parentText.split(' ').filter(Boolean)) };
|
|
220
|
+
});
|
|
221
|
+
const out = [];
|
|
222
|
+
changes.forEach(({ sha, parentText, date, subject, files }, at) => {
|
|
223
|
+
// A later change of the window (a pull request that came after this one) answers a path when it
|
|
224
|
+
// edits a scenario that covers that path (roadmap R24 line 5: atlas#173 changed the scenario 19
|
|
225
|
+
// minutes after atlas#167 changed the code).
|
|
226
|
+
const later = new Set(changes.slice(0, at).flatMap((one) => one.files));
|
|
214
227
|
const unanswered = [];
|
|
215
228
|
const ids = new Set();
|
|
216
229
|
for (const f of files) {
|
|
217
230
|
const covering = mine.filter((s) => s.covers.some((c) => touches([f], c)));
|
|
218
|
-
if (!covering.length || covering.some((s) => files.includes(s.file))) continue;
|
|
231
|
+
if (!covering.length || covering.some((s) => files.includes(s.file) || later.has(s.file))) continue;
|
|
219
232
|
unanswered.push(f);
|
|
220
233
|
for (const s of covering) ids.add(s.id);
|
|
221
234
|
}
|
|
222
|
-
if (!unanswered.length)
|
|
235
|
+
if (!unanswered.length) return;
|
|
223
236
|
const paths = unanswered.sort().slice(0, 20);
|
|
224
237
|
const names = [...ids].sort();
|
|
225
238
|
out.push(finding({
|
|
@@ -231,7 +244,7 @@ function checkNoScenario({ area, scenarios, since, base }) {
|
|
|
231
244
|
evidence: { commit: sha, date, paths, scenarios: names, parents: parentText.split(' ').filter(Boolean).length },
|
|
232
245
|
extra: { commit: sha },
|
|
233
246
|
}));
|
|
234
|
-
}
|
|
247
|
+
});
|
|
235
248
|
return out;
|
|
236
249
|
}
|
|
237
250
|
|