@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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; it is not built yet.
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 counts that wait on items not built yet. They are named, never
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({ signed_acts: 'waits on R72, the signed act', audit_unsigned_merges: 'waits on R14, the audit job' });
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 facts = new Map();
183
- const requiredAt = (sha) => {
184
- if (!facts.has(sha)) facts.set(sha, (async () => {
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/atlas.yaml?ref=${sha}`); }
187
- catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return []; throw error; }
188
- if (!file || typeof file.content !== 'string') return [];
189
- return deliveryFacts(Buffer.from(file.content, 'base64').toString('utf8')).required;
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 facts.get(sha);
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 requiredBefore = async () => {
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
- return requiredAt(parent);
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
- requiredBefore(),
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 });
@@ -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: signed acts ${numbers.owner_acts.signed_acts}; unsigned merges ${numbers.owner_acts.audit_unsigned_merges}`);
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) => {