@arjunkhera/atlas 0.4.27 → 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/door/lib/gate-check.mjs +108 -11
- 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/door/lib/gate-check.mjs
CHANGED
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
// An owner result turns into success when a comment of the pull request holds
|
|
23
23
|
// the owner's signed act for this repo, this pull request and this head
|
|
24
24
|
// (roadmap R72, signed-act.mjs). The key comes from atlas/owner-signers on the
|
|
25
|
-
// base, never from the head.
|
|
25
|
+
// base, never from the head. An act for an older head carries to this head
|
|
26
|
+
// when the new commits only merge the base in with no conflict (item 34b554e7).
|
|
26
27
|
//
|
|
27
28
|
// Red wins over owner: the pull request must be fixed before the owner signs.
|
|
28
29
|
// Rules that hold everywhere here:
|
|
29
|
-
// 1. The owner-only list
|
|
30
|
-
// the head
|
|
30
|
+
// 1. The owner-only list, atlas.yaml and the guarded workflows come from the
|
|
31
|
+
// base commit, never from the head and never from the merge base, which
|
|
32
|
+
// an agent can pick (item d56a9c03).
|
|
31
33
|
// 2. Nothing of the head runs. Every read is a git object.
|
|
32
34
|
// 3. It fails closed: a head or a merge base that git cannot read is red.
|
|
33
35
|
import { spawnSync } from 'node:child_process';
|
|
@@ -205,7 +207,7 @@ export function saysNoBehaviourChange(body) {
|
|
|
205
207
|
return !/^#/.test(first) && /^no\b/i.test(first);
|
|
206
208
|
}
|
|
207
209
|
|
|
208
|
-
// Product code sits under the `product_paths:` of atlas.yaml at the
|
|
210
|
+
// Product code sits under the `product_paths:` of atlas.yaml at the base
|
|
209
211
|
// (roadmap R4 line 5). It is not a doc by the rule of fails-before, and not a
|
|
210
212
|
// markdown file: a unit test cannot test skill text yet (roadmap R2 line 4).
|
|
211
213
|
// A repo with no such key keeps the other two rules alone.
|
|
@@ -230,7 +232,7 @@ export function coveredFiles(root, green, mergeBase) {
|
|
|
230
232
|
// for the files that main changed since that check was last green.
|
|
231
233
|
function testRows({ root, mergeBase, head, body, redMain, rules, undo = null }) {
|
|
232
234
|
const list = listChanged({ root, base: mergeBase, head });
|
|
233
|
-
const product = list.other_paths.filter(productTest(root, rules
|
|
235
|
+
const product = list.other_paths.filter(productTest(root, rules));
|
|
234
236
|
if (!product.length) return [];
|
|
235
237
|
// The exact undo of a change that tripped the brake puts main back as it
|
|
236
238
|
// was before that change, so it needs no new test (roadmap R13 line 4).
|
|
@@ -292,11 +294,16 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
292
294
|
answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null };
|
|
293
295
|
answer.rows.push(...brakeRows(state, undo));
|
|
294
296
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
+
// The rules come from the base commit, never from the merge base. An agent
|
|
298
|
+
// picks the merge base when it picks where its branch starts, also a middle
|
|
299
|
+
// commit of a merged pull request that weakened the list and then took the
|
|
300
|
+
// change back (item d56a9c03). The diff stays merge base to head.
|
|
301
|
+
const ruleRef = rules ?? baseSha;
|
|
302
|
+
for (const row of classify({ root: top, base: mergeBase, head: headSha, rules: ruleRef }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
303
|
+
answer.rows.push(...jobRows(top, ruleRef, headSha, changes));
|
|
297
304
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
298
305
|
if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
|
|
299
|
-
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
|
|
306
|
+
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules: ruleRef, undo }));
|
|
300
307
|
if (signed) signedRows(top, baseSha, headSha, signed, answer);
|
|
301
308
|
return finish(answer);
|
|
302
309
|
}
|
|
@@ -318,17 +325,106 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
|
|
|
318
325
|
if (!keys.length) { note(`no owner key in ${SIGNERS_FILE} on the base, so no signed act counts`); return; }
|
|
319
326
|
note(`${keys.length} owner key(s) in ${SIGNERS_FILE} on the base: ${keys.map((key) => key.fingerprint).join(', ')}`);
|
|
320
327
|
const verified = verifyTexts(texts, keys);
|
|
321
|
-
|
|
328
|
+
let found = findAct(verified, { kind: 'merge', repo, pr: Number(pr), base: baseRef, merge_base: answer.merge_base, head: headSha });
|
|
329
|
+
let carried = null;
|
|
330
|
+
if (!found) {
|
|
331
|
+
carried = carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answer);
|
|
332
|
+
if (carried?.found) found = carried.found;
|
|
333
|
+
}
|
|
322
334
|
if (!found) {
|
|
323
335
|
for (const row of verified.filter((each) => each.error).slice(0, 3)) note(`a signed act that does not count: ${row.error}`);
|
|
336
|
+
if (carried?.why) note(`a signed act for an older head does not carry: ${carried.why}`);
|
|
324
337
|
if (verified.some((each) => !each.error)) note('a valid signed act names another pull request, base, merge base or head; sign this head');
|
|
325
338
|
return;
|
|
326
339
|
}
|
|
327
340
|
const locked = answer.rows.filter((row) => row.kind === 'owner' && row.class === 'gate-checks');
|
|
328
341
|
if (locked.length) { note('a signed act never clears a head job named like a check: remove that job'); return; }
|
|
329
342
|
for (const row of answer.rows) if (row.kind === 'owner') row.kind = 'signed';
|
|
330
|
-
answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified };
|
|
331
|
-
note(`the owner signed
|
|
343
|
+
answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified, ...(carried?.found ? { carried_from: found.act.head } : {}) };
|
|
344
|
+
if (carried?.found) note(`the owner signed head ${found.act.head.slice(0, 7)} with key ${found.key} at ${found.signed_at}; this head only adds ${carried.merges} clean merge(s) of the base`);
|
|
345
|
+
else note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// At most this many new commits between a signed head and this head.
|
|
349
|
+
export const CARRY_LIMIT = 20;
|
|
350
|
+
// At most this many signed heads are tried, so copies of one block cost little.
|
|
351
|
+
export const CARRY_TRIES = 3;
|
|
352
|
+
const contains = (root, older, newer) => spawnSync('git', [MERGE_BASE, '--is-ancestor', '--end-of-options', older, newer], { cwd: root }).status === 0;
|
|
353
|
+
|
|
354
|
+
// Tracker item 34b554e7: a signed act survives a clean merge of the base. A
|
|
355
|
+
// batch of signed pull requests merges one at a time, so each next one is
|
|
356
|
+
// behind and must merge the base in, which makes a new head. The act for the
|
|
357
|
+
// old head H0 carries to this head H only when all of these hold:
|
|
358
|
+
// 1. The act names this repo, this pull request and this base branch.
|
|
359
|
+
// 2. H0 is in the history of H, and the merge base of the act is in the
|
|
360
|
+
// history of the merge base now: the base only moved forward.
|
|
361
|
+
// 3. Each commit in H that is not in H0 and not in the base is a merge with
|
|
362
|
+
// two parents. Its second parent was a tip of the base branch after the
|
|
363
|
+
// touch: a first-parent commit of the base after the signed merge base. Its tree is the tree that
|
|
364
|
+
// git makes for a merge of its two parents with no conflict. So no commit
|
|
365
|
+
// adds a line that the owner did not see, and a conflict fix needs a new touch.
|
|
366
|
+
// A clean merge brings in only what the base already holds, so the pull
|
|
367
|
+
// request changes no path now that it did not change at H0.
|
|
368
|
+
// 4. The brake is not on: an act signed before a brake never clears it.
|
|
369
|
+
// 5. Only the newest touch on this pull request carries, so a rollback to a
|
|
370
|
+
// head that the owner moved past does not clear.
|
|
371
|
+
// It fails closed: a commit or a merge that git cannot read stops the carry.
|
|
372
|
+
function carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answer) {
|
|
373
|
+
const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
|
|
374
|
+
// Every valid merge act of this pull request, on any base branch and head.
|
|
375
|
+
const mine = [];
|
|
376
|
+
for (const row of verified) {
|
|
377
|
+
if (row.error) continue;
|
|
378
|
+
for (const act of row.acts) if (act.kind === 'merge' && same(act.repo, repo) && Number(act.pr) === Number(pr)) mine.push({ act, signed_at: row.signed_at, key: row.key, verified: row.verified });
|
|
379
|
+
}
|
|
380
|
+
if (!mine.length) return null;
|
|
381
|
+
// Only the newest touch carries. An older act names a head that the owner
|
|
382
|
+
// moved past, so a rollback to it must not clear (security review finding 2).
|
|
383
|
+
const newest = mine.reduce((top, one) => (one.signed_at > top ? one.signed_at : top), '');
|
|
384
|
+
const seen = new Set();
|
|
385
|
+
const candidates = mine.filter((one) => {
|
|
386
|
+
const id = `${one.act.base} ${one.act.merge_base} ${one.act.head}`;
|
|
387
|
+
if (one.signed_at !== newest || one.act.base !== baseRef || one.act.head === headSha || seen.has(id)) return false;
|
|
388
|
+
seen.add(id);
|
|
389
|
+
return true;
|
|
390
|
+
}).slice(0, CARRY_TRIES);
|
|
391
|
+
if (!candidates.length) return { why: 'the newest signed act of this pull request names another base branch or head' };
|
|
392
|
+
if (answer.rows.some((row) => row.kind === 'owner' && row.class === 'brake')) return { why: 'the brake is on, so the owner signs this head' };
|
|
393
|
+
let why = null;
|
|
394
|
+
for (const one of candidates) {
|
|
395
|
+
const result = carryOne(root, baseSha, headSha, answer.merge_base, one.act);
|
|
396
|
+
if (result.merges) return { found: one, merges: result.merges };
|
|
397
|
+
why ??= result.why;
|
|
398
|
+
}
|
|
399
|
+
return { why: `${why} (${candidates.length} signed head(s) tried)` };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// git with no replace objects and no system config, so the merge that the
|
|
403
|
+
// gate makes depends on the commits alone.
|
|
404
|
+
const PINNED = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
|
|
405
|
+
|
|
406
|
+
function carryOne(root, baseSha, headSha, mergeBase, act) {
|
|
407
|
+
const old = commitOf(root, act.head);
|
|
408
|
+
if (old !== act.head) return { why: `the signed head ${act.head.slice(0, 7)} is not a commit in this clone` };
|
|
409
|
+
if (commitOf(root, act.merge_base) !== act.merge_base) return { why: `the signed merge base ${act.merge_base.slice(0, 7)} is not a commit in this clone` };
|
|
410
|
+
if (!contains(root, old, headSha)) return { why: `the signed head ${old.slice(0, 7)} is not in the history of this head` };
|
|
411
|
+
if (!contains(root, act.merge_base, mergeBase)) return { why: 'the base did not only move forward since the touch' };
|
|
412
|
+
const shas = (git(root, ['rev-list', `--max-count=${CARRY_LIMIT + 1}`, headSha, `^${old}`, `^${baseSha}`], { allowFail: true }) ?? '').split('\n').filter(Boolean);
|
|
413
|
+
if (!shas.length) return { why: 'this head adds no merge of the base' };
|
|
414
|
+
if (shas.length > CARRY_LIMIT) return { why: `this head adds more than ${CARRY_LIMIT} commits` };
|
|
415
|
+
// The tips that the base branch held since the touch: its first-parent
|
|
416
|
+
// commits after the signed merge base. A merge may bring in only one of
|
|
417
|
+
// them, never a middle commit of a merged pull request (finding 3).
|
|
418
|
+
const tips = new Set((git(root, ['rev-list', '--first-parent', baseSha, `^${act.merge_base}`], { allowFail: true }) ?? '').split('\n').filter(Boolean));
|
|
419
|
+
for (const sha of shas) {
|
|
420
|
+
const [tree, ...parents] = (git(root, ['show', '-s', '--format=%T %P', sha], { allowFail: true }) ?? '').trim().split(' ');
|
|
421
|
+
if (parents.length !== 2) return { why: `commit ${sha.slice(0, 7)} is not a merge of two parents` };
|
|
422
|
+
if (!tips.has(parents[1])) return { why: `merge ${sha.slice(0, 7)} brings in ${parents[1].slice(0, 7)}, which was never a tip of the base after the touch` };
|
|
423
|
+
const made = spawnSync('git', ['-c', 'merge.renames=true', '-c', 'merge.conflictStyle=merge', 'merge-tree', '--write-tree', '--no-messages', '--end-of-options', parents[0], parents[1]], { cwd: root, encoding: 'utf8', env: PINNED, timeout: 30_000 });
|
|
424
|
+
if (made.status !== 0) return { why: `merge ${sha.slice(0, 7)} has a conflict, so its fix needs a new touch` };
|
|
425
|
+
if (made.stdout.split('\n')[0].trim() !== tree) return { why: `merge ${sha.slice(0, 7)} changes more than a clean merge of its parents` };
|
|
426
|
+
}
|
|
427
|
+
return { merges: shas.length };
|
|
332
428
|
}
|
|
333
429
|
|
|
334
430
|
// Line 1: while the brake is on, every pull request of the repo comes to the
|
|
@@ -366,6 +462,7 @@ export function renderText(answer) {
|
|
|
366
462
|
}
|
|
367
463
|
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
368
464
|
else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
|
|
465
|
+
else if (answer.signed?.carried_from) out.push(`SUCCESS: the owner signed head ${oneLine(answer.signed.carried_from).slice(0, 7)}, and this head only merges in the base.`);
|
|
369
466
|
else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
|
|
370
467
|
else out.push('SUCCESS: no rule holds this pull request.');
|
|
371
468
|
return out;
|
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) => {
|