@arjunkhera/atlas 0.4.23 → 0.4.25
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/jobs/npm-release.yaml +24 -0
- package/door/lib/install.mjs +6 -5
- package/door/lib/jobs.mjs +98 -2
- package/hooks/step-in.mjs +27 -2
- package/package.json +1 -1
- package/work/lib/numbers.mjs +317 -0
- package/work/lib/roundup.mjs +46 -2
- package/work/lib/step-ins.mjs +35 -15
- package/work/mcp.mjs +15 -6
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# An Atlas job (the routines design, records part). A job does nothing until a
|
|
2
|
+
# repo names it in its atlas.yaml, with the npm packages it publishes:
|
|
3
|
+
#
|
|
4
|
+
# jobs:
|
|
5
|
+
# atlas: [npm-release]
|
|
6
|
+
# npm: ['@scope/name']
|
|
7
|
+
#
|
|
8
|
+
# It fits a repo that publishes to npm through a release.yml on master that
|
|
9
|
+
# keeps the Atlas release record, as Atlas does. A service, a schema repo or a
|
|
10
|
+
# repo that publishes nothing does not name it.
|
|
11
|
+
#
|
|
12
|
+
# The check is Atlas code (door/lib/jobs.mjs). Each version that npm got in the
|
|
13
|
+
# last 2 days must have the proof that `atlas upgrade` asks for: a successful
|
|
14
|
+
# push run of release.yml on a master commit that recorded that version with
|
|
15
|
+
# the sha256 of its tarball. A branch can publish before any merge (the gate
|
|
16
|
+
# probes, P7), so a version with no such run is a finding. `problem` is a
|
|
17
|
+
# code: no-tag, not-on-master, never-tip or no-record. Only a person can act
|
|
18
|
+
# on it (deprecate the version on npm), so the job may only report.
|
|
19
|
+
title: Each new npm version was published by a release run on master
|
|
20
|
+
when: daily
|
|
21
|
+
needs: []
|
|
22
|
+
act: atlas:npm-release
|
|
23
|
+
may: report
|
|
24
|
+
fields: [package, version, problem, commit]
|
package/door/lib/install.mjs
CHANGED
|
@@ -218,12 +218,13 @@ export const recordName = (version, sha256) => `atlas-release-${version}-${sha25
|
|
|
218
218
|
// commit must once have been the tip of master. A tag that a person made or
|
|
219
219
|
// moved finds no record, so the upgrade refuses. With no sha256 (a loaded
|
|
220
220
|
// copy has no tarball), any record of this version counts, and the byte
|
|
221
|
-
// compare of every file does the rest.
|
|
221
|
+
// compare of every file does the rest. A refusal carries a `code` too, for
|
|
222
|
+
// the npm-release job of the watcher (jobs.mjs).
|
|
222
223
|
export function findRelease({ gh, repo, version, sha256 }) {
|
|
223
224
|
const commit = tagCommit(gh, repo, version);
|
|
224
|
-
if (!commit) return { problem: `no tag v${version} names a commit` };
|
|
225
|
-
if (!onMaster(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
|
|
226
|
-
if (!wasTip(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
|
|
225
|
+
if (!commit) return { code: 'no-tag', problem: `no tag v${version} names a commit` };
|
|
226
|
+
if (!onMaster(gh, repo, commit)) return { code: 'not-on-master', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
|
|
227
|
+
if (!wasTip(gh, repo, commit)) return { code: 'never-tip', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
|
|
227
228
|
const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&head_sha=${commit}&per_page=100`]).workflow_runs ?? [];
|
|
228
229
|
const prefix = recordName(version, '');
|
|
229
230
|
const named = (name) => (sha256 ? name === recordName(version, sha256) : name.startsWith(prefix) && /^[0-9a-f]{64}$/.test(name.slice(prefix.length)));
|
|
@@ -234,7 +235,7 @@ export function findRelease({ gh, repo, version, sha256 }) {
|
|
|
234
235
|
const artifacts = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]).artifacts ?? [];
|
|
235
236
|
if (artifacts.some((artifact) => named(artifact.name ?? ''))) return { run, commit };
|
|
236
237
|
}
|
|
237
|
-
return { problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
|
|
238
|
+
return { code: 'no-record', commit, problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
// The blob ids that the version files of the tarball must have: the files of
|
package/door/lib/jobs.mjs
CHANGED
|
@@ -24,7 +24,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
|
|
24
24
|
import { dirname, join, resolve } from 'node:path';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { parseYaml } from '../../shape/check.mjs';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
27
28
|
import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
|
|
29
|
+
import { findRelease, sha512Integrity } from './install.mjs';
|
|
28
30
|
import { checkGateFiles, checkGateAudit, checkGateErrors } from './gate-audit.mjs';
|
|
29
31
|
import { commitAudit, ghApi } from './gate.mjs';
|
|
30
32
|
|
|
@@ -107,7 +109,100 @@ export async function checkRedMain({ repo, branch, env }) {
|
|
|
107
109
|
return findings;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
//
|
|
112
|
+
// ---------------------------------------------------------------- the check of npm-release
|
|
113
|
+
|
|
114
|
+
// A branch of the repo can run its own release.yml and publish to npm before any merge (the gate
|
|
115
|
+
// probes, P7). So each version that npm got in the last NPM_DAYS days must have the proof that
|
|
116
|
+
// `atlas upgrade` asks for: a successful push run of release.yml on a master commit that recorded
|
|
117
|
+
// this version with the sha256 of this exact tarball (findRelease, install.mjs). The watcher runs
|
|
118
|
+
// once a day, so two days cover one missed run. A longer window reaches versions from before the
|
|
119
|
+
// release record existed (Atlas 0.4.0), and the tip proof reads only the newest 100 changes of master.
|
|
120
|
+
export const NPM_DAYS = 2;
|
|
121
|
+
export const NPM_MAX = 50;
|
|
122
|
+
const PACKAGE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
123
|
+
|
|
124
|
+
// The npm packages that atlas.yaml names under `jobs: { npm: [...] }`.
|
|
125
|
+
export function npmPackages(root) {
|
|
126
|
+
const facts = parseYaml(readFileSync(join(root, 'atlas.yaml'), 'utf8')) ?? {};
|
|
127
|
+
const named = facts.jobs?.npm;
|
|
128
|
+
if (!Array.isArray(named) || !named.length) throw new Error('atlas.yaml names no package under jobs.npm');
|
|
129
|
+
const bad = named.map(String).find((name) => !PACKAGE.test(name));
|
|
130
|
+
if (bad !== undefined) throw new Error('jobs.npm in atlas.yaml holds a name that is not an npm package name');
|
|
131
|
+
return named.map(String);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// https, or http for this machine only (a test uses a fake registry on 127.0.0.1).
|
|
135
|
+
function registryBase(env) {
|
|
136
|
+
const text = String(env.NPM_REGISTRY_URL || 'https://registry.npmjs.org');
|
|
137
|
+
let url;
|
|
138
|
+
try { url = new URL(text); } catch { throw usage('NPM_REGISTRY_URL is not a URL'); }
|
|
139
|
+
const local = url.hostname === '127.0.0.1' || url.hostname === 'localhost';
|
|
140
|
+
if (!(url.protocol === 'https:' || (url.protocol === 'http:' && local))) throw usage('NPM_REGISTRY_URL must be https (http is allowed only for 127.0.0.1 or localhost)');
|
|
141
|
+
return url;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// findRelease reads GitHub through a sync `gh`. Here each read is a fetch, so the proof runs again
|
|
145
|
+
// after each read it asked for, until it needs none. Every read is a GET, and the same path gives the
|
|
146
|
+
// same answer, so a replay changes nothing.
|
|
147
|
+
export async function replayRelease({ base, key, repo, version, sha256 }) {
|
|
148
|
+
const answers = new Map();
|
|
149
|
+
for (let round = 0; round < 50; round += 1) {
|
|
150
|
+
try {
|
|
151
|
+
return findRelease({
|
|
152
|
+
repo, version, sha256,
|
|
153
|
+
gh: (args) => {
|
|
154
|
+
const path = `/${args.at(-1)}`;
|
|
155
|
+
if (!answers.has(path)) throw Object.assign(new Error('a read is still to come'), { need: path });
|
|
156
|
+
const answer = answers.get(path);
|
|
157
|
+
if (answer instanceof Error) throw answer;
|
|
158
|
+
return answer;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!error.need) throw error;
|
|
163
|
+
const r = await api(base, key, 'GET', error.need);
|
|
164
|
+
if (r.status === 404) answers.set(error.need, Object.assign(new Error('Not Found'), { stderr: 'HTTP 404' }));
|
|
165
|
+
else if (!r.ok) throw new Error(`GitHub answered ${r.status} when the release of ${version} was read`);
|
|
166
|
+
else answers.set(error.need, r.data);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`the release of ${version} needed more than 50 reads`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function checkNpmRelease({ repo, env, root, now = Date.now(), fetchImpl = fetch }) {
|
|
173
|
+
const base = apiBase(env);
|
|
174
|
+
const key = env.GITHUB_TOKEN || '';
|
|
175
|
+
if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
|
|
176
|
+
const registry = registryBase(env);
|
|
177
|
+
const findings = [];
|
|
178
|
+
for (const name of npmPackages(root)) {
|
|
179
|
+
const response = await fetchImpl(new URL(name.replace('/', '%2F'), `${registry.href.replace(/\/?$/, '/')}`));
|
|
180
|
+
if (!response.ok) throw new Error(`npm answered ${response.status} when ${name} was read`);
|
|
181
|
+
const doc = await response.json();
|
|
182
|
+
// A broken answer must read broken, never none: each version needs a publish time.
|
|
183
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
184
|
+
if (!object(doc?.versions) || !object(doc?.time)) throw new Error(`npm sent no versions and times for ${name}`);
|
|
185
|
+
if (Object.keys(doc.versions).some((version) => !Number.isFinite(Date.parse(doc.time[version])))) throw new Error(`npm sent a version of ${name} with no publish time`);
|
|
186
|
+
const recent = Object.keys(doc.versions).filter((version) => now - Date.parse(doc.time[version]) <= NPM_DAYS * DAY);
|
|
187
|
+
if (recent.length > NPM_MAX) throw new Error(`npm holds ${recent.length} versions of ${name} from the last ${NPM_DAYS} days, more than ${NPM_MAX}`);
|
|
188
|
+
for (const version of recent.sort()) {
|
|
189
|
+
if (!VALUE.test(version)) throw new Error(`npm holds a version of ${name} that is not a plain version`);
|
|
190
|
+
const dist = doc.versions[version].dist ?? {};
|
|
191
|
+
const tarball = new URL(String(dist.tarball ?? ''), registry);
|
|
192
|
+
if (tarball.origin !== registry.origin) throw new Error(`the tarball of ${name}@${version} is not on the registry`);
|
|
193
|
+
const got = await fetchImpl(tarball);
|
|
194
|
+
if (!got.ok) throw new Error(`npm answered ${got.status} when the tarball of ${name}@${version} was read`);
|
|
195
|
+
const bytes = Buffer.from(await got.arrayBuffer());
|
|
196
|
+
if (sha512Integrity(bytes) !== dist.integrity) throw new Error(`the tarball of ${name}@${version} does not match the integrity npm records`);
|
|
197
|
+
const release = await replayRelease({ base, key, repo, version, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
198
|
+
if (!release.problem) continue;
|
|
199
|
+
findings.push({ job: 'npm-release', key: `${name}@${version}`, package: name, version, problem: release.code, ...(release.commit ? { commit: release.commit } : {}) });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The check of each Atlas job, by its id, else by its `when`. A job whose check is not built yet reads `none` and
|
|
111
206
|
// says so in the notes; it never reads `found` from nothing.
|
|
112
207
|
// The gate jobs (roadmap R14) read GitHub through `gh`, which the Actions
|
|
113
208
|
// runner holds; their token is the run's own GITHUB_TOKEN.
|
|
@@ -116,6 +211,7 @@ const CHECKS = {
|
|
|
116
211
|
'gate-files': ({ root, env, fetchImpl }) => checkGateFiles({ root, env, fetchImpl }),
|
|
117
212
|
'gate-audit': ({ repo, branch, gh }) => checkGateAudit({ repo, branch, gh, audit: commitAudit }),
|
|
118
213
|
'gate-errors': ({ repo, gh }) => checkGateErrors({ repo, gh }),
|
|
214
|
+
'npm-release': checkNpmRelease,
|
|
119
215
|
};
|
|
120
216
|
|
|
121
217
|
export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs(), gh = ghApi, fetchImpl = fetch }) {
|
|
@@ -128,7 +224,7 @@ export async function jobsCheck({ root, repo, branch = null, env = process.env,
|
|
|
128
224
|
for (const id of named) {
|
|
129
225
|
const job = jobs.get(id);
|
|
130
226
|
if (!job) { notes.push(`${JOB_ID.test(id) ? id : '(a bad id)'}: no Atlas job has this id`); continue; }
|
|
131
|
-
const check = CHECKS[job.when];
|
|
227
|
+
const check = CHECKS[job.id] ?? CHECKS[job.when];
|
|
132
228
|
if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
|
|
133
229
|
try {
|
|
134
230
|
if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
|
package/hooks/step-in.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// every test that reads the count.
|
|
6
6
|
//
|
|
7
7
|
// Its limits are the reason the package may ship it:
|
|
8
|
-
// 1. A row holds the time, the session and the repos
|
|
8
|
+
// 1. A row holds the time, the session and the repos, and `from: platform`
|
|
9
|
+
// when a platform sent the message (item f7391622). It never holds the
|
|
9
10
|
// text of the message.
|
|
10
11
|
// 2. It makes no network call, reads no key and starts no program. It reads
|
|
11
12
|
// the hook input, atlas.yaml in the session folder or one level down, and
|
|
@@ -24,6 +25,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
24
25
|
const MAX_REPOS = 16;
|
|
25
26
|
const REPO = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
26
27
|
const SESSION = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
|
|
28
|
+
// Item f7391622: in a cloud project the platform also sends messages to the
|
|
29
|
+
// session, such as a reminder, a relay or a pull request event. A message
|
|
30
|
+
// that starts with one of these tags is the platform's.
|
|
31
|
+
const PLATFORM_TAGS = ['wake', 'session-context', 'relay', 'coordinator-relay', 'system-reminder', 'task-notification'];
|
|
32
|
+
const READ_BYTES = 65536;
|
|
27
33
|
|
|
28
34
|
// The folder that the hook writes and the Atlas tools read. The tools keep
|
|
29
35
|
// their own copy of the path, because these bytes are pinned.
|
|
@@ -52,13 +58,32 @@ export function reposOf(cwd) {
|
|
|
52
58
|
return [...new Set(found.map((name) => repoOf(join(cwd, name, 'atlas.yaml'))).filter(Boolean))].slice(0, MAX_REPOS);
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
// Who sent one message, from its first bytes: 'owner' or 'platform'. The
|
|
62
|
+
// text is read here and never kept.
|
|
63
|
+
// 1. A <message> tag with trigger="true" and from="human" is the owner's. A
|
|
64
|
+
// cloud project wraps an owner message this way.
|
|
65
|
+
// 2. A message that starts with a platform tag is the platform's.
|
|
66
|
+
// 3. Any other message is the owner's, so an unknown shape counts high.
|
|
67
|
+
export function senderOf(prompt) {
|
|
68
|
+
if (typeof prompt !== 'string') return 'owner';
|
|
69
|
+
const head = prompt.slice(0, READ_BYTES);
|
|
70
|
+
for (const match of head.matchAll(/<message\b([^>]*)>/g)) {
|
|
71
|
+
const attrs = match[1];
|
|
72
|
+
if (/\btrigger="true"/.test(attrs) && /\bfrom="human"/.test(attrs)) return 'owner';
|
|
73
|
+
}
|
|
74
|
+
const tag = /^\s*<([a-z][a-z-]*)[\s>]/.exec(head);
|
|
75
|
+
return tag && PLATFORM_TAGS.includes(tag[1]) ? 'platform' : 'owner';
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
// One message in, one row out, or null when no row is due.
|
|
56
79
|
export function rowOf(input, { now = new Date(), id = randomUUID() } = {}) {
|
|
57
80
|
const session = typeof input?.session_id === 'string' && SESSION.test(input.session_id) ? input.session_id : null;
|
|
58
81
|
if (!session) return null;
|
|
59
82
|
const repos = reposOf(input?.cwd);
|
|
60
83
|
if (repos === null) return null;
|
|
61
|
-
|
|
84
|
+
// A row with no sender is the owner's, as in the hook of 0.4.8, so only a
|
|
85
|
+
// platform message carries the key.
|
|
86
|
+
return { v: 1, id, at: now.toISOString(), session, repos, ...(senderOf(input?.prompt) === 'platform' ? { from: 'platform' } : {}) };
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
// The row goes to a file of its own, written under a temporary name and
|
package/package.json
CHANGED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Roadmap R16 (item 76d8c717): the numbers. Part 6 of the roundup counts,
|
|
2
|
+
// per repo, per week and per month, how changes reach the main branch and
|
|
3
|
+
// how often the owner steps in. Design: docs/design-docs/the-numbers/.
|
|
4
|
+
//
|
|
5
|
+
// Three rules hold it together:
|
|
6
|
+
// 1. Code counts. Every number is read from GitHub or from the tracker's
|
|
7
|
+
// marks at read time; the lead never types one, and none is stored.
|
|
8
|
+
// 2. A count that cannot be read is refused, never shown as zero. A repo
|
|
9
|
+
// whose GitHub read fails names the reason, and its GitHub counts are
|
|
10
|
+
// null (line 4).
|
|
11
|
+
// 3. A merge is not on green when a required check on its head failed,
|
|
12
|
+
// or finished after the merge (line 5).
|
|
13
|
+
import { refuse } from './verb-fields.mjs';
|
|
14
|
+
import { oneLine } from './questions.mjs';
|
|
15
|
+
import { deliveryFacts, FATAL_CONCLUSIONS } from './delivery.mjs';
|
|
16
|
+
import { standingOf, isOwnerMessage, isStepIn, senderOfMark } from './step-ins.mjs';
|
|
17
|
+
import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
|
|
18
|
+
import { groupOf } from '../../door/lib/changed-tests.mjs';
|
|
19
|
+
|
|
20
|
+
export const NUMBERS_MAX_DAYS = 92;
|
|
21
|
+
export const NUMBERS_DEFAULT_DAYS = 28;
|
|
22
|
+
export const PAGE = 100;
|
|
23
|
+
// At most this many pull requests are read for one repo. More is refused,
|
|
24
|
+
// never cut.
|
|
25
|
+
export const MAX_PULL_PAGES = 10;
|
|
26
|
+
const MAX_FILE_PAGES = 30;
|
|
27
|
+
const CONCURRENCY = 8;
|
|
28
|
+
const DAY = 86_400_000;
|
|
29
|
+
// The second check of a changed test (roadmap R73). A signed act (R72) is
|
|
30
|
+
// the other one; it is not built yet.
|
|
31
|
+
export const SECOND_CHECK = 'passes-after';
|
|
32
|
+
// A changed or removed file of these groups is an old test that changed. A
|
|
33
|
+
// package.json or a runner config changes for many reasons, so it is not.
|
|
34
|
+
const TEST_GROUPS = Object.freeze(['unit-test', 'check-block', 'scenario-words', 'test-helper', 'kit-manifest']);
|
|
35
|
+
// 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']);
|
|
37
|
+
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
|
|
39
|
+
// 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' });
|
|
41
|
+
|
|
42
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
43
|
+
const dayOf = (value) => String(value ?? '').slice(0, 10);
|
|
44
|
+
const time = (value) => Date.parse(value ?? '');
|
|
45
|
+
|
|
46
|
+
// The period of the numbers. from and to are UTC days, both counted. With
|
|
47
|
+
// neither, the period is the last NUMBERS_DEFAULT_DAYS days to the read.
|
|
48
|
+
export function periodOf({ from, to } = {}, readAt) {
|
|
49
|
+
const check = (value, name) => {
|
|
50
|
+
if (value === undefined) return null;
|
|
51
|
+
// A day that does not exist, such as 2026-02-30, is refused, not rolled over.
|
|
52
|
+
if (typeof value !== 'string' || !DATE.test(value) || Number.isNaN(time(`${value}T00:00:00Z`)) || new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value) refuse(`${name} must be a date, for example 2026-10-01`);
|
|
53
|
+
return value;
|
|
54
|
+
};
|
|
55
|
+
const fromDay = check(from, 'from'), toDay = check(to, 'to');
|
|
56
|
+
const end = toDay ? time(`${toDay}T00:00:00Z`) + DAY : time(readAt);
|
|
57
|
+
const start = fromDay ? time(`${fromDay}T00:00:00Z`) : end - NUMBERS_DEFAULT_DAYS * DAY;
|
|
58
|
+
if (start >= end) refuse('from must be on or before to');
|
|
59
|
+
if (end - start > NUMBERS_MAX_DAYS * DAY) refuse(`the numbers read at most ${NUMBERS_MAX_DAYS} days`);
|
|
60
|
+
return { start, end, from: new Date(start).toISOString(), to: new Date(end).toISOString() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The weeks of a period, counted back from its end: week 1 is the last seven
|
|
64
|
+
// days. The first week may be short. And the calendar months it touches.
|
|
65
|
+
export function bucketsOf(period) {
|
|
66
|
+
const weeks = [];
|
|
67
|
+
for (let k = 1, hi = period.end; hi > period.start; k += 1, hi -= 7 * DAY) {
|
|
68
|
+
const lo = Math.max(period.start, hi - 7 * DAY);
|
|
69
|
+
weeks.push({ week: k, start: lo, end: hi, from: dayOf(new Date(lo).toISOString()), to: dayOf(new Date(hi - 1).toISOString()) });
|
|
70
|
+
}
|
|
71
|
+
const months = [];
|
|
72
|
+
const first = new Date(period.start);
|
|
73
|
+
for (let y = first.getUTCFullYear(), m = first.getUTCMonth(); ; m += 1) {
|
|
74
|
+
const lo = Date.UTC(y, m, 1), hi = Date.UTC(y, m + 1, 1);
|
|
75
|
+
if (lo >= period.end) break;
|
|
76
|
+
const start = Math.max(lo, period.start), end = Math.min(hi, period.end);
|
|
77
|
+
months.push({ month: new Date(lo).toISOString().slice(0, 7), start, end, from: dayOf(new Date(start).toISOString()), to: dayOf(new Date(end - 1).toISOString()), whole: start === lo && end === hi });
|
|
78
|
+
}
|
|
79
|
+
return { weeks, months };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Every pull request of a repo that changed in the period, newest change
|
|
83
|
+
// first. The list pages past 100 and stops at the first page that ends
|
|
84
|
+
// before the period (line 4). A merge is always older than the last change
|
|
85
|
+
// of its pull request, so no merge of the period is missed.
|
|
86
|
+
export async function readAllPulls(repo, readGitHub, sinceAt, maxPages = MAX_PULL_PAGES) {
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (let page = 1; ; page += 1) {
|
|
89
|
+
if (page > maxPages) throw new Error(`more than ${maxPages * PAGE} pull requests changed in the period`);
|
|
90
|
+
const list = await readGitHub(`repos/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=${PAGE}&page=${page}`);
|
|
91
|
+
if (!Array.isArray(list)) throw new Error(`page ${page} of the pull requests is not a list`);
|
|
92
|
+
rows.push(...list);
|
|
93
|
+
const last = list.at(-1);
|
|
94
|
+
if (list.length < PAGE || !last || time(last.updated_at ?? last.created_at) < sinceAt) return rows;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readAllFiles(repo, number, readGitHub) {
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (let page = 1; page <= MAX_FILE_PAGES; page += 1) {
|
|
101
|
+
const list = await readGitHub(`repos/${repo}/pulls/${number}/files?per_page=${PAGE}&page=${page}`);
|
|
102
|
+
if (!Array.isArray(list)) throw new Error(`the files of pull request ${number} are not a list`);
|
|
103
|
+
rows.push(...list);
|
|
104
|
+
if (list.length < PAGE) return rows;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`pull request ${number} changed more than ${MAX_FILE_PAGES * PAGE} files`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Run each job, at most `limit` at a time. The first failure stops the rest
|
|
110
|
+
// and is thrown, so a count is whole or refused.
|
|
111
|
+
async function pool(jobs, limit = CONCURRENCY) {
|
|
112
|
+
const out = new Array(jobs.length);
|
|
113
|
+
let next = 0, failed = null;
|
|
114
|
+
const worker = async () => {
|
|
115
|
+
while (next < jobs.length && !failed) {
|
|
116
|
+
const at = next++;
|
|
117
|
+
try { out[at] = await jobs[at](); } catch (error) { failed = failed ?? error; }
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, worker));
|
|
121
|
+
if (failed) throw failed;
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Line 5: why one merge was not on green, or [] when it was. The required
|
|
126
|
+
// checks are those of atlas.yaml at the first parent of the merge commit, as
|
|
127
|
+
// item_done reads them: the gate as it stood when the merge came. A repo that names none counts
|
|
128
|
+
// every GitHub Actions check run on the head. For each check, the newest run
|
|
129
|
+
// that started before the merge decides.
|
|
130
|
+
export function notOnGreenReasons({ required = [], runs = [], mergedAt }) {
|
|
131
|
+
const merged = time(mergedAt);
|
|
132
|
+
const actions = runs.filter((run) => run?.app?.id === ACTIONS_APP_ID && typeof run.name === 'string');
|
|
133
|
+
const named = required.length > 0;
|
|
134
|
+
const names = named ? required : [...new Set(actions.map((run) => run.name))].sort();
|
|
135
|
+
// With no required list and no run, nothing proved the head green.
|
|
136
|
+
if (!names.length) return ['no check ran on the head'];
|
|
137
|
+
const reasons = [];
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
const all = actions.filter((run) => run.name === name);
|
|
140
|
+
const before = all.filter((run) => time(run.started_at ?? run.created_at) <= merged)
|
|
141
|
+
.sort((a, b) => `${b.started_at ?? ''}#${String(b.id ?? 0).padStart(24, '0')}`.localeCompare(`${a.started_at ?? ''}#${String(a.id ?? 0).padStart(24, '0')}`));
|
|
142
|
+
const run = before[0];
|
|
143
|
+
if (!run) { reasons.push(all.length ? `${name} started after the merge` : `${name} never ran on the head`); continue; }
|
|
144
|
+
const done = time(run.completed_at);
|
|
145
|
+
if (run.status !== 'completed' || Number.isNaN(done)) { reasons.push(`${name} had not finished at the merge`); continue; }
|
|
146
|
+
if (FATAL_CONCLUSIONS.includes(run.conclusion ?? '')) { reasons.push(`${name} ended ${run.conclusion}`); continue; }
|
|
147
|
+
if (named && run.conclusion === 'skipped') { reasons.push(`${name} was skipped`); continue; }
|
|
148
|
+
if (done > merged) reasons.push(`${name} finished ${Math.round((done - merged) / 1000)} s after the merge`);
|
|
149
|
+
}
|
|
150
|
+
return reasons;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A merged pull request that undoes another: GitHub's revert button writes
|
|
154
|
+
// both of these.
|
|
155
|
+
export const isRevert = (pull) => /^revert\b/i.test(String(pull.title ?? '').trim()) || /^Reverts [A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#\d+/m.test(String(pull.body ?? ''));
|
|
156
|
+
|
|
157
|
+
// The old tests that one merge changed or removed. An added test is new, so
|
|
158
|
+
// it is not here.
|
|
159
|
+
export function changedOldTests(files = []) {
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
if (!['modified', 'changed', 'removed', 'renamed'].includes(file?.status)) continue;
|
|
163
|
+
const path = file.status === 'renamed' ? (file.previous_filename ?? file.filename) : file.filename;
|
|
164
|
+
if (typeof path === 'string' && TEST_GROUPS.includes(groupOf(path))) out.push(path);
|
|
165
|
+
}
|
|
166
|
+
return [...new Set(out)].sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The second check of one merge: the newest passes-after run that started
|
|
170
|
+
// before the merge ended in success.
|
|
171
|
+
export function secondCheckPassed(runs = [], mergedAt) {
|
|
172
|
+
const merged = time(mergedAt);
|
|
173
|
+
const run = runs.filter((one) => one?.app?.id === ACTIONS_APP_ID && one.name === SECOND_CHECK && time(one.started_at ?? one.created_at) <= merged)
|
|
174
|
+
.sort((a, b) => String(b.started_at ?? '').localeCompare(String(a.started_at ?? '')))[0];
|
|
175
|
+
return Boolean(run && run.status === 'completed' && run.conclusion === 'success' && time(run.completed_at) <= merged);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Each merge of one repo in the period, with what the numbers count of it.
|
|
179
|
+
export async function readMerges({ repo, defaultBranch, period, readGitHub }) {
|
|
180
|
+
const pulls = await readAllPulls(repo, readGitHub, period.start);
|
|
181
|
+
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 () => {
|
|
185
|
+
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;
|
|
190
|
+
})());
|
|
191
|
+
return facts.get(sha);
|
|
192
|
+
};
|
|
193
|
+
return pool(merges.map((pull) => async () => {
|
|
194
|
+
const head = pull.head?.sha, merge = pull.merge_commit_sha;
|
|
195
|
+
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 () => {
|
|
197
|
+
const parent = (await readGitHub(`repos/${repo}/commits/${merge}`))?.parents?.[0]?.sha;
|
|
198
|
+
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);
|
|
200
|
+
};
|
|
201
|
+
const [checks, files, required] = await Promise.all([
|
|
202
|
+
readGitHub(`repos/${repo}/commits/${head}/check-runs?per_page=${PAGE}`),
|
|
203
|
+
readAllFiles(repo, pull.number, readGitHub),
|
|
204
|
+
requiredBefore(),
|
|
205
|
+
]);
|
|
206
|
+
if (!Array.isArray(checks?.check_runs)) throw new Error(`the check runs of pull request ${pull.number} are not a list`);
|
|
207
|
+
const runs = checks.check_runs;
|
|
208
|
+
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
|
+
const tests = changedOldTests(files);
|
|
210
|
+
return {
|
|
211
|
+
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
|
+
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),
|
|
214
|
+
};
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The time an item went live: the release that holds it, the deploy check,
|
|
219
|
+
// or the last merge. A legacy mark has none of them.
|
|
220
|
+
export function liveAt(mark) {
|
|
221
|
+
const fields = mark.fields ?? {};
|
|
222
|
+
const releases = (Array.isArray(fields.releases) ? fields.releases : []).map((row) => row?.published_at).filter((at) => !Number.isNaN(time(at)));
|
|
223
|
+
if (releases.length) return releases.sort().at(-1);
|
|
224
|
+
if (!Number.isNaN(time(fields.deployed?.checked_at))) return fields.deployed.checked_at;
|
|
225
|
+
const merged = (Array.isArray(fields.prs) ? fields.prs : []).map((row) => row?.merged_at).filter((at) => !Number.isNaN(time(at)));
|
|
226
|
+
if (merged.length) return merged.sort().at(-1);
|
|
227
|
+
return fields.marked_at ?? null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function median(values) {
|
|
231
|
+
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
|
232
|
+
if (!sorted.length) return null;
|
|
233
|
+
const mid = Math.floor(sorted.length / 2);
|
|
234
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
235
|
+
}
|
|
236
|
+
const days = (ms) => Math.round((ms / DAY) * 10) / 10;
|
|
237
|
+
|
|
238
|
+
// Line 3: one row for each item that went live in the period, from input to
|
|
239
|
+
// live. Input is the time the item was recorded.
|
|
240
|
+
export function deliveredRows({ marks, items, period }) {
|
|
241
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
242
|
+
const rows = [];
|
|
243
|
+
for (const mark of marks) {
|
|
244
|
+
if (mark.fields?.kind !== 'delivery' || mark.fields?.verdict !== 'delivered') continue;
|
|
245
|
+
const item = byId.get(mark.fields.item);
|
|
246
|
+
if (!item) continue;
|
|
247
|
+
const live = liveAt(mark);
|
|
248
|
+
if (Number.isNaN(time(live)) || time(live) < period.start || time(live) >= period.end) continue;
|
|
249
|
+
const input = item.created_at;
|
|
250
|
+
const span = Number.isNaN(time(input)) ? null : days(time(live) - time(input));
|
|
251
|
+
rows.push({ item: item.id, title: oneLine(item.title ?? '', 120), mark: mark.id, input_at: input ?? null, live_at: live, days: span });
|
|
252
|
+
}
|
|
253
|
+
// One row for each item: an item delivered, reopened and delivered again
|
|
254
|
+
// keeps its last delivery in the period.
|
|
255
|
+
const last = new Map();
|
|
256
|
+
for (const row of rows) if (!last.has(row.item) || String(row.live_at) > String(last.get(row.item).live_at)) last.set(row.item, row);
|
|
257
|
+
const kept = [...last.values()].sort((a, b) => String(a.live_at).localeCompare(String(b.live_at)));
|
|
258
|
+
return { rows: kept, median_days_to_live: median(kept.map((row) => row.days)) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const zero = (keys) => Object.fromEntries(keys.map((key) => [key, 0]));
|
|
262
|
+
const nulls = (keys) => Object.fromEntries(keys.map((key) => [key, null]));
|
|
263
|
+
|
|
264
|
+
// The numbers of one product: per repo, the totals, the weeks and the months;
|
|
265
|
+
// and the delivered items with their time to live.
|
|
266
|
+
export async function readNumbers({ product, repos, items, marks, period, readGitHub }) {
|
|
267
|
+
const { weeks, months } = bucketsOf(period);
|
|
268
|
+
const inPeriod = (at) => !Number.isNaN(time(at)) && time(at) >= period.start && time(at) < period.end;
|
|
269
|
+
const productRepos = new Set(repos.map((repo) => repo.id));
|
|
270
|
+
const itemRepos = new Map(items.map((item) => [item.id, Array.isArray(item.fields?.repos) ? item.fields.repos : []]));
|
|
271
|
+
const pairs = standingOf(marks).filter(({ mark }) => mark.fields?.verdict === 'counted' && inPeriod(mark.fields.marked_at));
|
|
272
|
+
const reopens = marks.filter((mark) => mark.fields?.kind === 'reopen' && itemRepos.has(mark.fields.item) && inPeriod(mark.fields.marked_at));
|
|
273
|
+
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const repo of repos) {
|
|
276
|
+
// Each counted thing, with the moment it counts at.
|
|
277
|
+
const events = [];
|
|
278
|
+
let refused = null, merges = [];
|
|
279
|
+
try { merges = await readMerges({ repo: repo.id, defaultBranch: repo.default_branch, period, readGitHub }); }
|
|
280
|
+
catch (error) { refused = `GitHub did not answer: ${oneLine(error?.message ?? String(error), 300)}`; }
|
|
281
|
+
if (!refused) for (const merge of merges) {
|
|
282
|
+
events.push(['merges', merge.merged_at]);
|
|
283
|
+
if (merge.not_on_green.length) events.push(['not_on_green', merge.merged_at]);
|
|
284
|
+
if (merge.revert) events.push(['reverts', merge.merged_at]);
|
|
285
|
+
if (merge.unchecked) events.push(['unchecked_test_changes', merge.merged_at]);
|
|
286
|
+
}
|
|
287
|
+
for (const mark of reopens) if (itemRepos.get(mark.fields.item).includes(repo.id)) events.push(['reopens', mark.fields.marked_at]);
|
|
288
|
+
for (const pair of pairs) {
|
|
289
|
+
if (!(pair.mark.fields.repos ?? []).includes(repo.id)) continue;
|
|
290
|
+
if (isOwnerMessage(pair)) events.push(['owner_messages', pair.mark.fields.marked_at]);
|
|
291
|
+
if (isStepIn(pair)) events.push(['step_ins', pair.mark.fields.marked_at]);
|
|
292
|
+
if (senderOfMark(pair.mark) === 'platform') events.push(['platform_messages', pair.mark.fields.marked_at]);
|
|
293
|
+
}
|
|
294
|
+
const counts = (lo, hi) => {
|
|
295
|
+
const row = { ...(refused ? nulls(GITHUB_COUNTS) : zero(GITHUB_COUNTS)), ...zero(GRAPH_COUNTS) };
|
|
296
|
+
for (const [key, at] of events) if (time(at) >= lo && time(at) < hi) row[key] += 1;
|
|
297
|
+
return row;
|
|
298
|
+
};
|
|
299
|
+
out.push({
|
|
300
|
+
repo: repo.id, refused,
|
|
301
|
+
total: counts(period.start, period.end),
|
|
302
|
+
weeks: weeks.map((week) => ({ week: week.week, from: week.from, to: week.to, ...counts(week.start, week.end) })),
|
|
303
|
+
months: months.map((month) => ({ month: month.month, from: month.from, to: month.to, whole: month.whole, ...counts(month.start, month.end) })),
|
|
304
|
+
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
|
+
reverts: refused ? null : merges.filter((merge) => merge.revert).map((merge) => ({ pr: merge.url, title: merge.title })),
|
|
306
|
+
unchecked_test_changes: refused ? null : merges.filter((merge) => merge.unchecked).map((merge) => ({ pr: merge.url, tests: merge.changed_tests })),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const delivered = deliveredRows({ marks: marks.filter((mark) => mark.fields?.product === product), items, period });
|
|
310
|
+
const notCounted = marks.filter((mark) => mark.fields?.kind === 'step-in' && mark.fields?.verdict === 'not counted' && inPeriod(mark.fields.marked_at)).length;
|
|
311
|
+
return {
|
|
312
|
+
from: dayOf(period.from), to: dayOf(new Date(period.end - 1).toISOString()), repos: out,
|
|
313
|
+
delivered: delivered.rows, median_days_to_live: delivered.median_days_to_live,
|
|
314
|
+
not_counted_sessions: notCounted, owner_acts: { ...WAITING }, second_check: SECOND_CHECK,
|
|
315
|
+
repos_known: [...productRepos].sort(),
|
|
316
|
+
};
|
|
317
|
+
}
|
package/work/lib/roundup.mjs
CHANGED
|
@@ -13,11 +13,17 @@
|
|
|
13
13
|
// and questions that wait longer than STUCK_DAYS
|
|
14
14
|
// 5. Trend per week, for TREND_WEEKS weeks: merges, deliveries,
|
|
15
15
|
// new items; and the open questions with the oldest age
|
|
16
|
+
// 6. The numbers per repo, per week and per month: merges, merges not
|
|
17
|
+
// on green, reverts, reopens, unchecked test changes,
|
|
18
|
+
// step-ins and owner messages; and the time from input
|
|
19
|
+
// to live of each delivered item (roadmap R16,
|
|
20
|
+
// numbers.mjs). A count GitHub did not give is refused.
|
|
16
21
|
// Every row carries a citation: an item id, a question id or a pull request
|
|
17
22
|
// URL. It is computed at read time and never stored.
|
|
18
23
|
import { refuse, isoDate, LIVE_LIFECYCLES } from './verb-fields.mjs';
|
|
19
24
|
import { oneLine, ROW_LINE_MAX } from './questions.mjs';
|
|
20
25
|
import { readPullsByRepo, pullsOfItem, daysBetween, listLine } from './delivery.mjs';
|
|
26
|
+
import { readNumbers, periodOf } from './numbers.mjs';
|
|
21
27
|
|
|
22
28
|
export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
|
|
23
29
|
|
|
@@ -116,6 +122,7 @@ export function createRoundupVerbs(core) {
|
|
|
116
122
|
const windowDays = args.days === undefined ? DEFAULT_WINDOW : args.days;
|
|
117
123
|
if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > MAX_WINDOW) refuse(`days must be a whole number from 1 to ${MAX_WINDOW}`);
|
|
118
124
|
const readAt = now();
|
|
125
|
+
const period = periodOf({ from: args.from, to: args.to }, readAt);
|
|
119
126
|
const since = minus(readAt, windowDays);
|
|
120
127
|
const trendFrom = minus(readAt, Math.max(windowDays, TREND_WEEKS * 7));
|
|
121
128
|
|
|
@@ -174,7 +181,16 @@ export function createRoundupVerbs(core) {
|
|
|
174
181
|
for (const row of items) bump(row.created_at, 'new_items');
|
|
175
182
|
const oldest = needRows.reduce((max, row) => Math.max(max, row.days_waiting ?? 0), 0);
|
|
176
183
|
|
|
177
|
-
|
|
184
|
+
// 6. The numbers. They read every pull request of the period, not only
|
|
185
|
+
// the newest 100, and the marks of the tracker.
|
|
186
|
+
const markType = await typeNamed('mark');
|
|
187
|
+
const marks = await client.listAllEntities({ type: markType.name, state: 'active' });
|
|
188
|
+
const numbers = await readNumbers({
|
|
189
|
+
product, items, marks, period, readGitHub,
|
|
190
|
+
repos: repoIds.map((id) => ({ id, default_branch: reg.repos.get(id)?.fields?.default_branch ?? null })),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits, weeks, oldest, repoErrors, numbers });
|
|
178
194
|
return {
|
|
179
195
|
verb: 'roundup', read_at: readAt, product, window_days: windowDays, since,
|
|
180
196
|
needs_you: needRows,
|
|
@@ -182,6 +198,7 @@ export function createRoundupVerbs(core) {
|
|
|
182
198
|
what_moved: { merged, delivered: deliveries, new_items: created },
|
|
183
199
|
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 },
|
|
184
200
|
trend: { weeks, open_questions: needRows.length, oldest_question_days: oldest },
|
|
201
|
+
numbers,
|
|
185
202
|
repos_read: repoIds, repo_errors: repoErrors,
|
|
186
203
|
citation: { product, read_at: readAt, items: items.length, repos: repoIds },
|
|
187
204
|
text,
|
|
@@ -191,7 +208,7 @@ export function createRoundupVerbs(core) {
|
|
|
191
208
|
}
|
|
192
209
|
|
|
193
210
|
function render(view) {
|
|
194
|
-
const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits = { stale: [], unread: [] }, weeks, oldest, repoErrors } = view;
|
|
211
|
+
const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits = { stale: [], unread: [] }, weeks, oldest, repoErrors, numbers } = view;
|
|
195
212
|
const more = (list) => (list.length > SHOWN ? [` and ${list.length - SHOWN} more`] : []);
|
|
196
213
|
const out = [`${product} — roundup, ${dayOf(readAt)}`];
|
|
197
214
|
|
|
@@ -227,6 +244,33 @@ function render(view) {
|
|
|
227
244
|
...weeks.map((week) => ` week ${week.week} (${week.from} to ${week.to}): ${plural(week.merged, 'merge')} · ${plural(week.delivered, 'delivery')} · ${plural(week.new_items, 'new item')}`),
|
|
228
245
|
` waiting on you: ${plural(needRows.length, 'question')}${needRows.length ? `, oldest ${plural(oldest, 'day')}` : ''}`].join('\n'));
|
|
229
246
|
|
|
247
|
+
out.push(renderNumbers(numbers, more));
|
|
248
|
+
|
|
230
249
|
if (repoErrors.length) out.push([`GitHub did not answer for ${plural(repoErrors.length, 'repo')}; parts 3 to 5 count from the graph only`, ...repoErrors.map((row) => oneLine(` ${row.repo} — ${row.reason}`, ROW_LINE_MAX))].join('\n'));
|
|
231
250
|
return out.join('\n\n');
|
|
232
251
|
}
|
|
252
|
+
|
|
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']];
|
|
255
|
+
const countsLine = (row) => COLUMNS.map(([key, word]) => `${row[key] === null ? 'refused' : row[key]} ${word}`).join(' · ');
|
|
256
|
+
function renderNumbers(numbers, more) {
|
|
257
|
+
const out = [`6. The numbers, ${numbers.from} to ${numbers.to} (UTC)`];
|
|
258
|
+
if (!numbers.repos.length) out.push(' no repo is registered for this product');
|
|
259
|
+
for (const repo of numbers.repos) {
|
|
260
|
+
out.push(` ${repo.repo}${repo.refused ? ` · GitHub counts refused: ${oneLine(repo.refused, 160)}` : ''}`);
|
|
261
|
+
out.push(` total: ${countsLine(repo.total)}`);
|
|
262
|
+
for (const month of repo.months) out.push(` ${month.month}${month.whole ? '' : ` (${month.from} to ${month.to})`}: ${countsLine(month)}`);
|
|
263
|
+
for (const week of repo.weeks) out.push(` week ${week.week} (${week.from} to ${week.to}): ${countsLine(week)}`);
|
|
264
|
+
for (const row of (repo.not_on_green ?? []).slice(0, SHOWN)) out.push(listLine(`not on green: ${row.reasons.join('; ')}`, row.pr, ' '));
|
|
265
|
+
out.push(...more(repo.not_on_green ?? []));
|
|
266
|
+
for (const row of (repo.reverts ?? []).slice(0, SHOWN)) out.push(listLine(`revert: ${row.title}`, row.pr, ' '));
|
|
267
|
+
}
|
|
268
|
+
const median = numbers.median_days_to_live;
|
|
269
|
+
out.push(` input to live: ${numbers.delivered.length} delivered ${numbers.delivered.length === 1 ? 'item' : 'items'}${median === null ? '' : `, median ${median} days`}`);
|
|
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
|
+
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}`);
|
|
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
|
+
return out.join('\n');
|
|
275
|
+
}
|
|
276
|
+
|
package/work/lib/step-ins.mjs
CHANGED
|
@@ -25,6 +25,9 @@ export const STEP_IN_READ_VERBS = Object.freeze(['step_ins']);
|
|
|
25
25
|
// an answer replies to an open question.
|
|
26
26
|
export const STEP_IN_CLASSES = Object.freeze([...OWNER_ONLY_CLASSES, 'brake', 'signed-act', 'answer']);
|
|
27
27
|
export const STEP_IN_KIND = 'step-in';
|
|
28
|
+
// Who sent a message, as the hook reads it (item f7391622). Only an owner
|
|
29
|
+
// message can be a step-in.
|
|
30
|
+
export const SENDERS = Object.freeze(['owner', 'platform']);
|
|
28
31
|
export const NAME_KIND = 'step-in-name';
|
|
29
32
|
const ROW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
30
33
|
const SESSION = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
|
|
@@ -48,7 +51,10 @@ export function parseRow(raw) {
|
|
|
48
51
|
if (!ROW_ID.test(String(row.id ?? '')) || !SESSION.test(String(row.session ?? ''))) return null;
|
|
49
52
|
if (typeof row.at !== 'string' || Number.isNaN(Date.parse(row.at))) return null;
|
|
50
53
|
if (!Array.isArray(row.repos) || row.repos.length > 16 || !row.repos.every((repo) => typeof repo === 'string' && REPO.test(repo))) return null;
|
|
51
|
-
|
|
54
|
+
if (row.from !== undefined && !SENDERS.includes(row.from)) return null;
|
|
55
|
+
// A row from the hook of 0.4.8 has no sender. It reads as the owner's, as
|
|
56
|
+
// it did then.
|
|
57
|
+
return { id: row.id, at: new Date(row.at).toISOString(), session: row.session, repos: [...row.repos], from: row.from ?? 'owner' };
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
// Turn each row in the folder into a mark. Each row is claimed by a rename
|
|
@@ -101,9 +107,11 @@ export function createStepInVerbs(core) {
|
|
|
101
107
|
if (prior) return prior;
|
|
102
108
|
const product = await productOf(row.repos);
|
|
103
109
|
return create(type, requestId, 'step_in_record', {
|
|
104
|
-
title: oneLine(`Step-in · ${row.repos.join(', ') || 'no repo'} · ${row.at}`, 200),
|
|
105
|
-
body:
|
|
106
|
-
|
|
110
|
+
title: oneLine(`Step-in · ${row.repos.join(', ') || 'no repo'} · ${row.at}${row.from === 'platform' ? ' · platform' : ''}`, 200),
|
|
111
|
+
body: row.from === 'platform'
|
|
112
|
+
? 'One message that a platform sent to the session, such as a reminder or a relay. The hook keeps the time, the session and the repos; it never keeps the text. It is not a step-in.'
|
|
113
|
+
: 'One message from the person. The hook keeps the time, the session and the repos; it never keeps the text.',
|
|
114
|
+
fields: { kind: STEP_IN_KIND, verdict: 'counted', item: '', product, marked_at: row.at, session: row.session, repos: row.repos, row: row.id, source: 'hook', ...(row.from === 'platform' ? { from: 'platform' } : {}) },
|
|
107
115
|
tags: ['atlas-mark', 'step-in', ...(product ? [`product:${product}`] : [])],
|
|
108
116
|
readBack: false,
|
|
109
117
|
});
|
|
@@ -128,14 +136,7 @@ export function createStepInVerbs(core) {
|
|
|
128
136
|
// Every step-in mark with the newest name that corrects it.
|
|
129
137
|
async function standing() {
|
|
130
138
|
const type = await typeNamed('mark');
|
|
131
|
-
|
|
132
|
-
const names = new Map();
|
|
133
|
-
for (const row of rows.filter((one) => one.fields?.kind === NAME_KIND)) {
|
|
134
|
-
const of = row.fields.corrects;
|
|
135
|
-
const held = names.get(of);
|
|
136
|
-
if (!held || String(row.fields.marked_at ?? '') > String(held.fields.marked_at ?? '')) names.set(of, row);
|
|
137
|
-
}
|
|
138
|
-
return rows.filter((one) => one.fields?.kind === STEP_IN_KIND).map((mark) => ({ mark, name: names.get(mark.id) ?? null }));
|
|
139
|
+
return standingOf(await client.listAllEntities({ type: type.name, state: 'active' }));
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
const verbs = {
|
|
@@ -189,11 +190,12 @@ export function createStepInVerbs(core) {
|
|
|
189
190
|
.filter(({ mark }) => (!session || mark.fields.session === session) && (!since || String(mark.fields.marked_at) >= new Date(since).toISOString()))
|
|
190
191
|
.sort((a, b) => String(b.mark.fields.marked_at).localeCompare(String(a.mark.fields.marked_at)));
|
|
191
192
|
const shown = rows.slice(0, limit).map(({ mark, name }) => ({
|
|
192
|
-
step_in: mark.id, at: mark.fields.marked_at, session: mark.fields.session, repos: mark.fields.repos ?? [], verdict: mark.fields.verdict,
|
|
193
|
+
step_in: mark.id, at: mark.fields.marked_at, session: mark.fields.session, repos: mark.fields.repos ?? [], verdict: mark.fields.verdict, from: senderOfMark(mark),
|
|
193
194
|
class: name?.fields?.class ?? null, lesson: name?.fields?.lesson ?? null, item: name?.fields?.item || null, named_by: name?.id ?? null,
|
|
194
195
|
}));
|
|
195
|
-
const counted = rows.filter(
|
|
196
|
-
|
|
196
|
+
const counted = rows.filter(isStepIn).length;
|
|
197
|
+
const platform = rows.filter(({ mark }) => mark.fields.verdict === 'counted' && senderOfMark(mark) === 'platform').length;
|
|
198
|
+
return { verb: 'step_ins', total: rows.length, step_ins: counted, platform_messages: platform, not_counted_sessions: rows.filter(({ mark }) => mark.fields.verdict === 'not counted').length, rows: shown };
|
|
197
199
|
},
|
|
198
200
|
};
|
|
199
201
|
|
|
@@ -208,6 +210,24 @@ export function createStepInVerbs(core) {
|
|
|
208
210
|
return { verbs, record, notCounted };
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
// Each step-in mark of a list of marks, with the newest name that corrects
|
|
214
|
+
// it. The numbers part of the roundup reads the same pairs.
|
|
215
|
+
export function standingOf(rows) {
|
|
216
|
+
const names = new Map();
|
|
217
|
+
for (const row of rows.filter((one) => one.fields?.kind === NAME_KIND)) {
|
|
218
|
+
const of = row.fields.corrects;
|
|
219
|
+
const held = names.get(of);
|
|
220
|
+
if (!held || String(row.fields.marked_at ?? '') > String(held.fields.marked_at ?? '')) names.set(of, row);
|
|
221
|
+
}
|
|
222
|
+
return rows.filter((one) => one.fields?.kind === STEP_IN_KIND).map((mark) => ({ mark, name: names.get(mark.id) ?? null }));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A mark from before item f7391622 has no sender: it reads as the owner's.
|
|
226
|
+
export const senderOfMark = (mark) => (mark.fields?.from === 'platform' ? 'platform' : 'owner');
|
|
227
|
+
// An owner message counts; a step-in is an owner message with no class.
|
|
228
|
+
export const isOwnerMessage = ({ mark }) => mark.fields?.verdict === 'counted' && senderOfMark(mark) === 'owner';
|
|
229
|
+
export const isStepIn = (pair) => isOwnerMessage(pair) && !pair.name?.fields?.class;
|
|
230
|
+
|
|
211
231
|
// A lesson is a link: a pull request, a learnings entry, a page. Or the id
|
|
212
232
|
// of a tracker record, such as the item the lesson became.
|
|
213
233
|
function lessonOf(value) {
|
package/work/mcp.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// never reaches the server. In a Claude cloud session only, the server reads
|
|
14
14
|
// the cloud environment's own ATLAS_ENGRAM variable first, which .mcp.json
|
|
15
15
|
// passes on as ATLAS_ENGRAM_CLOUD. It reads them from nowhere else.
|
|
16
|
-
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
17
17
|
import { randomUUID } from 'node:crypto';
|
|
18
18
|
import { resolve, isAbsolute, dirname, join, delimiter } from 'node:path';
|
|
19
19
|
import { existsSync, realpathSync, readFileSync, writeFileSync, mkdirSync, statSync, openSync, fstatSync, closeSync, constants as fsConstants } from 'node:fs';
|
|
@@ -69,7 +69,7 @@ export const TOOLS = [
|
|
|
69
69
|
{ name: 'item_reopen', description: `Reopen a delivered item: the lifecycle goes back to active and a reopen mark is appended, citing the failed check by URL. The delivery mark is never erased; the reopen points at it with the corrects relation. The owner, or the lead in this session, calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'failed_check'], properties: { request_id: REQUEST_ID, item_id: S('Item id carrying at least one delivery mark.'), reason: S('Why it is reopened, in plain words.'), failed_check: S('Absolute URL of the check that failed: a CI run, a monitor, an issue.'), corrects: S('Mark id of the delivery this reopen corrects. Default: the item\'s latest delivery mark.') }, additionalProperties: false } },
|
|
70
70
|
{ name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
|
|
71
71
|
{ name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, no owner flag needed. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
|
|
72
|
-
{ name: 'roundup', description: 'Answer "where does this product stand, and where is it going?" in five parts: 1 needs you (the needs_me read); 2 where we stand (items tagged stage-<n> in ladder order, a count of every item by lifecycle, and the active items); 3 what moved in the window (delivery marks, merged pull requests, new items); 4 stuck or drifting (active items with no change for 14 days, merged but not marked, open pull requests that name no item, questions waiting 14 days); 5 the trend per week for four weeks. Every row cites an item, a question or a pull request. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), days: { type: 'integer', minimum: 1, maximum: 90, description: 'The window for part 3, in days. Default 7.' } }, additionalProperties: false } },
|
|
72
|
+
{ name: 'roundup', description: 'Answer "where does this product stand, and where is it going?" in five parts: 1 needs you (the needs_me read); 2 where we stand (items tagged stage-<n> in ladder order, a count of every item by lifecycle, and the active items); 3 what moved in the window (delivery marks, merged pull requests, new items); 4 stuck or drifting (active items with no change for 14 days, merged but not marked, open pull requests that name no item, questions waiting 14 days); 5 the trend per week for four weeks; 6 the numbers per repo, per week and per month (merges, merges not on green, reverts, reopens, unchecked test changes, step-ins, owner and platform messages) and the time from input to live of each delivered item, with the median. Part 6 reads every pull request of its period from GitHub; a count GitHub did not give reads refused, never 0. Every row cites an item, a question or a pull request. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), days: { type: 'integer', minimum: 1, maximum: 90, description: 'The window for part 3, in days. Default 7.' }, from: S('Part 6: the first UTC day, YYYY-MM-DD. Default: 28 days before the read.'), to: S('Part 6: the last UTC day, YYYY-MM-DD, counted. Default: the read. At most 92 days in all.') }, additionalProperties: false } },
|
|
73
73
|
{ name: 'code_read_brief', description: 'The brief for the sub-agents that read the code for "where are we with X", one per registered repo of the product. It carries the never-read and never-quote rules, the citation contract and the JSON shape of the digest. Give one brief to one sub-agent; sub-agents never receive this server or the Engram MCP. Pass each digest back in where_are_we.code. When: before answering "where are we with X". Scope: read, no owner flag needed; it opens no repo and writes nothing.', inputSchema: { type: 'object', required: ['product', 'capability'], properties: { product: S('Product registry id.'), capability: S('The capability to read for, the X of the question.'), repos: { type: 'array', description: 'Limit the briefs to these registered repo ids. Default: every repo of the product.', items: { type: 'string' } }, roots: { type: 'object', description: 'Repo id to the local checkout path the sub-agent reads.', additionalProperties: { type: 'string' } }, commits: { type: 'object', description: 'Repo id to the commit the read runs against.', additionalProperties: { type: 'string' } }, focus: { type: 'array', description: 'Extra things the read should look for.', items: { type: 'string' } } }, additionalProperties: false } },
|
|
74
74
|
{ name: 'where_are_we', description: 'Answer "where are we with X" in six parts: live support and known limits from the code read at question time, then in progress, delivered and proposed from the items, then the current priority decision. Every row carries a citation: a path and a line, an entity id, a pull request, or a decision date. The code half comes from the sub-agent digests, which are refused when they cite a never-read path or quote a never-quote path, and every string is scrubbed by pattern. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product', 'capability', 'code'], properties: { product: S('Product registry id.'), capability: S('The capability the question is about, the X.'), code: { type: 'array', description: 'One digest per repo, from the sub-agents briefed by code_read_brief. At least one is required: an answer with no code read says nothing about what the code does today.', items: CODE_DIGEST }, items: { type: 'array', description: 'Item ids the lead judges to answer this question.', items: { type: 'string' } }, match: { type: 'array', description: 'Terms to match against item titles, summaries and tags. Give items, match, or both.', items: { type: 'string' } }, movement_repos: { type: 'array', description: "Narrow the movement read to these registered repo ids. Default: every repo of the product. The digests' own repos are always read.", items: { type: 'string' } }, since: S('ISO date. Adds what changed since then.') }, additionalProperties: false } },
|
|
75
75
|
{ name: 'built_pending_possible', description: 'Answer "what are we building, what is built, what is pending and what can be done": delivered items by date, active items, paused items with their reason, proposed items with their age, and the decisions that set the order. Item records, delivery marks and decisions only; no code read and no GitHub read. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Limits built and the decisions to what came after it.') }, additionalProperties: false } },
|
|
@@ -183,8 +183,12 @@ export function journalDirectory(env) {
|
|
|
183
183
|
return directory;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
function
|
|
187
|
-
return
|
|
186
|
+
function ghAsync(args) {
|
|
187
|
+
return new Promise((done, fail) => {
|
|
188
|
+
execFile('gh', args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 30_000, env: childEnvironment() }, (error, stdout, stderr) => {
|
|
189
|
+
if (error) { error.message = `${error.message}${stderr ? `: ${String(stderr).trim().split('\n')[0]}` : ''}`; fail(error); } else done(stdout);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
// Every GitHub read the verbs may make, GET only and one shape per line. The
|
|
@@ -206,14 +210,19 @@ export const GITHUB_READS = Object.freeze([
|
|
|
206
210
|
new RegExp(`^repos/${REPO}/contents/atlas\\.yaml\\?ref=(?:${SHA}|${BRANCH})$`),
|
|
207
211
|
new RegExp(`^repos/${REPO}/releases\\?per_page=\\d{1,3}$`),
|
|
208
212
|
new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${TAG}$`),
|
|
213
|
+
// Roadmap R16: the numbers page past 100 pull requests, and read the
|
|
214
|
+
// files of each merge to find a changed test.
|
|
215
|
+
new RegExp(`^repos/${REPO}/pulls\\?state=all&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
216
|
+
new RegExp(`^repos/${REPO}/pulls/\\d{1,9}/files\\?per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
209
217
|
// Roadmap R14 line 6: the newest run of the daily audit, for the roundup.
|
|
210
218
|
new RegExp(`^repos/${REPO}/actions/workflows/atlas-jobs\\.yml/runs\\?per_page=\\d{1,3}$`),
|
|
211
219
|
]);
|
|
212
220
|
export function githubReader() {
|
|
213
221
|
return async (path) => {
|
|
214
222
|
if (!GITHUB_READS.some((shape) => shape.test(path))) throw new Error(`this read is not on the allowlist: ${path}`);
|
|
215
|
-
// --method GET is explicit: the reader can never turn into a write.
|
|
216
|
-
|
|
223
|
+
// --method GET is explicit: the reader can never turn into a write. It
|
|
224
|
+
// does not block, so the numbers (R16) can read several at a time.
|
|
225
|
+
return JSON.parse(await ghAsync(['api', '--method', 'GET', path]));
|
|
217
226
|
};
|
|
218
227
|
}
|
|
219
228
|
|