@arjunkhera/atlas 0.4.28 → 0.4.29
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/package.json +1 -1
- package/work/lib/numbers.mjs +54 -17
- package/work/lib/roundup.mjs +2 -2
- package/work/mcp.mjs +4 -0
package/package.json
CHANGED
package/work/lib/numbers.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { deliveryFacts, FATAL_CONCLUSIONS } from './delivery.mjs';
|
|
|
16
16
|
import { standingOf, isOwnerMessage, isStepIn, senderOfMark } from './step-ins.mjs';
|
|
17
17
|
import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
|
|
18
18
|
import { groupOf } from '../../door/lib/changed-tests.mjs';
|
|
19
|
+
import { SIGNERS_FILE, parseSigners, verifyTexts, findAct } from '../../door/lib/signed-act.mjs';
|
|
19
20
|
|
|
20
21
|
export const NUMBERS_MAX_DAYS = 92;
|
|
21
22
|
export const NUMBERS_DEFAULT_DAYS = 28;
|
|
@@ -24,20 +25,21 @@ export const PAGE = 100;
|
|
|
24
25
|
// never cut.
|
|
25
26
|
export const MAX_PULL_PAGES = 10;
|
|
26
27
|
const MAX_FILE_PAGES = 30;
|
|
28
|
+
const MAX_COMMENT_PAGES = 10;
|
|
27
29
|
const CONCURRENCY = 8;
|
|
28
30
|
const DAY = 86_400_000;
|
|
29
31
|
// The second check of a changed test (roadmap R73). A signed act (R72) is
|
|
30
|
-
// the other one
|
|
32
|
+
// the other one.
|
|
31
33
|
export const SECOND_CHECK = 'passes-after';
|
|
32
34
|
// A changed or removed file of these groups is an old test that changed. A
|
|
33
35
|
// package.json or a runner config changes for many reasons, so it is not.
|
|
34
36
|
const TEST_GROUPS = Object.freeze(['unit-test', 'check-block', 'scenario-words', 'test-helper', 'kit-manifest']);
|
|
35
37
|
// The GitHub counts and the tracker counts of one row.
|
|
36
|
-
export const GITHUB_COUNTS = Object.freeze(['merges', 'not_on_green', 'reverts', 'unchecked_test_changes']);
|
|
38
|
+
export const GITHUB_COUNTS = Object.freeze(['merges', 'not_on_green', 'reverts', 'unchecked_test_changes', 'signed_acts']);
|
|
37
39
|
export const GRAPH_COUNTS = Object.freeze(['reopens', 'step_ins', 'owner_messages', 'platform_messages']);
|
|
38
|
-
// Line 2: the
|
|
40
|
+
// Line 2: the count that waits on an item not built yet. It is named, never
|
|
39
41
|
// shown as zero.
|
|
40
|
-
export const WAITING = Object.freeze({
|
|
42
|
+
export const WAITING = Object.freeze({ audit_unsigned_merges: 'waits on R14 line 3, the unsigned-merge finding of the audit job' });
|
|
41
43
|
|
|
42
44
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
43
45
|
const dayOf = (value) => String(value ?? '').slice(0, 10);
|
|
@@ -106,6 +108,29 @@ async function readAllFiles(repo, number, readGitHub) {
|
|
|
106
108
|
throw new Error(`pull request ${number} changed more than ${MAX_FILE_PAGES * PAGE} files`);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
// Line 2: the comment texts of one pull request, every page. More than
|
|
112
|
+
// MAX_COMMENT_PAGES pages is refused, never cut.
|
|
113
|
+
export async function readAllComments(repo, number, readGitHub) {
|
|
114
|
+
const texts = [];
|
|
115
|
+
for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
|
|
116
|
+
const rows = await readGitHub(`repos/${repo}/issues/${number}/comments?per_page=${PAGE}&page=${page}`);
|
|
117
|
+
if (!Array.isArray(rows)) throw new Error(`the comments of pull request ${number} are not a list`);
|
|
118
|
+
texts.push(...rows.map((row) => String(row?.body ?? '')));
|
|
119
|
+
if (rows.length < PAGE) return texts;
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`pull request ${number} has more than ${MAX_COMMENT_PAGES * PAGE} comments`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Line 2: the owner's signed act for the merged head of one pull request, or
|
|
125
|
+
// null. The key comes from atlas/owner-signers at the first parent of the
|
|
126
|
+
// merge, as the gate reads it from the base. The act must name this repo,
|
|
127
|
+
// this pull request, its base branch and its last head. The gate checked the
|
|
128
|
+
// merge base when it let the head pass; this count does not read it again.
|
|
129
|
+
export function signedActOf({ repo, pull, texts, keys }) {
|
|
130
|
+
const found = findAct(verifyTexts(texts, keys), { kind: 'merge', repo, pr: Number(pull.number), base: pull.base?.ref, head: pull.head?.sha });
|
|
131
|
+
return found ? { signed_at: found.signed_at, key: found.key } : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
109
134
|
// Run each job, at most `limit` at a time. The first failure stops the rest
|
|
110
135
|
// and is thrown, so a count is whole or refused.
|
|
111
136
|
async function pool(jobs, limit = CONCURRENCY) {
|
|
@@ -179,38 +204,48 @@ export function secondCheckPassed(runs = [], mergedAt) {
|
|
|
179
204
|
export async function readMerges({ repo, defaultBranch, period, readGitHub }) {
|
|
180
205
|
const pulls = await readAllPulls(repo, readGitHub, period.start);
|
|
181
206
|
const merges = pulls.filter((pull) => pull.merged_at && time(pull.merged_at) >= period.start && time(pull.merged_at) < period.end && (!defaultBranch || pull.base?.ref === defaultBranch));
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
207
|
+
const cache = new Map();
|
|
208
|
+
// One file at one commit, read once. A file that is not there is null.
|
|
209
|
+
const textAt = (path, sha) => {
|
|
210
|
+
const key = `${path}@${sha}`;
|
|
211
|
+
if (!cache.has(key)) cache.set(key, (async () => {
|
|
185
212
|
let file;
|
|
186
|
-
try { file = await readGitHub(`repos/${repo}/contents
|
|
187
|
-
catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return
|
|
188
|
-
if (!file || typeof file.content !== 'string') return
|
|
189
|
-
return
|
|
213
|
+
try { file = await readGitHub(`repos/${repo}/contents/${path}?ref=${sha}`); }
|
|
214
|
+
catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null; throw error; }
|
|
215
|
+
if (!file || typeof file.content !== 'string') return null;
|
|
216
|
+
return Buffer.from(file.content, 'base64').toString('utf8');
|
|
190
217
|
})());
|
|
191
|
-
return
|
|
218
|
+
return cache.get(key);
|
|
219
|
+
};
|
|
220
|
+
const requiredAt = async (sha) => {
|
|
221
|
+
const text = await textAt('atlas.yaml', sha);
|
|
222
|
+
return text === null ? [] : deliveryFacts(text).required;
|
|
192
223
|
};
|
|
193
224
|
return pool(merges.map((pull) => async () => {
|
|
194
225
|
const head = pull.head?.sha, merge = pull.merge_commit_sha;
|
|
195
226
|
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}`);
|
|
196
|
-
const
|
|
227
|
+
const before = async () => {
|
|
197
228
|
const parent = (await readGitHub(`repos/${repo}/commits/${merge}`))?.parents?.[0]?.sha;
|
|
198
229
|
if (!/^[0-9a-f]{40}$/i.test(String(parent))) throw new Error(`GitHub names no parent of the merge commit of pull request ${pull.number}`);
|
|
199
|
-
|
|
230
|
+
const [required, signers] = await Promise.all([requiredAt(parent), textAt(SIGNERS_FILE, parent)]);
|
|
231
|
+
return { required, signers };
|
|
200
232
|
};
|
|
201
|
-
const [checks, files, required] = await Promise.all([
|
|
233
|
+
const [checks, files, { required, signers }] = await Promise.all([
|
|
202
234
|
readGitHub(`repos/${repo}/commits/${head}/check-runs?per_page=${PAGE}`),
|
|
203
235
|
readAllFiles(repo, pull.number, readGitHub),
|
|
204
|
-
|
|
236
|
+
before(),
|
|
205
237
|
]);
|
|
206
238
|
if (!Array.isArray(checks?.check_runs)) throw new Error(`the check runs of pull request ${pull.number} are not a list`);
|
|
207
239
|
const runs = checks.check_runs;
|
|
208
240
|
if (Number(checks?.total_count ?? runs.length) > runs.length) throw new Error(`the head of pull request ${pull.number} has more than ${PAGE} check runs`);
|
|
209
241
|
const tests = changedOldTests(files);
|
|
242
|
+
// With no owner key at the merge, no act can count: the comments are not read.
|
|
243
|
+
const keys = parseSigners(signers ?? '').keys;
|
|
244
|
+
const signed = keys.length ? signedActOf({ repo, pull, texts: await readAllComments(repo, pull.number, readGitHub), keys }) : null;
|
|
210
245
|
return {
|
|
211
246
|
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,
|
|
212
247
|
not_on_green: notOnGreenReasons({ required, runs, mergedAt: pull.merged_at }), revert: isRevert(pull),
|
|
213
|
-
changed_tests: tests, unchecked: tests.length > 0 && !secondCheckPassed(runs, pull.merged_at),
|
|
248
|
+
changed_tests: tests, signed, unchecked: tests.length > 0 && !signed && !secondCheckPassed(runs, pull.merged_at),
|
|
214
249
|
};
|
|
215
250
|
}));
|
|
216
251
|
}
|
|
@@ -283,6 +318,7 @@ export async function readNumbers({ product, repos, items, marks, period, readGi
|
|
|
283
318
|
if (merge.not_on_green.length) events.push(['not_on_green', merge.merged_at]);
|
|
284
319
|
if (merge.revert) events.push(['reverts', merge.merged_at]);
|
|
285
320
|
if (merge.unchecked) events.push(['unchecked_test_changes', merge.merged_at]);
|
|
321
|
+
if (merge.signed) events.push(['signed_acts', merge.merged_at]);
|
|
286
322
|
}
|
|
287
323
|
for (const mark of reopens) if (itemRepos.get(mark.fields.item).includes(repo.id)) events.push(['reopens', mark.fields.marked_at]);
|
|
288
324
|
for (const pair of pairs) {
|
|
@@ -304,6 +340,7 @@ export async function readNumbers({ product, repos, items, marks, period, readGi
|
|
|
304
340
|
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 })),
|
|
305
341
|
reverts: refused ? null : merges.filter((merge) => merge.revert).map((merge) => ({ pr: merge.url, title: merge.title })),
|
|
306
342
|
unchecked_test_changes: refused ? null : merges.filter((merge) => merge.unchecked).map((merge) => ({ pr: merge.url, tests: merge.changed_tests })),
|
|
343
|
+
signed_acts: refused ? null : merges.filter((merge) => merge.signed).map((merge) => ({ pr: merge.url, signed_at: merge.signed.signed_at, key: merge.signed.key })),
|
|
307
344
|
});
|
|
308
345
|
}
|
|
309
346
|
const delivered = deliveredRows({ marks: marks.filter((mark) => mark.fields?.product === product), items, period });
|
package/work/lib/roundup.mjs
CHANGED
|
@@ -251,7 +251,7 @@ function render(view) {
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
// Part 6. A refused count reads "refused", never 0.
|
|
254
|
-
const COLUMNS = [['merges', 'merges'], ['not_on_green', 'not on green'], ['reverts', 'reverts'], ['unchecked_test_changes', 'unchecked test changes'], ['reopens', 'reopens'], ['step_ins', 'step-ins'], ['owner_messages', 'owner messages'], ['platform_messages', 'platform messages']];
|
|
254
|
+
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']];
|
|
255
255
|
const countsLine = (row) => COLUMNS.map(([key, word]) => `${row[key] === null ? 'refused' : row[key]} ${word}`).join(' · ');
|
|
256
256
|
function renderNumbers(numbers, more) {
|
|
257
257
|
const out = [`6. The numbers, ${numbers.from} to ${numbers.to} (UTC)`];
|
|
@@ -269,7 +269,7 @@ function renderNumbers(numbers, more) {
|
|
|
269
269
|
out.push(` input to live: ${numbers.delivered.length} delivered ${numbers.delivered.length === 1 ? 'item' : 'items'}${median === null ? '' : `, median ${median} days`}`);
|
|
270
270
|
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)}`, ' '));
|
|
271
271
|
out.push(...more(numbers.delivered));
|
|
272
|
-
out.push(` owner acts:
|
|
272
|
+
out.push(` owner acts: unsigned merges ${numbers.owner_acts.audit_unsigned_merges}`);
|
|
273
273
|
if (numbers.not_counted_sessions) out.push(` ${numbers.not_counted_sessions} sessions ran with the hooks off, so their messages were not counted`);
|
|
274
274
|
return out.join('\n');
|
|
275
275
|
}
|
package/work/mcp.mjs
CHANGED
|
@@ -216,6 +216,10 @@ export const GITHUB_READS = Object.freeze([
|
|
|
216
216
|
new RegExp(`^repos/${REPO}/pulls/\\d{1,9}/files\\?per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
217
217
|
// Roadmap R14 line 6: the newest run of the daily audit, for the roundup.
|
|
218
218
|
new RegExp(`^repos/${REPO}/actions/workflows/atlas-jobs\\.yml/runs\\?per_page=\\d{1,3}$`),
|
|
219
|
+
// Roadmap R16 line 2: the comments of a merge, for the owner's signed act,
|
|
220
|
+
// and the owner key file at the first parent of the merge.
|
|
221
|
+
new RegExp(`^repos/${REPO}/issues/\\d{1,9}/comments\\?per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
222
|
+
new RegExp(`^repos/${REPO}/contents/atlas/owner-signers\\?ref=${SHA}$`),
|
|
219
223
|
]);
|
|
220
224
|
export function githubReader() {
|
|
221
225
|
return async (path) => {
|