@arjunkhera/atlas 0.4.3 → 0.4.5
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 +11 -3
- 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/package.json +1 -1
- package/skills/lead/SKILL.md +1 -0
- package/skills/sdlc-task/SKILL.md +3 -1
- package/skills/upkeep/SKILL.md +3 -1
- 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
|
@@ -206,9 +206,17 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
|
206
206
|
const rows = checkStates(checks, checkRuns(gh, repo, sha));
|
|
207
207
|
line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
|
|
208
208
|
for (const row of rows) line(checkLine(row));
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
// Checks that ran on an old base prove nothing about the merge: two pull
|
|
210
|
+
// requests, each green alone, can turn the main branch red together (atlas#188).
|
|
211
|
+
const behind = Number(gh('GET', `repos/${repo}/compare/${encodeURIComponent(pull.base.ref)}...${sha}`)?.behind_by ?? NaN);
|
|
212
|
+
if (!(behind >= 0)) throw new GateError(`GitHub gave no compare of ${pull.base.ref} and ${sha.slice(0, 7)}.`);
|
|
213
|
+
line(` ${behind === 0 ? '✓' : '✗'} ${(behind === 0 ? 'current' : 'behind').padEnd(8)} ${behind === 0 ? `the head holds every commit of ${pull.base.ref}` : `${pull.base.ref} has ${behind} commit(s) that the head does not hold`}`);
|
|
214
|
+
const green = rows.every((row) => row.state === 'green');
|
|
215
|
+
if (green && behind === 0) { line('READY: every required check is green, on top of the current main branch. Merge with a merge commit, at this head.'); return 0; }
|
|
216
|
+
line(behind > 0
|
|
217
|
+
? `NOT READY: do not merge. Merge ${pull.base.ref} in, wait for green, then run ready again.`
|
|
218
|
+
: 'NOT READY: do not merge. Wait for a pending check; fix a red or missing one.');
|
|
219
|
+
return 1;
|
|
212
220
|
}
|
|
213
221
|
|
|
214
222
|
// The merge door hook (packages/atlas/hooks/door.mjs) reads this file before
|
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/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 |
|
|
@@ -148,7 +148,9 @@ A lock is the owner's approval of a short design.
|
|
|
148
148
|
nothing, so this step is the gate (owner, 24 October 2026).
|
|
149
149
|
1. Right before the merge, run `atlas gate` with the verb `ready`,
|
|
150
150
|
`--repo` and `--pr`.
|
|
151
|
-
2. A pending check: wait for it. A red or missing check: fix it.
|
|
151
|
+
2. A pending check: wait for it. A red or missing check: fix it. A
|
|
152
|
+
branch behind the main branch: merge the main branch in, wait for
|
|
153
|
+
green, and run `ready` again.
|
|
152
154
|
3. Merge only when it prints READY. Name the head SHA that it read in
|
|
153
155
|
the merge call, so a later push cannot slip in.
|
|
154
156
|
4. Where the verb `apply` set the rulesets, the branch rule holds the
|
package/skills/upkeep/SKILL.md
CHANGED
|
@@ -97,7 +97,9 @@ If the tracker cannot be reached, skip this half and say so.
|
|
|
97
97
|
diff changes only index lines and link targets, run `atlas gate` with
|
|
98
98
|
the verb `ready`, `--repo` and `--pr`. On READY, merge it with a merge
|
|
99
99
|
commit at the head SHA that ready read. The merge door hook refuses any
|
|
100
|
-
other merge.
|
|
100
|
+
other merge. When the branch is behind the main branch, merge the main
|
|
101
|
+
branch in, wait for green, and run `ready` again. Otherwise leave it
|
|
102
|
+
open and say why in the report.
|
|
101
103
|
|
|
102
104
|
## The run line
|
|
103
105
|
|
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
|
|