@arjunkhera/atlas 0.4.33 → 0.4.35
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 +1 -1
- package/door/lib/changed-docs.mjs +190 -0
- package/door/lib/upkeep.mjs +12 -1
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/work/lib/numbers.mjs +35 -4
- package/work/lib/roundup.mjs +2 -2
- package/work/mcp.mjs +3 -0
package/door/cli.mjs
CHANGED
|
@@ -139,7 +139,7 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
139
139
|
--dry-run print what it would open; change nothing
|
|
140
140
|
The token is only GITHUB_TOKEN. The atlas-jobs workflow runs both, once a day
|
|
141
141
|
|
|
142
|
-
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, and in a pull request its tracker item
|
|
142
|
+
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, stale pages, and in a pull request its tracker item, its changed designs and STE on its changed lines
|
|
143
143
|
atlas upkeep replay --root <repo> what the doc-sources rule would have asked of the last merges; writes nothing
|
|
144
144
|
atlas upkeep procedures --root <repo> run the procedure steps marked atlas-run; --base <ref> runs only the procedures a change touches
|
|
145
145
|
--last <n> how many merges on the first-parent line; default 30
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The checks of the changed designs, docs and pages (roadmap R65, item
|
|
2
|
+
// 536c8ddd). Code, no model, no key, no network. `atlas upkeep check` runs
|
|
3
|
+
// them. Design: docs/design-docs/upkeep-reads-changes/.
|
|
4
|
+
//
|
|
5
|
+
// 1. Designs. With a pull request, each design folder that it adds or
|
|
6
|
+
// changes passes the design check of this package. An old design that the
|
|
7
|
+
// pull request does not touch is not read.
|
|
8
|
+
// 2. STE. With a pull request, each markdown file that it adds or changes is
|
|
9
|
+
// measured against the STE limits. A finding on a line the pull request
|
|
10
|
+
// adds or changes fails; one on an unchanged line is only listed, so an
|
|
11
|
+
// old page does not block a small fix.
|
|
12
|
+
// 3. Pages. Each row of docs/artifacts.json names a build command or a
|
|
13
|
+
// frozen date. A page whose source changed after the page is listed as
|
|
14
|
+
// stale, from git dates. A repo with no docs/artifacts.json skips this.
|
|
15
|
+
//
|
|
16
|
+
// archive/, node_modules, fixtures and each folder that atlas.yaml lists
|
|
17
|
+
// under `verbatim:` (text kept word for word, such as docs/intake/) are
|
|
18
|
+
// skipped by 1 and 2. A page with `generated: true` in its front matter is
|
|
19
|
+
// kept by the code that writes it, so 2 skips it too.
|
|
20
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
21
|
+
import { join, posix } from 'node:path';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
24
|
+
import { checkDesignFolder, DESIGN_FILE } from './design.mjs';
|
|
25
|
+
import { checkText, markdownUnits } from './ste.mjs';
|
|
26
|
+
|
|
27
|
+
export const ARTIFACTS = 'docs/artifacts.json';
|
|
28
|
+
const SKIP_PARTS = new Set(['node_modules', 'archive', 'fixtures']);
|
|
29
|
+
const MAX_LISTED = 20;
|
|
30
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
31
|
+
|
|
32
|
+
const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
33
|
+
const readText = (root, path) => { try { return readFileSync(join(root, path), 'utf8'); } catch { return null; } };
|
|
34
|
+
|
|
35
|
+
// The folders that atlas.yaml marks as verbatim, as plain paths with no
|
|
36
|
+
// trailing slash. A missing or unreadable atlas.yaml marks none.
|
|
37
|
+
export function verbatimFolders(root) {
|
|
38
|
+
const text = readText(root, 'atlas.yaml');
|
|
39
|
+
if (text === null) return [];
|
|
40
|
+
let data;
|
|
41
|
+
try { data = parseYaml(text); } catch { return []; }
|
|
42
|
+
const list = data && typeof data === 'object' ? data.verbatim : null;
|
|
43
|
+
if (!Array.isArray(list)) return [];
|
|
44
|
+
return list.map((one) => String(one).trim().replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const under = (path, folder) => path === folder || path.startsWith(`${folder}/`);
|
|
48
|
+
export function skipped(path, verbatim = []) {
|
|
49
|
+
if (path.split('/').some((part) => SKIP_PARTS.has(part))) return true;
|
|
50
|
+
return verbatim.some((folder) => under(path, folder));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The paths a pull request adds or changes, from the merge base of its base
|
|
54
|
+
// to its head. A deleted path is not listed: nothing is left to check.
|
|
55
|
+
export function changedPaths(root, base, head) {
|
|
56
|
+
const out = git(root, ['diff', '--name-only', '--no-renames', '--diff-filter=d', '-z', '--no-ext-diff', '--no-textconv', `${base}...${head}`]);
|
|
57
|
+
return out.split('\0').filter(Boolean).sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The line numbers of one file that the pull request adds or changes, from
|
|
61
|
+
// the hunks of a diff with no context.
|
|
62
|
+
export function addedLines(root, base, head, path) {
|
|
63
|
+
const out = git(root, ['diff', '-U0', '--no-renames', '--no-ext-diff', '--no-textconv', `${base}...${head}`, '--', path]);
|
|
64
|
+
const lines = new Set();
|
|
65
|
+
for (const [, start, count] of out.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
66
|
+
const first = Number(start);
|
|
67
|
+
const n = count === undefined ? 1 : Number(count);
|
|
68
|
+
for (let line = first; line < first + n; line += 1) lines.add(line);
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The nearest folder at or above `path` that holds design.yaml, or null.
|
|
74
|
+
function designOf(root, path) {
|
|
75
|
+
let dir = posix.dirname(path);
|
|
76
|
+
while (dir && dir !== '.') {
|
|
77
|
+
if (existsSync(join(root, dir, DESIGN_FILE))) return dir;
|
|
78
|
+
dir = posix.dirname(dir);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function changedDesigns(root, paths, verbatim = []) {
|
|
84
|
+
const folders = new Set();
|
|
85
|
+
for (const path of paths) {
|
|
86
|
+
if (skipped(path, verbatim)) continue;
|
|
87
|
+
const folder = designOf(root, path);
|
|
88
|
+
if (folder && !skipped(folder, verbatim)) folders.add(folder);
|
|
89
|
+
}
|
|
90
|
+
return [...folders].sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const generated = (text) => {
|
|
94
|
+
const head = /^---\n([\s\S]*?)\n---/.exec(String(text).replace(/\r\n?/g, '\n'));
|
|
95
|
+
if (!head) return false;
|
|
96
|
+
try { return parseYaml(head[1])?.generated === true; } catch { return false; }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// The STE findings of one markdown text, split by the lines a pull request
|
|
100
|
+
// changed. A finding names the line its paragraph, item or cell starts on, so
|
|
101
|
+
// a finding counts as changed when any line of that unit changed.
|
|
102
|
+
export function steSplit(text, changed) {
|
|
103
|
+
const units = markdownUnits(text);
|
|
104
|
+
const total = text.split('\n').length;
|
|
105
|
+
const spanOf = (line) => {
|
|
106
|
+
const next = units.find((unit) => unit.line > line);
|
|
107
|
+
return [line, next ? next.line - 1 : total];
|
|
108
|
+
};
|
|
109
|
+
const out = { changed: [], unchanged: [] };
|
|
110
|
+
for (const finding of checkText(text)) {
|
|
111
|
+
const [from, to] = spanOf(finding.line);
|
|
112
|
+
let hit = false;
|
|
113
|
+
for (let line = from; line <= to && !hit; line += 1) hit = changed.has(line);
|
|
114
|
+
(hit ? out.changed : out.unchanged).push(finding);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The rows of docs/artifacts.json: each names a build command or a frozen
|
|
120
|
+
// date, and a page whose source changed after it is stale.
|
|
121
|
+
export function artifactRows(root) {
|
|
122
|
+
const text = readText(root, ARTIFACTS);
|
|
123
|
+
if (text === null) return { checked: false, findings: [], stale: [] };
|
|
124
|
+
let data;
|
|
125
|
+
try { data = JSON.parse(text); } catch (error) { return { checked: true, findings: [`pages: ${ARTIFACTS} is not JSON: ${error.message}`], stale: [] }; }
|
|
126
|
+
const rows = Array.isArray(data?.artifacts) ? data.artifacts : null;
|
|
127
|
+
if (!rows) return { checked: true, findings: [`pages: ${ARTIFACTS} has no "artifacts" list`], stale: [] };
|
|
128
|
+
const findings = [];
|
|
129
|
+
const stale = [];
|
|
130
|
+
const lastChange = (path) => {
|
|
131
|
+
try { return Number(git(root, ['log', '-1', '--format=%ct', '--', path]).trim()) || null; } catch { return null; }
|
|
132
|
+
};
|
|
133
|
+
rows.forEach((row, index) => {
|
|
134
|
+
const name = row?.title ? `"${row.title}"` : `row ${index + 1}`;
|
|
135
|
+
const build = typeof row?.build === 'string' && row.build.trim() && !/^none\b/i.test(row.build.trim());
|
|
136
|
+
const frozen = typeof row?.frozen === 'string' && DATE.test(row.frozen);
|
|
137
|
+
if (row?.frozen !== undefined && !frozen) findings.push(`pages: ${name} in ${ARTIFACTS} has frozen "${row.frozen}"; write a date as YYYY-MM-DD`);
|
|
138
|
+
else if (!build && !frozen) findings.push(`pages: ${name} in ${ARTIFACTS} names no build command and no frozen date`);
|
|
139
|
+
if (!build || frozen) return;
|
|
140
|
+
const page = typeof row.local_path === 'string' ? row.local_path : null;
|
|
141
|
+
const source = typeof row.source === 'string' ? row.source.replace(/\/+$/, '') : null;
|
|
142
|
+
if (!page || !source || !existsSync(join(root, page)) || !existsSync(join(root, source))) return;
|
|
143
|
+
const [pageAt, sourceAt] = [lastChange(page), lastChange(source)];
|
|
144
|
+
if (pageAt && sourceAt && sourceAt > pageAt) stale.push({ title: row.title ?? null, page, source, build: row.build });
|
|
145
|
+
});
|
|
146
|
+
return { checked: true, findings, stale };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The three checks. `pr` is the pull_request of a GitHub event, or null on
|
|
150
|
+
// the main branch, where 1 and 2 have no change to read.
|
|
151
|
+
export function changedDocFindings(root, { pr = null } = {}) {
|
|
152
|
+
const findings = [];
|
|
153
|
+
const notes = [];
|
|
154
|
+
const verbatim = verbatimFolders(root);
|
|
155
|
+
const result = { designs: null, pages: null, ste: null, verbatim };
|
|
156
|
+
const base = pr?.base?.sha;
|
|
157
|
+
const head = pr?.head?.sha;
|
|
158
|
+
if (base && head && existsSync(join(root, '.git'))) {
|
|
159
|
+
let paths = null;
|
|
160
|
+
try { paths = changedPaths(root, base, head); } catch (error) { findings.push(`changed docs: the changed paths could not be read: ${String(error.message).split('\n')[0]}`); }
|
|
161
|
+
if (paths) {
|
|
162
|
+
result.designs = changedDesigns(root, paths, verbatim);
|
|
163
|
+
for (const folder of result.designs) {
|
|
164
|
+
const found = checkDesignFolder(join(root, folder));
|
|
165
|
+
for (const one of found.slice(0, MAX_LISTED)) findings.push(`design: ${folder}/${one.file}:${one.line} ${one.rule}: ${one.why}`);
|
|
166
|
+
if (found.length > MAX_LISTED) findings.push(`design: ${folder}: ${found.length - MAX_LISTED} more`);
|
|
167
|
+
}
|
|
168
|
+
const pages = paths.filter((path) => path.endsWith('.md') && !skipped(path, verbatim));
|
|
169
|
+
result.ste = { files: 0, changed: 0, unchanged: 0 };
|
|
170
|
+
for (const path of pages) {
|
|
171
|
+
let stat;
|
|
172
|
+
try { stat = statSync(join(root, path)); } catch { continue; }
|
|
173
|
+
if (!stat.isFile()) continue;
|
|
174
|
+
const text = readFileSync(join(root, path), 'utf8');
|
|
175
|
+
if (generated(text)) continue;
|
|
176
|
+
result.ste.files += 1;
|
|
177
|
+
const split = steSplit(text, addedLines(root, base, head, path));
|
|
178
|
+
result.ste.changed += split.changed.length;
|
|
179
|
+
result.ste.unchanged += split.unchanged.length;
|
|
180
|
+
for (const one of split.changed) findings.push(`ste: ${path}:${one.line} ${one.rule}: ${one.why}`);
|
|
181
|
+
if (split.unchanged.length) notes.push(`ste: ${path} has ${split.unchanged.length} finding(s) on lines this pull request does not change, at line(s) ${split.unchanged.slice(0, MAX_LISTED).map((one) => one.line).join(', ')}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
result.pages = artifactRows(root);
|
|
186
|
+
findings.push(...result.pages.findings);
|
|
187
|
+
for (const one of result.pages.stale.slice(0, MAX_LISTED)) notes.push(`stale page: ${one.page} is older than its source ${one.source}. Rebuild it: ${one.build}`);
|
|
188
|
+
if (result.pages.stale.length > MAX_LISTED) notes.push(`stale page: ${result.pages.stale.length - MAX_LISTED} more`);
|
|
189
|
+
return { findings, notes, ...result };
|
|
190
|
+
}
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
// procedure names its code, or why it names none, and each named path
|
|
18
18
|
// exists. With --event, each doc whose code the pull request changes is
|
|
19
19
|
// updated in it, or marked "still true" in its text (TOUCHED_DOCS_GATE).
|
|
20
|
+
// 5. Changed docs (roadmap R65, lib/changed-docs.mjs). With --event, each
|
|
21
|
+
// design folder the pull request adds or changes passes the design check,
|
|
22
|
+
// and each changed markdown file passes the STE limits on its changed
|
|
23
|
+
// lines. Each row of docs/artifacts.json names a build or a frozen date,
|
|
24
|
+
// and a stale page is listed.
|
|
20
25
|
//
|
|
21
26
|
// It reads the tree and the event file, and writes nothing.
|
|
22
27
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
@@ -24,6 +29,7 @@ import { join, dirname, posix, resolve } from 'node:path';
|
|
|
24
29
|
import { listChanged } from './changed-tests.mjs';
|
|
25
30
|
import { docSourceFindings, replay } from './doc-sources.mjs';
|
|
26
31
|
import { proceduresCommand } from './procedure-steps.mjs';
|
|
32
|
+
import { changedDocFindings } from './changed-docs.mjs';
|
|
27
33
|
|
|
28
34
|
// R39 line 4 gates only after the replay of line 3. The replay of the last 30
|
|
29
35
|
// merges (atlas#225): rule "file" pulled in 81 docs over 24 merges, 58 not
|
|
@@ -234,6 +240,8 @@ export function upkeepCheck(root, { event = null } = {}) {
|
|
|
234
240
|
if (dead.length > MAX_LISTED) findings.push(`dead link: ${dead.length - MAX_LISTED} more`);
|
|
235
241
|
const sources = docSourceFindings(root, { pr: event?.pull_request ?? null, gate: TOUCHED_DOCS_GATE });
|
|
236
242
|
findings.push(...sources.findings);
|
|
243
|
+
const docs = changedDocFindings(root, { pr: event?.pull_request ?? null });
|
|
244
|
+
findings.push(...docs.findings);
|
|
237
245
|
if (item.checked && !item.ok) findings.push('item: the pull request names no tracker item. Put the item id in the title, the body or the branch, or write "no item:" and a reason');
|
|
238
246
|
// The heading check holds a pull request that names an item, in a repo that
|
|
239
247
|
// keeps the Atlas template. A repo without the template is not held yet.
|
|
@@ -244,7 +252,7 @@ export function upkeepCheck(root, { event = null } = {}) {
|
|
|
244
252
|
if (text.tests.error) findings.push(`pr text: the changed-test list could not be made: ${text.tests.error}`);
|
|
245
253
|
else if (text.tests.checked) findings.push(...changedTestsFindings(event.pull_request.body, text.tests.rows));
|
|
246
254
|
}
|
|
247
|
-
return { green: findings.length === 0, findings, notes: sources.notes, index, dead, item, text, sources };
|
|
255
|
+
return { green: findings.length === 0, findings, notes: [...sources.notes, ...docs.notes], index, dead, item, text, sources, docs };
|
|
248
256
|
}
|
|
249
257
|
|
|
250
258
|
export function upkeepCommand({ what, event: eventFile, json = false, last, base }, root, line) {
|
|
@@ -263,6 +271,9 @@ export function upkeepCommand({ what, event: eventFile, json = false, last, base
|
|
|
263
271
|
line(`pr text: ${result.text.checked ? 'the six headings were read' : 'not checked'}`);
|
|
264
272
|
if (result.text.checked) line(`changed tests: ${result.text.tests?.checked ? (result.text.tests.error ? 'no list' : `${result.text.tests.rows.filter((row) => NAMED_GROUPS.has(row.group)).length} row(s) to name`) : `not checked (${result.text.tests?.why})`}`);
|
|
265
273
|
line(`doc sources: ${result.sources.units} doc(s) and procedure(s)${result.sources.touched ? `; this pull request touches the code of ${result.sources.touched.length}` : ''}`);
|
|
274
|
+
line(`designs: ${result.docs.designs ? `${result.docs.designs.length} changed design folder(s) checked` : 'not a pull request'}`);
|
|
275
|
+
line(`ste: ${result.docs.ste ? `${result.docs.ste.files} changed markdown file(s); ${result.docs.ste.changed} finding(s) on changed lines, ${result.docs.ste.unchanged} on other lines` : 'not a pull request'}`);
|
|
276
|
+
line(`pages: ${result.docs.pages.checked ? `${result.docs.pages.stale.length} stale` : 'no docs/artifacts.json'}`);
|
|
266
277
|
for (const finding of result.findings) line(` - ${finding}`);
|
|
267
278
|
for (const note of result.notes) line(` note: ${note}`);
|
|
268
279
|
line(`upkeep check: ${result.green ? 'GREEN' : 'RED'}`);
|
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
|
|
113
|
+
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names and stale pages. In a pull request it also reads the tracker item, the changed designs, and STE on the changed lines. 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 |
|
package/work/lib/numbers.mjs
CHANGED
|
@@ -35,11 +35,18 @@ export const SECOND_CHECK = 'passes-after';
|
|
|
35
35
|
// package.json or a runner config changes for many reasons, so it is not.
|
|
36
36
|
const TEST_GROUPS = Object.freeze(['unit-test', 'check-block', 'scenario-words', 'test-helper', 'kit-manifest']);
|
|
37
37
|
// The GitHub counts and the tracker counts of one row.
|
|
38
|
-
export const GITHUB_COUNTS = Object.freeze(['merges', 'not_on_green', 'reverts', 'unchecked_test_changes', 'signed_acts']);
|
|
38
|
+
export const GITHUB_COUNTS = Object.freeze(['merges', 'not_on_green', 'reverts', 'unchecked_test_changes', 'signed_acts', 'audit_unsigned_merges']);
|
|
39
39
|
export const GRAPH_COUNTS = Object.freeze(['reopens', 'step_ins', 'owner_messages', 'platform_messages']);
|
|
40
|
-
// Line 2: the
|
|
41
|
-
// shown as zero.
|
|
42
|
-
export const WAITING = Object.freeze({
|
|
40
|
+
// Line 2: the counts that wait on an item not built yet. Each is named, never
|
|
41
|
+
// shown as zero. None waits now: R14 line 3 shipped the unsigned-merge finding.
|
|
42
|
+
export const WAITING = Object.freeze({});
|
|
43
|
+
// Line 2: the audit job (R14 line 3) opens one issue for each merge that the
|
|
44
|
+
// gate sent to the owner with no signed act. GitHub Actions writes it, with
|
|
45
|
+
// the label atlas-job and this title.
|
|
46
|
+
export const AUDIT_LABEL = 'atlas-job';
|
|
47
|
+
const AUDIT_TITLE = /^atlas-job: gate-audit owner-unsigned:([0-9a-f]{40})$/;
|
|
48
|
+
const AUDIT_AUTHOR = 'github-actions[bot]';
|
|
49
|
+
const MAX_ISSUE_PAGES = 10;
|
|
43
50
|
|
|
44
51
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
45
52
|
const dayOf = (value) => String(value ?? '').slice(0, 10);
|
|
@@ -108,6 +115,26 @@ async function readAllFiles(repo, number, readGitHub) {
|
|
|
108
115
|
throw new Error(`pull request ${number} changed more than ${MAX_FILE_PAGES * PAGE} files`);
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
// Line 2: the merge commits that the audit job reported as unsigned, from
|
|
119
|
+
// its issues opened since the start of the period. Only an issue that GitHub
|
|
120
|
+
// Actions opened counts, so a person cannot add one by hand. A closed issue
|
|
121
|
+
// still counts: the merge happened. More than MAX_ISSUE_PAGES pages is
|
|
122
|
+
// refused, never cut.
|
|
123
|
+
export async function readUnsignedMerges(repo, readGitHub, sinceAt) {
|
|
124
|
+
const shas = new Set();
|
|
125
|
+
const since = new Date(sinceAt).toISOString();
|
|
126
|
+
for (let page = 1; page <= MAX_ISSUE_PAGES; page += 1) {
|
|
127
|
+
const rows = await readGitHub(`repos/${repo}/issues?labels=${AUDIT_LABEL}&state=all&since=${since}&per_page=${PAGE}&page=${page}`);
|
|
128
|
+
if (!Array.isArray(rows)) throw new Error('the audit issues are not a list');
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
const match = AUDIT_TITLE.exec(String(row?.title ?? ''));
|
|
131
|
+
if (match && row?.user?.login === AUDIT_AUTHOR && !row.pull_request) shas.add(match[1].toLowerCase());
|
|
132
|
+
}
|
|
133
|
+
if (rows.length < PAGE) return shas;
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`more than ${MAX_ISSUE_PAGES * PAGE} audit issues`);
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
// Line 2: the comment texts of one pull request, every page. More than
|
|
112
139
|
// MAX_COMMENT_PAGES pages is refused, never cut.
|
|
113
140
|
export async function readAllComments(repo, number, readGitHub) {
|
|
@@ -222,6 +249,7 @@ export async function readMerges({ repo, defaultBranch, period, readGitHub }) {
|
|
|
222
249
|
const text = await textAt('atlas.yaml', sha);
|
|
223
250
|
return text === null ? [] : deliveryFacts(text).required;
|
|
224
251
|
};
|
|
252
|
+
const unsigned = await readUnsignedMerges(repo, readGitHub, period.start);
|
|
225
253
|
return pool(merges.map((pull) => async () => {
|
|
226
254
|
const head = pull.head?.sha, merge = pull.merge_commit_sha;
|
|
227
255
|
if (!/^[0-9a-f]{40}$/i.test(String(head)) || !/^[0-9a-f]{40}$/i.test(String(merge))) throw new Error(`GitHub names no head or merge commit for pull request ${pull.number}`);
|
|
@@ -245,6 +273,7 @@ export async function readMerges({ repo, defaultBranch, period, readGitHub }) {
|
|
|
245
273
|
const signed = keys.length ? signedActOf({ repo, pull, texts: await readAllComments(repo, pull.number, readGitHub), keys }) : null;
|
|
246
274
|
return {
|
|
247
275
|
repo, number: pull.number, url: pull.html_url ?? `https://github.com/${repo}/pull/${pull.number}`, title: oneLine(pull.title ?? '', 200), merged_at: pull.merged_at,
|
|
276
|
+
audit_unsigned: unsigned.has(String(merge).toLowerCase()),
|
|
248
277
|
not_on_green: notOnGreenReasons({ required, runs, mergedAt: pull.merged_at }), revert: isRevert(pull),
|
|
249
278
|
changed_tests: tests, signed, unchecked: tests.length > 0 && !signed && !secondCheckPassed(runs, pull.merged_at),
|
|
250
279
|
};
|
|
@@ -320,6 +349,7 @@ export async function readNumbers({ product, repos, items, marks, period, readGi
|
|
|
320
349
|
if (merge.revert) events.push(['reverts', merge.merged_at]);
|
|
321
350
|
if (merge.unchecked) events.push(['unchecked_test_changes', merge.merged_at]);
|
|
322
351
|
if (merge.signed) events.push(['signed_acts', merge.merged_at]);
|
|
352
|
+
if (merge.audit_unsigned) events.push(['audit_unsigned_merges', merge.merged_at]);
|
|
323
353
|
}
|
|
324
354
|
for (const mark of reopens) if (itemRepos.get(mark.fields.item).includes(repo.id)) events.push(['reopens', mark.fields.marked_at]);
|
|
325
355
|
for (const pair of pairs) {
|
|
@@ -341,6 +371,7 @@ export async function readNumbers({ product, repos, items, marks, period, readGi
|
|
|
341
371
|
not_on_green: refused ? null : merges.filter((merge) => merge.not_on_green.length).map((merge) => ({ pr: merge.url, merged_at: merge.merged_at, reasons: merge.not_on_green })),
|
|
342
372
|
reverts: refused ? null : merges.filter((merge) => merge.revert).map((merge) => ({ pr: merge.url, title: merge.title })),
|
|
343
373
|
unchecked_test_changes: refused ? null : merges.filter((merge) => merge.unchecked).map((merge) => ({ pr: merge.url, tests: merge.changed_tests })),
|
|
374
|
+
audit_unsigned_merges: refused ? null : merges.filter((merge) => merge.audit_unsigned).map((merge) => ({ pr: merge.url, merged_at: merge.merged_at })),
|
|
344
375
|
signed_acts: refused ? null : merges.filter((merge) => merge.signed).map((merge) => ({ pr: merge.url, signed_at: merge.signed.signed_at, key: merge.signed.key })),
|
|
345
376
|
});
|
|
346
377
|
}
|
package/work/lib/roundup.mjs
CHANGED
|
@@ -266,7 +266,7 @@ function render(view) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
// Part 6. A refused count reads "refused", never 0.
|
|
269
|
-
const COLUMNS = [['merges', 'merges'], ['not_on_green', 'not on green'], ['reverts', 'reverts'], ['unchecked_test_changes', 'unchecked test changes'], ['signed_acts', 'signed acts'], ['reopens', 'reopens'], ['step_ins', 'step-ins'], ['owner_messages', 'owner messages'], ['platform_messages', 'platform messages']];
|
|
269
|
+
const COLUMNS = [['merges', 'merges'], ['not_on_green', 'not on green'], ['reverts', 'reverts'], ['unchecked_test_changes', 'unchecked test changes'], ['signed_acts', 'signed acts'], ['audit_unsigned_merges', 'unsigned merges'], ['reopens', 'reopens'], ['step_ins', 'step-ins'], ['owner_messages', 'owner messages'], ['platform_messages', 'platform messages']];
|
|
270
270
|
const countsLine = (row) => COLUMNS.map(([key, word]) => `${row[key] === null ? 'refused' : row[key]} ${word}`).join(' · ');
|
|
271
271
|
function renderNumbers(numbers, more) {
|
|
272
272
|
const out = [`6. The numbers, ${numbers.from} to ${numbers.to} (UTC)`];
|
|
@@ -284,7 +284,7 @@ function renderNumbers(numbers, more) {
|
|
|
284
284
|
out.push(` input to live: ${numbers.delivered.length} delivered ${numbers.delivered.length === 1 ? 'item' : 'items'}${median === null ? '' : `, median ${median} days`}`);
|
|
285
285
|
for (const row of numbers.delivered.slice(0, SHOWN)) out.push(listLine(row.title, `${row.item.slice(0, 8)} · ${row.days ?? '?'} days · live ${dayOf(row.live_at)}`, ' '));
|
|
286
286
|
out.push(...more(numbers.delivered));
|
|
287
|
-
out.push(`
|
|
287
|
+
for (const [count, wait] of Object.entries(numbers.owner_acts ?? {})) out.push(` ${count.replace(/_/g, ' ')}: ${wait}`);
|
|
288
288
|
if (numbers.not_counted_sessions) out.push(` ${numbers.not_counted_sessions} sessions ran with the hooks off, so their messages were not counted`);
|
|
289
289
|
return out.join('\n');
|
|
290
290
|
}
|
package/work/mcp.mjs
CHANGED
|
@@ -227,6 +227,9 @@ export const GITHUB_READS = Object.freeze([
|
|
|
227
227
|
// and the owner key file at the first parent of the merge.
|
|
228
228
|
new RegExp(`^repos/${REPO}/issues/\\d{1,9}/comments\\?per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
229
229
|
new RegExp(`^repos/${REPO}/contents/atlas/owner-signers\\?ref=${SHA}$`),
|
|
230
|
+
// Roadmap R16 line 2: the audit issues of the period, for the merges that
|
|
231
|
+
// the audit job reported as unsigned.
|
|
232
|
+
new RegExp(`^repos/${REPO}/issues\\?labels=atlas-job&state=all&since=\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z&per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
230
233
|
]);
|
|
231
234
|
export function githubReader() {
|
|
232
235
|
return async (path) => {
|