@arjunkhera/atlas 0.4.21 → 0.4.23
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 +7 -5
- package/door/jobs/gate-audit.yaml +16 -0
- package/door/jobs/gate-errors.yaml +11 -0
- package/door/jobs/gate-files.yaml +13 -0
- package/door/lib/gate-audit.mjs +303 -0
- package/door/lib/gate-check.mjs +4 -0
- package/door/lib/gate.mjs +92 -23
- package/door/lib/jobs.mjs +25 -4
- package/door/lib/procedure-steps.mjs +122 -0
- package/door/lib/upkeep.mjs +4 -2
- package/door/templates/atlas-jobs.yml +5 -1
- package/door/workflows/atlas-gate-audit.yml +128 -0
- package/door/workflows/atlas-gate.yml +142 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/repo-skills/SKILL.md +14 -10
- package/work/lib/roundup.mjs +29 -3
- package/work/mcp.mjs +2 -0
package/door/lib/jobs.mjs
CHANGED
|
@@ -25,6 +25,8 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { parseYaml } from '../../shape/check.mjs';
|
|
27
27
|
import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
|
|
28
|
+
import { checkGateFiles, checkGateAudit, checkGateErrors } from './gate-audit.mjs';
|
|
29
|
+
import { commitAudit, ghApi } from './gate.mjs';
|
|
28
30
|
|
|
29
31
|
export const JOBS_LABEL = 'atlas-job';
|
|
30
32
|
export const HANDED_OVER = 'handed-over';
|
|
@@ -107,9 +109,16 @@ export async function checkRedMain({ repo, branch, env }) {
|
|
|
107
109
|
|
|
108
110
|
// The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
|
|
109
111
|
// says so in the notes; it never reads `found` from nothing.
|
|
110
|
-
|
|
112
|
+
// The gate jobs (roadmap R14) read GitHub through `gh`, which the Actions
|
|
113
|
+
// runner holds; their token is the run's own GITHUB_TOKEN.
|
|
114
|
+
const CHECKS = {
|
|
115
|
+
'red-main': checkRedMain,
|
|
116
|
+
'gate-files': ({ root, env, fetchImpl }) => checkGateFiles({ root, env, fetchImpl }),
|
|
117
|
+
'gate-audit': ({ repo, branch, gh }) => checkGateAudit({ repo, branch, gh, audit: commitAudit }),
|
|
118
|
+
'gate-errors': ({ repo, gh }) => checkGateErrors({ repo, gh }),
|
|
119
|
+
};
|
|
111
120
|
|
|
112
|
-
export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs() }) {
|
|
121
|
+
export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs(), gh = ghApi, fetchImpl = fetch }) {
|
|
113
122
|
const named = namedJobs(root);
|
|
114
123
|
const notes = [];
|
|
115
124
|
const ran = [];
|
|
@@ -123,7 +132,10 @@ export async function jobsCheck({ root, repo, branch = null, env = process.env,
|
|
|
123
132
|
if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
|
|
124
133
|
try {
|
|
125
134
|
if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
|
|
126
|
-
|
|
135
|
+
// A check gives its findings, or { findings, notes }.
|
|
136
|
+
const got = await check({ repo, branch: mainBranch, env, root, gh, fetchImpl });
|
|
137
|
+
const found = (Array.isArray(got) ? got : got.findings).map((f) => ({ ...f, job: id }));
|
|
138
|
+
for (const note of Array.isArray(got) ? [] : got.notes ?? []) notes.push(`${id}: ${String(note).slice(0, 300)}`);
|
|
127
139
|
findings.push(...found);
|
|
128
140
|
ran.push({ job: id, state: found.length ? 'found' : 'none' });
|
|
129
141
|
} catch (error) {
|
|
@@ -194,13 +206,22 @@ export function jobIssue(finding) {
|
|
|
194
206
|
export const handedOverStops = (now = Date.now()) => (issue) => (issue.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === HANDED_OVER)
|
|
195
207
|
&& Number.isFinite(Date.parse(issue.closed_at)) && now - Date.parse(issue.closed_at) <= HANDED_OVER_DAYS * DAY;
|
|
196
208
|
|
|
209
|
+
// A job with `handed_over: ignored` never stops on a closed issue: anyone with
|
|
210
|
+
// the shared login can close an issue, so a gate finding comes back while its
|
|
211
|
+
// commit is in the window (roadmap R14).
|
|
212
|
+
export const closedStopsFor = (jobs, now = Date.now()) => (issue) => {
|
|
213
|
+
const job = (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l?.name)).find((name) => String(name).startsWith('job:'));
|
|
214
|
+
if (job && jobs.get(job.slice(4))?.handed_over === 'ignored') return false;
|
|
215
|
+
return handedOverStops(now)(issue);
|
|
216
|
+
};
|
|
217
|
+
|
|
197
218
|
export async function fileJobs({ envelope, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, now = Date.now(), jobs = atlasJobs() }) {
|
|
198
219
|
const { findings, dropped } = typedFindings(envelope, jobs);
|
|
199
220
|
if (dropped) line(`dropped ${dropped} finding(s) that are not typed, or that name no Atlas job`);
|
|
200
221
|
if (envelope.state === 'broken') line('the check reads broken, so a check found nothing it can trust. The run ends red.');
|
|
201
222
|
const list = findings.map((f) => ({ key: `${f.job}:${f.key}`, issue: jobIssue(f) }));
|
|
202
223
|
const result = list.length
|
|
203
|
-
? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops:
|
|
224
|
+
? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: closedStopsFor(jobs, now) })
|
|
204
225
|
: (line('no finding: nothing to file.'), { opened: [], skipped: [], deferred: [], failed: [] });
|
|
205
226
|
return { ...result, broken: envelope.state === 'broken' };
|
|
206
227
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// CI runs the procedures (roadmap R40, item 38b2a0fe). Target design 4.6: CI
|
|
2
|
+
// runs each procedure step that a clean machine can run.
|
|
3
|
+
//
|
|
4
|
+
// A procedure marks such a step with a fenced block whose info string holds
|
|
5
|
+
// the tag `atlas-run`:
|
|
6
|
+
//
|
|
7
|
+
// ```sh atlas-run
|
|
8
|
+
// npm test
|
|
9
|
+
// ```
|
|
10
|
+
//
|
|
11
|
+
// A step that needs what a clean machine does not have names it after the
|
|
12
|
+
// tag: `atlas-run needs=secret/NPM_TOKEN` or `atlas-run needs=device/macos`.
|
|
13
|
+
// Such a step is listed as "not run here", never as passed.
|
|
14
|
+
//
|
|
15
|
+
// `atlas upkeep procedures` runs the marked steps of each procedure that
|
|
16
|
+
// atlas.yaml `procedures:` names. With --base, it runs only the procedures a
|
|
17
|
+
// pull request changes: its own folder, or a path its `sources:` names. Each
|
|
18
|
+
// block runs in bash, from the repo root, with `set -euo pipefail`. It reads
|
|
19
|
+
// no repo kind, so a repo with no deploy target uses it unchanged.
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { fenceOpens, fenceCloses } from './markdown.mjs';
|
|
24
|
+
import { docUnits, covers, changedPaths } from './doc-sources.mjs';
|
|
25
|
+
|
|
26
|
+
export const TAG = 'atlas-run';
|
|
27
|
+
const SHELLS = new Set(['sh', 'bash', 'shell']);
|
|
28
|
+
const NEEDS = new Set(['secret', 'device', 'network']);
|
|
29
|
+
// The bound on one block. A step that runs longer is a failure. It sits
|
|
30
|
+
// below the 10 minutes of the CI job, so the step fails with a reason.
|
|
31
|
+
export const STEP_TIMEOUT_MS = 8 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
// Line 1: the marked steps of one procedure text. Each step is one fenced
|
|
34
|
+
// block. `problems` holds a tag that cannot run as written.
|
|
35
|
+
export function markedSteps(text) {
|
|
36
|
+
const steps = [];
|
|
37
|
+
const problems = [];
|
|
38
|
+
const lines = String(text ?? '').split('\n');
|
|
39
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
40
|
+
const mark = fenceOpens(lines[i]);
|
|
41
|
+
if (!mark) continue;
|
|
42
|
+
const info = lines[i].trim().slice(mark.length).trim().split(/\s+/).filter(Boolean);
|
|
43
|
+
const start = i;
|
|
44
|
+
const body = [];
|
|
45
|
+
for (i += 1; i < lines.length && !fenceCloses(lines[i], mark); i += 1) body.push(lines[i]);
|
|
46
|
+
if (!info.includes(TAG)) continue;
|
|
47
|
+
const line = start + 1;
|
|
48
|
+
const lang = info[0] === TAG ? null : info[0];
|
|
49
|
+
if (!SHELLS.has(lang)) { problems.push({ line, why: `the block is tagged ${TAG} but its language is ${lang ?? 'missing'}; use sh or bash` }); continue; }
|
|
50
|
+
if (i >= lines.length) { problems.push({ line, why: 'the block never closes' }); continue; }
|
|
51
|
+
const needs = [];
|
|
52
|
+
let bad = null;
|
|
53
|
+
for (const word of info.slice(info.indexOf(TAG) + 1)) {
|
|
54
|
+
const m = /^needs=(.+)$/.exec(word);
|
|
55
|
+
if (!m) { bad = `"${word}" after ${TAG} is not needs=<kind>/<name>`; break; }
|
|
56
|
+
for (const one of m[1].split(',')) {
|
|
57
|
+
const [kind, name] = one.split('/');
|
|
58
|
+
if (!NEEDS.has(kind) || !name) { bad = `"${one}" is not one of ${[...NEEDS].map((k) => `${k}/<name>`).join(', ')}`; break; }
|
|
59
|
+
needs.push({ kind, name });
|
|
60
|
+
}
|
|
61
|
+
if (bad) break;
|
|
62
|
+
}
|
|
63
|
+
if (bad) { problems.push({ line, why: bad }); continue; }
|
|
64
|
+
const script = body.join('\n').trim();
|
|
65
|
+
if (!script) { problems.push({ line, why: 'the block is empty' }); continue; }
|
|
66
|
+
steps.push({ line, script, needs });
|
|
67
|
+
}
|
|
68
|
+
return { steps, problems };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The procedures to run. With `changed`, only those a pull request changes:
|
|
72
|
+
// a path in the procedure's folder, or a path its `sources:` names.
|
|
73
|
+
export function proceduresToRun(root, changed = null) {
|
|
74
|
+
const units = docUnits(root).filter((one) => one.kind === 'procedure');
|
|
75
|
+
if (changed === null) return units;
|
|
76
|
+
return units.filter((one) => changed.some((path) => path.startsWith(`${one.unit}/`)
|
|
77
|
+
|| (one.named?.sources ?? []).some((source) => covers(root, source, path))));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const needsText = (needs) => needs.map((one) => `${one.kind} ${one.name}`).join(', ');
|
|
81
|
+
|
|
82
|
+
// Line 2 and line 3: run each marked step of each chosen procedure. A step
|
|
83
|
+
// that needs a secret, a device or the network is "not run here".
|
|
84
|
+
export function runProcedures(root, { base = null, run = runStep } = {}) {
|
|
85
|
+
const changed = base === null ? null : changedPaths(root, `${base}...HEAD`);
|
|
86
|
+
const chosen = proceduresToRun(root, changed);
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const unit of chosen) {
|
|
89
|
+
const { steps, problems } = markedSteps(readFileSync(join(root, unit.where), 'utf8'));
|
|
90
|
+
for (const one of problems) rows.push({ procedure: unit.unit, where: `${unit.where}:${one.line}`, state: 'bad tag', why: one.why });
|
|
91
|
+
if (!steps.length && !problems.length) rows.push({ procedure: unit.unit, where: unit.where, state: 'no step', why: `it marks no step with ${TAG}` });
|
|
92
|
+
for (const step of steps) {
|
|
93
|
+
const where = `${unit.where}:${step.line}`;
|
|
94
|
+
if (step.needs.length) { rows.push({ procedure: unit.unit, where, state: 'not run here', why: `needs ${needsText(step.needs)}` }); continue; }
|
|
95
|
+
const result = run(root, step.script);
|
|
96
|
+
rows.push({ procedure: unit.unit, where, state: result.ok ? 'passed' : 'failed', why: result.why, tail: result.tail });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const red = rows.filter((row) => row.state === 'failed' || row.state === 'bad tag');
|
|
100
|
+
return { procedures: chosen.map((one) => one.unit), all: changed === null, rows, green: red.length === 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function runStep(root, script) {
|
|
104
|
+
const out = spawnSync('bash', ['-c', `set -euo pipefail\n${script}`], { cwd: root, encoding: 'utf8', timeout: STEP_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
|
|
105
|
+
const tail = `${out.stdout ?? ''}${out.stderr ?? ''}`.trimEnd().split('\n').slice(-20).join('\n');
|
|
106
|
+
if (out.error?.code === 'ETIMEDOUT') return { ok: false, why: `it ran past ${STEP_TIMEOUT_MS / 60000} minutes`, tail };
|
|
107
|
+
if (out.error) return { ok: false, why: out.error.message, tail };
|
|
108
|
+
return out.status === 0 ? { ok: true, why: 'exit 0', tail } : { ok: false, why: `exit ${out.status ?? out.signal}`, tail };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `atlas upkeep procedures`.
|
|
112
|
+
export function proceduresCommand({ base = null, json = false }, root, line) {
|
|
113
|
+
const out = runProcedures(root, { base });
|
|
114
|
+
if (json) { process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); return out.green ? 0 : 1; }
|
|
115
|
+
line(`procedures: ${out.procedures.length} to run${out.all ? '' : ` (the ones this change touches, from ${base})`}${out.procedures.length ? `: ${out.procedures.join(', ')}` : ''}`);
|
|
116
|
+
for (const row of out.rows) {
|
|
117
|
+
line(` ${row.state.padEnd(12)} ${row.where} ${row.why}`);
|
|
118
|
+
if (row.state === 'failed' && row.tail) for (const one of row.tail.split('\n')) line(` | ${one}`);
|
|
119
|
+
}
|
|
120
|
+
line(`procedure steps: ${out.green ? 'GREEN' : 'RED'}`);
|
|
121
|
+
return out.green ? 0 : 1;
|
|
122
|
+
}
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
|
23
23
|
import { join, dirname, posix, resolve } from 'node:path';
|
|
24
24
|
import { listChanged } from './changed-tests.mjs';
|
|
25
25
|
import { docSourceFindings, replay } from './doc-sources.mjs';
|
|
26
|
+
import { proceduresCommand } from './procedure-steps.mjs';
|
|
26
27
|
|
|
27
28
|
// R39 line 4 gates only after the replay of line 3. The replay of the last 30
|
|
28
29
|
// merges (atlas#225): rule "file" pulled in 81 docs over 24 merges, 58 not
|
|
@@ -246,9 +247,10 @@ export function upkeepCheck(root, { event = null } = {}) {
|
|
|
246
247
|
return { green: findings.length === 0, findings, notes: sources.notes, index, dead, item, text, sources };
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
export function upkeepCommand({ what, event: eventFile, json = false, last }, root, line) {
|
|
250
|
+
export function upkeepCommand({ what, event: eventFile, json = false, last, base }, root, line) {
|
|
250
251
|
if (what === 'replay') return replayCommand({ last, json }, root, line);
|
|
251
|
-
if (what
|
|
252
|
+
if (what === 'procedures') return proceduresCommand({ base: base ?? null, json }, root, line);
|
|
253
|
+
if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, atlas upkeep replay or atlas upkeep procedures.`);
|
|
252
254
|
let event = null;
|
|
253
255
|
if (eventFile !== undefined) {
|
|
254
256
|
try { event = JSON.parse(readFileSync(resolve(eventFile), 'utf8')); } catch (error) { throw new Error(`cannot read the event file ${eventFile}: ${error.message}`); }
|
|
@@ -5,7 +5,9 @@ name: atlas-jobs
|
|
|
5
5
|
# It runs once a day from the default branch, and never on a pull request, so
|
|
6
6
|
# a pull request cannot change what it runs. It has two jobs:
|
|
7
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.
|
|
8
|
+
# read permissions and the run's own token, and no repo secret. The
|
|
9
|
+
# gate jobs (gate-audit, gate-errors) also read pull requests and
|
|
10
|
+
# check runs, so the job reads those two as well.
|
|
9
11
|
# file holds only issues write. It runs Atlas code on the JSON of the
|
|
10
12
|
# check job, and files one `atlas-job` issue for each new finding.
|
|
11
13
|
# A job does nothing until atlas.yaml names it under `jobs: { atlas: [...] }`.
|
|
@@ -28,6 +30,8 @@ jobs:
|
|
|
28
30
|
permissions:
|
|
29
31
|
contents: read
|
|
30
32
|
actions: read
|
|
33
|
+
pull-requests: read
|
|
34
|
+
checks: read
|
|
31
35
|
outputs:
|
|
32
36
|
findings: ${{ steps.check.outputs.findings }}
|
|
33
37
|
steps:
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
name: atlas-gate-audit
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [master]
|
|
5
|
+
|
|
6
|
+
# The push audit of the advisory gate (roadmap R14, design
|
|
7
|
+
# docs/design-docs/audit-job/). This plan gives the repo no rulesets, so
|
|
8
|
+
# GitHub merges and takes pushes with no check. This workflow looks at each
|
|
9
|
+
# commit that a push put on the main branch, along the first parent:
|
|
10
|
+
# 1. A commit that no pull request merged is a push past the gate.
|
|
11
|
+
# 2. A force push.
|
|
12
|
+
# 3. A merge made before the required checks of its pull request were
|
|
13
|
+
# green. A check counts only from a run of the base file that defines it.
|
|
14
|
+
# 4. A gate run that is not from the base copy of atlas-gate.yml.
|
|
15
|
+
# Each finding opens one `atlas-job` issue, and the run ends red. It does not
|
|
16
|
+
# undo the push.
|
|
17
|
+
#
|
|
18
|
+
# It runs the code of the pushed commit, so a push can edit it and hide. The
|
|
19
|
+
# watcher (atlas-jobs.yml) runs the npm copy of Atlas once a day. It audits
|
|
20
|
+
# the commits of the last two days again, and compares these files with the
|
|
21
|
+
# latest release. A test keeps this file byte identical to
|
|
22
|
+
# packages/atlas/door/workflows/atlas-gate-audit.yml.
|
|
23
|
+
#
|
|
24
|
+
# Two jobs, each with only what it needs:
|
|
25
|
+
# audit read rights only. It writes the findings as job output.
|
|
26
|
+
# file contents: read for the checkout, and issues: write. That is the
|
|
27
|
+
# only write right of this workflow.
|
|
28
|
+
permissions: {}
|
|
29
|
+
|
|
30
|
+
concurrency:
|
|
31
|
+
group: atlas-gate-audit-${{ github.sha }}
|
|
32
|
+
cancel-in-progress: false
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
audit:
|
|
36
|
+
name: gate · push audit
|
|
37
|
+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
timeout-minutes: 10
|
|
40
|
+
permissions:
|
|
41
|
+
contents: read
|
|
42
|
+
pull-requests: read
|
|
43
|
+
checks: read
|
|
44
|
+
actions: read
|
|
45
|
+
outputs:
|
|
46
|
+
findings: ${{ steps.audit.outputs.findings }}
|
|
47
|
+
steps:
|
|
48
|
+
# actions/checkout v7.0.1
|
|
49
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
50
|
+
with:
|
|
51
|
+
fetch-depth: 0
|
|
52
|
+
persist-credentials: false
|
|
53
|
+
# actions/setup-node v7.0.0
|
|
54
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
55
|
+
with:
|
|
56
|
+
node-version: "22.22.0"
|
|
57
|
+
|
|
58
|
+
- name: Audit each commit of this push
|
|
59
|
+
id: audit
|
|
60
|
+
env:
|
|
61
|
+
GH_TOKEN: ${{ github.token }}
|
|
62
|
+
BEFORE: ${{ github.event.before }}
|
|
63
|
+
AFTER: ${{ github.event.after }}
|
|
64
|
+
FORCED: ${{ github.event.forced }}
|
|
65
|
+
REPO: ${{ github.repository }}
|
|
66
|
+
run: |
|
|
67
|
+
set -uo pipefail
|
|
68
|
+
out="$RUNNER_TEMP/findings.json"
|
|
69
|
+
# keep FILE [FROM]: add the findings of FROM to FILE, through Atlas code.
|
|
70
|
+
keep() { node --input-type=module -e 'import { keepFindings } from "./packages/atlas/door/lib/gate-audit.mjs"; import { readFileSync, existsSync } from "node:fs"; const [to, from] = process.argv.slice(1); const have = from && existsSync(from) ? JSON.parse(readFileSync(from, "utf8")) : { findings: [] }; keepFindings(to, have.findings ?? [], { broken: have.state === "broken" });' "$@"; }
|
|
71
|
+
keep "$out"
|
|
72
|
+
forced=()
|
|
73
|
+
if [ "$FORCED" = "true" ]; then forced=(--forced); fi
|
|
74
|
+
if [ -z "${BEFORE//0/}" ] || [ "$FORCED" = "true" ] || ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
|
|
75
|
+
commits="$AFTER"
|
|
76
|
+
else
|
|
77
|
+
commits=$(git rev-list --first-parent --reverse "$BEFORE..$AFTER")
|
|
78
|
+
fi
|
|
79
|
+
for sha in $commits; do
|
|
80
|
+
flag=()
|
|
81
|
+
if [ "$sha" = "$AFTER" ]; then flag=("${forced[@]}"); fi
|
|
82
|
+
# GitHub can take a moment to link a fresh merge to its pull
|
|
83
|
+
# request. So a "no pull request" answer gets one more read, with
|
|
84
|
+
# a fresh findings file.
|
|
85
|
+
if ! text=$(node packages/atlas/bin/atlas gate audit --repo "$REPO" --commit "$sha" "${flag[@]}" --findings "$out.$sha"); then
|
|
86
|
+
if grep -q '^RED PUSH' <<<"$text"; then
|
|
87
|
+
sleep 30
|
|
88
|
+
rm -f "$out.$sha"
|
|
89
|
+
text=$(node packages/atlas/bin/atlas gate audit --repo "$REPO" --commit "$sha" "${flag[@]}" --findings "$out.$sha")
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
printf '%s\n' "$text" | tee -a "$GITHUB_STEP_SUMMARY"
|
|
93
|
+
# A run that wrote no file stopped before it could: mark it broken.
|
|
94
|
+
if [ -s "$out.$sha" ]; then keep "$out" "$out.$sha"; else echo '{"jobs":1,"state":"broken","findings":[]}' > "$out.$sha"; keep "$out" "$out.$sha"; fi
|
|
95
|
+
done
|
|
96
|
+
echo "findings=$(base64 -w0 < "$out")" >> "$GITHUB_OUTPUT"
|
|
97
|
+
|
|
98
|
+
file:
|
|
99
|
+
name: gate · push audit issues
|
|
100
|
+
needs: audit
|
|
101
|
+
if: always() && needs.audit.outputs.findings != ''
|
|
102
|
+
runs-on: ubuntu-latest
|
|
103
|
+
timeout-minutes: 5
|
|
104
|
+
permissions:
|
|
105
|
+
contents: read
|
|
106
|
+
issues: write
|
|
107
|
+
steps:
|
|
108
|
+
# actions/checkout v7.0.1
|
|
109
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
110
|
+
with:
|
|
111
|
+
persist-credentials: false
|
|
112
|
+
# actions/setup-node v7.0.0
|
|
113
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
114
|
+
with:
|
|
115
|
+
node-version: "22.22.0"
|
|
116
|
+
- name: File one issue for each new finding
|
|
117
|
+
env:
|
|
118
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
119
|
+
FINDINGS: ${{ needs.audit.outputs.findings }}
|
|
120
|
+
run: |
|
|
121
|
+
set -euo pipefail
|
|
122
|
+
printf '%s' "$FINDINGS" | base64 -d > "$RUNNER_TEMP/findings.json"
|
|
123
|
+
node packages/atlas/bin/atlas jobs file --findings "$RUNNER_TEMP/findings.json" --repo "$GITHUB_REPOSITORY" | tee -a "$GITHUB_STEP_SUMMARY"
|
|
124
|
+
state=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).state' "$RUNNER_TEMP/findings.json")
|
|
125
|
+
if [ "$state" != "none" ]; then
|
|
126
|
+
echo "::error::the push audit found a break of the gate (state $state). Read the issues it opened."
|
|
127
|
+
exit 1
|
|
128
|
+
fi
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
name: atlas-gate
|
|
2
|
+
run-name: "gate #${{ github.event.pull_request.number }} at ${{ github.event.pull_request.head.sha }}"
|
|
3
|
+
on:
|
|
4
|
+
pull_request_target:
|
|
5
|
+
types: [opened, synchronize, reopened, edited, ready_for_review]
|
|
6
|
+
|
|
7
|
+
# The gate (roadmap R6, design docs/design-docs/gate-check/). One job, named
|
|
8
|
+
# `gate`, ends as success, or as failure with "owner: class" or "red: reason"
|
|
9
|
+
# in its summary. It judges the pull request by the static rules: the
|
|
10
|
+
# owner-only list, secrets of a known shape, head jobs named like a check, and
|
|
11
|
+
# a test change for product code.
|
|
12
|
+
#
|
|
13
|
+
# It runs on pull_request_target, so GitHub runs this file as the base branch
|
|
14
|
+
# holds it, with the base code. The head is fetched as git objects only and is
|
|
15
|
+
# never checked out, built or run. atlas.yaml comes from the base side only.
|
|
16
|
+
# The title of the run names the pull request and the head; `atlas gate ready`
|
|
17
|
+
# reads it to pin the run (done line 8).
|
|
18
|
+
#
|
|
19
|
+
# The token can only read. No checkout keeps a credential: the one fetch of
|
|
20
|
+
# the head takes the token for that command alone. The job writes two output
|
|
21
|
+
# keys, `result` and `class`, each one word of a fixed set.
|
|
22
|
+
#
|
|
23
|
+
# The signed act (roadmap R72): the gate reads the comments of the pull
|
|
24
|
+
# request, and a valid signature of the owner for this head turns "owner" into
|
|
25
|
+
# success. The key comes from atlas/owner-signers on the base. A new comment
|
|
26
|
+
# with a signed act reruns this run (atlas-gate-signed.yml).
|
|
27
|
+
#
|
|
28
|
+
# Report mode: the gate is not in required_checks yet. It joins the list after
|
|
29
|
+
# the A1 exit test of the overhaul roadmap.
|
|
30
|
+
#
|
|
31
|
+
# The second job, `shape`, is the repo shape check from the base (roadmap R7,
|
|
32
|
+
# design docs/design-docs/shape-check-from-base/). It is the job that
|
|
33
|
+
# `atlas tooling` writes into every repo, word for word, and `atlas status`
|
|
34
|
+
# compares it with the package.
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
pull-requests: read
|
|
38
|
+
issues: read
|
|
39
|
+
checks: read
|
|
40
|
+
actions: read
|
|
41
|
+
|
|
42
|
+
concurrency:
|
|
43
|
+
group: atlas-gate-${{ github.event.pull_request.number }}
|
|
44
|
+
cancel-in-progress: true
|
|
45
|
+
|
|
46
|
+
jobs:
|
|
47
|
+
gate:
|
|
48
|
+
name: gate
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
timeout-minutes: 10
|
|
51
|
+
outputs:
|
|
52
|
+
result: ${{ steps.check.outputs.result }}
|
|
53
|
+
class: ${{ steps.check.outputs.class }}
|
|
54
|
+
steps:
|
|
55
|
+
# actions/checkout v7.0.1
|
|
56
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
57
|
+
with:
|
|
58
|
+
ref: ${{ github.sha }}
|
|
59
|
+
fetch-depth: 0
|
|
60
|
+
persist-credentials: false
|
|
61
|
+
# actions/setup-node v7.0.0
|
|
62
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
63
|
+
with:
|
|
64
|
+
node-version: "22.22.0"
|
|
65
|
+
|
|
66
|
+
- name: Fetch the head as objects only
|
|
67
|
+
env:
|
|
68
|
+
PR: ${{ github.event.pull_request.number }}
|
|
69
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
70
|
+
TOKEN: ${{ github.token }}
|
|
71
|
+
run: |
|
|
72
|
+
set -euo pipefail
|
|
73
|
+
header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
|
|
74
|
+
git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
|
|
75
|
+
got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
|
|
76
|
+
if [ "$got" != "$HEAD_SHA" ]; then
|
|
77
|
+
echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
|
|
78
|
+
exit 1
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
- name: Read the pull request text from the event
|
|
82
|
+
run: node -e 'const fs=require("fs");const e=JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));fs.writeFileSync(process.env.RUNNER_TEMP+"/body.md",String(e.pull_request.body??""))'
|
|
83
|
+
|
|
84
|
+
- name: Gate check
|
|
85
|
+
id: check
|
|
86
|
+
env:
|
|
87
|
+
PR: ${{ github.event.pull_request.number }}
|
|
88
|
+
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
89
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
90
|
+
GH_TOKEN: ${{ github.token }}
|
|
91
|
+
run: |
|
|
92
|
+
set -euo pipefail
|
|
93
|
+
node packages/atlas/bin/atlas gate check --root . --repo "$GITHUB_REPOSITORY" --base "$GITHUB_SHA" --head "$HEAD_SHA" --pr "$PR" --base-ref "$BASE_REF" --body "$RUNNER_TEMP/body.md" --summary "$GITHUB_STEP_SUMMARY" --output "$GITHUB_OUTPUT"
|
|
94
|
+
|
|
95
|
+
shape:
|
|
96
|
+
name: gate · shape
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
# The check reads two trees and opens no network. It finishes in seconds
|
|
99
|
+
# on a small repo. The bound stops a runner being billed until someone
|
|
100
|
+
# kills it; a run that hits it is red.
|
|
101
|
+
timeout-minutes: 10
|
|
102
|
+
outputs:
|
|
103
|
+
result: ${{ steps.shape.outputs.result }}
|
|
104
|
+
class: ${{ steps.shape.outputs.class }}
|
|
105
|
+
steps:
|
|
106
|
+
# actions/checkout v7.0.1
|
|
107
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
108
|
+
with:
|
|
109
|
+
ref: ${{ github.sha }}
|
|
110
|
+
fetch-depth: 0
|
|
111
|
+
persist-credentials: false
|
|
112
|
+
# actions/setup-node v7.0.0
|
|
113
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
114
|
+
with:
|
|
115
|
+
node-version: "22.22.0"
|
|
116
|
+
- name: Fetch the head as objects only
|
|
117
|
+
env:
|
|
118
|
+
PR: ${{ github.event.pull_request.number }}
|
|
119
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
120
|
+
TOKEN: ${{ github.token }}
|
|
121
|
+
run: |
|
|
122
|
+
set -euo pipefail
|
|
123
|
+
header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
|
|
124
|
+
git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
|
|
125
|
+
got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
|
|
126
|
+
if [ "$got" != "$HEAD_SHA" ]; then
|
|
127
|
+
echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
|
|
128
|
+
exit 1
|
|
129
|
+
fi
|
|
130
|
+
- name: Shape check from the base
|
|
131
|
+
id: shape
|
|
132
|
+
env:
|
|
133
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
134
|
+
run: |
|
|
135
|
+
set -euo pipefail
|
|
136
|
+
if [ ! -f .github/atlas/check.mjs ]; then
|
|
137
|
+
echo "OWNER: first-run: the base has no .github/atlas/check.mjs, so the owner reads the first shape"
|
|
138
|
+
echo "result=owner" >> "$GITHUB_OUTPUT"
|
|
139
|
+
echo "class=first-run" >> "$GITHUB_OUTPUT"
|
|
140
|
+
exit 1
|
|
141
|
+
fi
|
|
142
|
+
node .github/atlas/check.mjs --gate --repo "$GITHUB_REPOSITORY" --base-commit "$GITHUB_SHA" --head-commit "$HEAD_SHA"
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -110,7 +110,7 @@ set. A test keeps the two lists equal.
|
|
|
110
110
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
111
111
|
| `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 |
|
|
112
112
|
| `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` |
|
|
113
|
-
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates |
|
|
113
|
+
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
|
|
114
114
|
| `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, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
115
115
|
| `atlas approve` | Never from a session. Only the owner runs it, on his Mac, with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way |
|
|
116
116
|
| `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 |
|
|
@@ -27,19 +27,24 @@ the form the repo already uses for that kind of thing.
|
|
|
27
27
|
deploy. A rule says how to judge a change. They have different approval
|
|
28
28
|
rules, so split a skill that holds both.
|
|
29
29
|
4. **Write the header Atlas reads,** for a skill file. It names the type,
|
|
30
|
-
the commands and files, and the code it covers
|
|
31
|
-
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
the commands and files, and the code it covers in `sources:`. The
|
|
31
|
+
Atlas check finds the skill when `procedures` in `atlas.yaml` names
|
|
32
|
+
it. `atlas upkeep check` reads `sources:`.
|
|
33
|
+
5. **Tag each step a clean machine can run.** Put its commands in a
|
|
34
|
+
fenced block. Open it with ```` ```sh atlas-run ````. CI runs each
|
|
35
|
+
such block when a pull request changes the skill or its sources. A
|
|
36
|
+
step that needs a secret, a device or the network gets
|
|
37
|
+
`needs=secret/<name>`, `needs=device/<name>` or `needs=network/<name>`
|
|
38
|
+
after the tag. CI lists it as "not run here".
|
|
39
|
+
6. **Keep it to this repo.** A step that holds how to work in general
|
|
35
40
|
belongs in Atlas. Do not put it here. Propose it to Atlas with the
|
|
36
41
|
`atlas:feedback` skill.
|
|
37
|
-
|
|
42
|
+
7. **Follow the repo's own rules.** Read its entry file first. Some repos
|
|
38
43
|
ask for one area folder for each change, or a status file updated
|
|
39
44
|
when the state changes. Keep to them.
|
|
40
|
-
|
|
45
|
+
8. **Prove the steps once.** Ask the `atlas:verifier` crew to run them in
|
|
41
46
|
a fresh worktree, before you ask for the merge.
|
|
42
|
-
|
|
47
|
+
9. **Write the pull request text.** Say what you found, what you kept,
|
|
43
48
|
and the gap you closed.
|
|
44
49
|
|
|
45
50
|
## The header of a skill file
|
|
@@ -51,9 +56,8 @@ description: <when to use it, in one or two sentences>
|
|
|
51
56
|
type: task # or rule
|
|
52
57
|
names: # each command and file the steps name
|
|
53
58
|
- <command or path>
|
|
54
|
-
|
|
59
|
+
sources: # the code this skill describes
|
|
55
60
|
- <path>
|
|
56
|
-
ci: <how CI runs it, or "cannot run in CI">
|
|
57
61
|
---
|
|
58
62
|
```
|
|
59
63
|
|
package/work/lib/roundup.mjs
CHANGED
|
@@ -25,6 +25,29 @@ export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
|
|
|
25
25
|
// stage-2 is the stage 2 item or one of its parts.
|
|
26
26
|
export const STAGE_TAG = /^stage-(\d{1,2})$/;
|
|
27
27
|
export const STUCK_DAYS = 14;
|
|
28
|
+
// Roadmap R14 done line 6: the daily audit runs in the watcher workflow. A
|
|
29
|
+
// repo whose watcher has not run for this long is flagged in part 4.
|
|
30
|
+
export const AUDIT_WORKFLOW = 'atlas-jobs.yml';
|
|
31
|
+
export const AUDIT_STALE_HOURS = 26;
|
|
32
|
+
const HOUR = 3_600_000;
|
|
33
|
+
|
|
34
|
+
// The newest run of the daily audit in each repo. A repo with no such
|
|
35
|
+
// workflow (GitHub answers 404) has no audit, so it is left out. Any other
|
|
36
|
+
// failure is listed as unread, never as fresh.
|
|
37
|
+
export async function readAudits(repoIds, readGitHub, readAt, staleHours = AUDIT_STALE_HOURS) {
|
|
38
|
+
const stale = [], unread = [];
|
|
39
|
+
for (const repo of repoIds) {
|
|
40
|
+
let runs;
|
|
41
|
+
try { runs = (await readGitHub(`repos/${repo}/actions/workflows/${AUDIT_WORKFLOW}/runs?per_page=1`))?.workflow_runs; } catch (error) {
|
|
42
|
+
if (!/\b404\b|Not Found/i.test(String(error?.message ?? error))) unread.push({ repo, reason: oneLine(`the audit runs could not be read: ${error?.message ?? String(error)}`, 200) });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const last = Array.isArray(runs) && runs.length ? runs[0].created_at ?? null : null;
|
|
46
|
+
const hours = last ? Math.floor((Date.parse(readAt) - Date.parse(last)) / HOUR) : null;
|
|
47
|
+
if (hours === null || hours >= staleHours) stale.push({ repo, workflow: `.github/workflows/${AUDIT_WORKFLOW}`, last_run_at: last, hours });
|
|
48
|
+
}
|
|
49
|
+
return { stale, unread };
|
|
50
|
+
}
|
|
28
51
|
export const TREND_WEEKS = 4;
|
|
29
52
|
const DEFAULT_WINDOW = 7;
|
|
30
53
|
const MAX_WINDOW = 90;
|
|
@@ -141,6 +164,7 @@ export function createRoundupVerbs(core) {
|
|
|
141
164
|
const notMarked = (flight.merged_not_marked ?? []).map((row) => ({ item: row.item, pr: row.pr, title: row.title, days: row.days }));
|
|
142
165
|
const openNoItem = pulls.filter((pull) => pull.state === 'open' && !owner.has(pull.url)).map((pull) => ({ pr: pull.url, title: pull.title, updated_at: pull.updated_at }));
|
|
143
166
|
const longWaits = needRows.filter((row) => row.days_waiting !== null && row.days_waiting >= STUCK_DAYS);
|
|
167
|
+
const audits = await readAudits(repoIds, readGitHub, readAt);
|
|
144
168
|
|
|
145
169
|
// 5. Trend, per week; week 1 is the last seven days.
|
|
146
170
|
const weeks = Array.from({ length: TREND_WEEKS }, (_, index) => ({ week: index + 1, from: dayOf(minus(readAt, (index + 1) * 7)), to: dayOf(minus(readAt, index * 7)), merged: 0, delivered: 0, new_items: 0 }));
|
|
@@ -150,13 +174,13 @@ export function createRoundupVerbs(core) {
|
|
|
150
174
|
for (const row of items) bump(row.created_at, 'new_items');
|
|
151
175
|
const oldest = needRows.reduce((max, row) => Math.max(max, row.days_waiting ?? 0), 0);
|
|
152
176
|
|
|
153
|
-
const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors });
|
|
177
|
+
const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits, weeks, oldest, repoErrors });
|
|
154
178
|
return {
|
|
155
179
|
verb: 'roundup', read_at: readAt, product, window_days: windowDays, since,
|
|
156
180
|
needs_you: needRows,
|
|
157
181
|
where_we_stand: { ladder, lifecycles, active },
|
|
158
182
|
what_moved: { merged, delivered: deliveries, new_items: created },
|
|
159
|
-
stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS },
|
|
183
|
+
stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS, audits_not_run: audits.stale, audits_unread: audits.unread, audit_stale_hours: AUDIT_STALE_HOURS },
|
|
160
184
|
trend: { weeks, open_questions: needRows.length, oldest_question_days: oldest },
|
|
161
185
|
repos_read: repoIds, repo_errors: repoErrors,
|
|
162
186
|
citation: { product, read_at: readAt, items: items.length, repos: repoIds },
|
|
@@ -167,7 +191,7 @@ export function createRoundupVerbs(core) {
|
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
function render(view) {
|
|
170
|
-
const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors } = view;
|
|
194
|
+
const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits = { stale: [], unread: [] }, weeks, oldest, repoErrors } = view;
|
|
171
195
|
const more = (list) => (list.length > SHOWN ? [` and ${list.length - SHOWN} more`] : []);
|
|
172
196
|
const out = [`${product} — roundup, ${dayOf(readAt)}`];
|
|
173
197
|
|
|
@@ -195,6 +219,8 @@ function render(view) {
|
|
|
195
219
|
stuck.push(` merged, not marked (${notMarked.length})`, ...notMarked.slice(0, SHOWN).map((row) => listLine(row.title, `${row.pr} · ${row.item}`, ' ')));
|
|
196
220
|
stuck.push(` open pull requests with no item (${openNoItem.length})`, ...openNoItem.slice(0, SHOWN).map((row) => listLine(row.title, row.pr, ' ')));
|
|
197
221
|
stuck.push(` questions waiting ${STUCK_DAYS} days or more (${longWaits.length})`, ...longWaits.slice(0, SHOWN).map((row) => listLine(row.text, `${row.question} · ${row.days_waiting} days`, ' ')));
|
|
222
|
+
stuck.push(` audits with no run in ${AUDIT_STALE_HOURS} hours (${audits.stale.length})`, ...audits.stale.slice(0, SHOWN).map((row) => listLine(row.repo, row.last_run_at ? `last run ${row.last_run_at}, ${row.hours} hours` : 'no run yet', ' ')));
|
|
223
|
+
for (const row of audits.unread.slice(0, SHOWN)) stuck.push(listLine(row.repo, row.reason, ' '));
|
|
198
224
|
out.push(stuck.join('\n'));
|
|
199
225
|
|
|
200
226
|
out.push([`5. Trend, per week (week 1 is the last seven days)`,
|
package/work/mcp.mjs
CHANGED
|
@@ -206,6 +206,8 @@ export const GITHUB_READS = Object.freeze([
|
|
|
206
206
|
new RegExp(`^repos/${REPO}/contents/atlas\\.yaml\\?ref=(?:${SHA}|${BRANCH})$`),
|
|
207
207
|
new RegExp(`^repos/${REPO}/releases\\?per_page=\\d{1,3}$`),
|
|
208
208
|
new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${TAG}$`),
|
|
209
|
+
// Roadmap R14 line 6: the newest run of the daily audit, for the roundup.
|
|
210
|
+
new RegExp(`^repos/${REPO}/actions/workflows/atlas-jobs\\.yml/runs\\?per_page=\\d{1,3}$`),
|
|
209
211
|
]);
|
|
210
212
|
export function githubReader() {
|
|
211
213
|
return async (path) => {
|