@mcptoolshop/housekeeping 0.0.0 → 1.3.1

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.
@@ -0,0 +1,1184 @@
1
+ #!/usr/bin/env node
2
+ // Collect a full operational snapshot of a GitHub org into data/snapshots/<ts>.json
3
+ // Raw JSON is the source of truth (diffable, replayable); the SQLite DB is derived.
4
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, appendFileSync } from 'node:fs';
5
+ import { join, basename } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { parse as parseYaml } from 'yaml';
8
+ import { graphql, rest, restPaged, restStatus, restWithStatus, pMap, whoami, looksTransient } from './gh.mjs';
9
+ import { resolveOrg } from './config.mjs';
10
+ import { exitCodeFor, formatError } from './errors.mjs';
11
+ import { info, isDebug } from './log.mjs';
12
+ import { readLockfile, advisoriesFromBulk, countBySeverity, lockfilePathsFromTree } from './lockfile.mjs';
13
+ import { priceRun, ratesFromUsage, withFallback, runnerClass } from './cost.mjs';
14
+ import { pagesDeployJobs, jobEnvironment } from './workflow-risks.mjs';
15
+ import { DATA_DIR } from './paths.mjs';
16
+
17
+ export const COLLECTOR_VERSION = '1.4.0';
18
+
19
+ const REPO_FIELDS = `
20
+ id name description url homepageUrl
21
+ isPrivate isArchived isFork isTemplate isEmpty
22
+ createdAt updatedAt pushedAt diskUsage
23
+ stargazerCount forkCount
24
+ watchers { totalCount }
25
+ hasIssuesEnabled hasWikiEnabled hasDiscussionsEnabled
26
+ primaryLanguage { name }
27
+ licenseInfo { spdxId }
28
+ repositoryTopics(first:25) { nodes { topic { name } } }
29
+ languages(first:10, orderBy:{field:SIZE, direction:DESC}) { totalSize edges { size node { name } } }
30
+ openIssues: issues(states:OPEN) { totalCount }
31
+ closedIssues: issues(states:CLOSED) { totalCount }
32
+ openPRsCount: pullRequests(states:OPEN) { totalCount }
33
+ mergedPRsCount: pullRequests(states:MERGED) { totalCount }
34
+ closedPRsCount: pullRequests(states:CLOSED) { totalCount }
35
+ issueList: issues(states:OPEN, first:30, orderBy:{field:CREATED_AT, direction:ASC}) {
36
+ nodes { number title createdAt updatedAt url
37
+ comments { totalCount } author { login }
38
+ labels(first:10) { nodes { name } } }
39
+ }
40
+ prList: pullRequests(states:OPEN, first:50, orderBy:{field:CREATED_AT, direction:ASC}) {
41
+ nodes { number title createdAt updatedAt url isDraft mergeable reviewDecision
42
+ additions deletions changedFiles headRefName baseRefName
43
+ author { login } labels(first:10) { nodes { name } }
44
+ # The head commit's rollup. Required contexts absent HERE but
45
+ # present on the default branch are the paths-gated case: the job
46
+ # exists and runs, just not for this PR's file set.
47
+ commits(last:1) { nodes { commit { statusCheckRollup {
48
+ state
49
+ contexts(first:100) { totalCount nodes {
50
+ __typename
51
+ ... on CheckRun { name conclusion }
52
+ ... on StatusContext { context state }
53
+ } }
54
+ } } } } }
55
+ }
56
+ releases(first:10, orderBy:{field:CREATED_AT, direction:DESC}) {
57
+ totalCount nodes { tagName name publishedAt isLatest isDraft isPrerelease }
58
+ }
59
+ tags: refs(refPrefix:"refs/tags/", first:30, orderBy:{field:TAG_COMMIT_DATE, direction:DESC}) {
60
+ totalCount
61
+ nodes { name target { __typename ... on Commit { committedDate } ... on Tag { tagger { date } } } }
62
+ }
63
+ # requiredStatusCheckContexts is the list branch protection GATES on; the
64
+ # rollup beneath it is what CI actually REPORTED on that same commit. A
65
+ # context in the first list and in neither rollup is a gate nothing can
66
+ # satisfy: every per-run view still reads green, and every PR is BLOCKED
67
+ # forever. Both sides come from this one query, so the pairing is free.
68
+ defaultBranchRef {
69
+ name
70
+ branchProtectionRule {
71
+ requiresStatusChecks requiresStrictStatusChecks requiredStatusCheckContexts
72
+ }
73
+ target { ... on Commit { oid committedDate statusCheckRollup {
74
+ state
75
+ contexts(first:100) { totalCount nodes {
76
+ __typename
77
+ ... on CheckRun { name conclusion }
78
+ ... on StatusContext { context state }
79
+ } }
80
+ } } }
81
+ }
82
+ rootTree: object(expression:"HEAD:") { ... on Tree { entries { name type } } }
83
+ wfTree: object(expression:"HEAD:.github/workflows") { ... on Tree { entries { name type } } }
84
+ ghTree: object(expression:"HEAD:.github") { ... on Tree { entries { name type } } }
85
+ pkg: object(expression:"HEAD:package.json") { ... on Blob { text byteSize isTruncated } }
86
+ # Read, don't just count: pnpm 11 writes pnpm-workspace.yaml into
87
+ # single-package repos to hold settings, so only a packages: key proves a
88
+ # workspace. Sibling fields in the same query -- no extra round trip. Both
89
+ # spellings are fetched because both are in WORKSPACE_MARKERS.
90
+ # (No backticks in here: this fragment is a JS template literal.)
91
+ pnpmWs: object(expression:"HEAD:pnpm-workspace.yaml") { ... on Blob { text } }
92
+ pnpmWsYml: object(expression:"HEAD:pnpm-workspace.yml") { ... on Blob { text } }
93
+ # The handbook site's sidebar shape and the Starlight version it is declared
94
+ # against. Both are needed because neither is a defect alone: Starlight 0.39
95
+ # removed the top-level autogenerate key on a labelled group and added the
96
+ # nested items form, so each shape is correct for exactly one side of that
97
+ # boundary and wrong for the other. Sibling fields in the same query.
98
+ # (No backticks in here: this fragment is a JS template literal.)
99
+ siteAstroCfg: object(expression:"HEAD:site/astro.config.mjs") { ... on Blob { text byteSize isTruncated } }
100
+ sitePkg: object(expression:"HEAD:site/package.json") { ... on Blob { text } }
101
+ # The committed Atlas map, by id only: maps run to megabytes, so its content
102
+ # is fetched in the atlas pass and only when this id is not already cached.
103
+ # null here means no such file on the default branch.
104
+ atlasMap: object(expression:"HEAD:atlas/structure.json") { ... on Blob { oid byteSize } }
105
+ `;
106
+
107
+ const PAGE_QUERY = `
108
+ query($owner:String!, $n:Int!, $cursor:String) {
109
+ rateLimit { remaining cost resetAt }
110
+ organization(login:$owner) {
111
+ repositories(first:$n, after:$cursor, orderBy:{field:NAME, direction:ASC}, ownerAffiliations:OWNER) {
112
+ pageInfo { hasNextPage endCursor }
113
+ totalCount
114
+ nodes { ${REPO_FIELDS} }
115
+ }
116
+ }
117
+ }`;
118
+
119
+ const log = (...a) => info('[collect]', ...a);
120
+
121
+ export function safeJson(t) {
122
+ try { return t ? JSON.parse(t) : null; } catch { return null; }
123
+ }
124
+
125
+ // Fields that differ on every sweep no matter what the org did. They are
126
+ // excluded from the content fingerprint ONLY -- the snapshot itself always
127
+ // carries them. `collector_version` and `gh_login` are deliberately NOT here:
128
+ // a different collector, or a different token's visibility, produces a
129
+ // genuinely different observation even when the bytes look similar.
130
+ const VOLATILE_FIELDS = new Set(['taken_at', 'duration_ms', 'rate_limit_remaining']);
131
+
132
+ /**
133
+ * Serialise with object keys sorted recursively, arrays left in order.
134
+ *
135
+ * Needed because key order in the snapshot is not stable across sweeps. The
136
+ * lockfile pass builds its result as an object keyed by repo and fills it from
137
+ * `pMap` at concurrency 6, so keys land in COMPLETION order -- two sweeps that
138
+ * observed exactly the same org can serialise the same data in a different
139
+ * order. Hashing raw `JSON.stringify` output would then report every sweep as
140
+ * changed and the dedupe guard would never fire. Array order is left alone: it
141
+ * is meaningful everywhere it appears here, and reordering would hide real
142
+ * drift.
143
+ *
144
+ * `undefined` is handled the way JSON.stringify does -- dropped from objects,
145
+ * rendered as null in arrays -- so the fingerprint matches what gets written.
146
+ */
147
+ export function canonicalJson(value) {
148
+ if (value === undefined) return undefined;
149
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
150
+ if (Array.isArray(value)) {
151
+ return '[' + value.map(v => canonicalJson(v) ?? 'null').join(',') + ']';
152
+ }
153
+ const parts = [];
154
+ for (const k of Object.keys(value).sort()) {
155
+ const v = canonicalJson(value[k]);
156
+ if (v !== undefined) parts.push(JSON.stringify(k) + ':' + v);
157
+ }
158
+ return '{' + parts.join(',') + '}';
159
+ }
160
+
161
+ /** Content identity of a snapshot: everything the sweep observed, nothing about when. */
162
+ export function snapshotFingerprint(snapshot) {
163
+ const stable = {};
164
+ for (const [k, v] of Object.entries(snapshot ?? {})) {
165
+ if (!VOLATILE_FIELDS.has(k)) stable[k] = v;
166
+ }
167
+ return createHash('sha256').update(canonicalJson(stable) ?? 'null').digest('hex');
168
+ }
169
+
170
+ /** Newest snapshot already on disk, or null. Filenames are ISO-derived, so they sort. */
171
+ function latestSnapshotOnDisk(dir) {
172
+ try {
173
+ const files = readdirSync(dir).filter(f => f.endsWith('.json')).sort();
174
+ return files.length ? join(dir, files.at(-1)) : null;
175
+ } catch { return null; }
176
+ }
177
+
178
+ // Every sweep appends one line here whether or not it produced a snapshot. This
179
+ // is what keeps the append-only rule honest while skipping a duplicate write:
180
+ // the 9 MB payload is dropped, the fact that we looked is not.
181
+ const SWEEP_LOG = join(DATA_DIR, 'sweeps.jsonl');
182
+
183
+ function recordSweep(entry) {
184
+ try { appendFileSync(SWEEP_LOG, JSON.stringify(entry) + '\n'); }
185
+ catch (e) { log(`WARNING could not append to sweeps.jsonl: ${e.message}`); }
186
+ }
187
+
188
+ // A failing gh call's message opens with the command line ("Command failed:
189
+ // ...gh.exe api graphql --input <temp file>") and says why only on its last
190
+ // line ("gh: HTTP 502"). Keep the line that says why.
191
+ export function failureReason(e) {
192
+ const lines = String(e?.message ?? e ?? '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
193
+ const why = /^Command failed:/.test(lines[0] ?? '') && lines.length > 1 ? lines.at(-1) : lines[0];
194
+ return (why ?? 'unknown error').slice(0, 300);
195
+ }
196
+
197
+ /**
198
+ * Run a sweep so that dying is recorded too. Three sweeps on 2026-09-30 threw
199
+ * before a snapshot existed and left no line at all, which broke "every sweep
200
+ * appends one line" exactly when the log mattered: a sweep that failed is the
201
+ * one you most want to know was attempted. The error is rethrown untouched --
202
+ * recording a failure must never turn it into a success.
203
+ */
204
+ export async function recordingFailure(ctx, fn, record = recordSweep) {
205
+ try {
206
+ return await fn();
207
+ } catch (e) {
208
+ record({
209
+ at: ctx.takenAt, result: 'failed', stage: ctx.stage, error: failureReason(e),
210
+ duration_ms: Date.now() - ctx.started, collector_version: COLLECTOR_VERSION,
211
+ ...(ctx.login ? { gh_login: ctx.login } : {}),
212
+ });
213
+ throw e;
214
+ }
215
+ }
216
+
217
+ /** Pass 1 - every repo's metadata, open issues, open PRs, releases, trees, package.json. */
218
+ async function collectRepos(org, pageSize = 8) {
219
+ const repos = [];
220
+ let cursor = null, page = 0, total = null, rl = null;
221
+ // for(;;), not do/while: `continue` in a do/while re-tests `cursor`, which is
222
+ // null on page 1, so a retry of the first page would end the sweep empty.
223
+ for (;;) {
224
+ // A page that keeps timing out server-side is too heavy, not unlucky: the
225
+ // transport has already retried it. Halve and re-ask from the same cursor,
226
+ // so no repo is skipped. Only the first page's size is fixed by the caller.
227
+ let body;
228
+ try {
229
+ body = await graphql(PAGE_QUERY, { owner: org, n: pageSize, cursor });
230
+ } catch (e) {
231
+ if (!looksTransient(e.message) || pageSize === 1) throw e;
232
+ pageSize = Math.max(1, pageSize >> 1);
233
+ log(` page timed out; retrying at ${pageSize} repos/page`);
234
+ continue;
235
+ }
236
+ const conn = body.data?.organization?.repositories;
237
+ if (!conn) throw new Error(`no repositories visible for org ${org}`);
238
+ rl = body.data.rateLimit;
239
+ if (total === null) total = conn.totalCount;
240
+ repos.push(...conn.nodes.filter(Boolean));
241
+ cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
242
+ log(`page ${++page}: ${repos.length}/${total} repos (rateLimit ${rl.remaining} left)`);
243
+ if (body._softErrors) log(' soft errors:', body._softErrors);
244
+ if (!cursor) break;
245
+ }
246
+ return { repos, total, rateLimit: rl };
247
+ }
248
+
249
+ /** Pass 2 - raw YAML for every workflow file, batched via aliased GraphQL (no per-file REST). */
250
+ async function collectWorkflowFiles(org, repos, batchSize = 6) {
251
+ const targets = repos
252
+ .map(r => ({
253
+ name: r.name,
254
+ files: (r.wfTree?.entries ?? [])
255
+ .filter(e => e.type === 'blob' && /\.ya?ml$/i.test(e.name))
256
+ .map(e => e.name),
257
+ }))
258
+ .filter(t => t.files.length);
259
+
260
+ const out = new Map();
261
+ for (let i = 0; i < targets.length; i += batchSize) {
262
+ const batch = targets.slice(i, i + batchSize);
263
+ const parts = batch.map((t, ri) => {
264
+ const files = t.files.slice(0, 12).map((f, fi) =>
265
+ `f${fi}: object(expression:"HEAD:.github/workflows/${f}") { ... on Blob { text byteSize } }`
266
+ ).join('\n ');
267
+ return `r${ri}: repository(owner:$owner, name:${JSON.stringify(t.name)}) {\n ${files}\n }`;
268
+ });
269
+ const body = await graphql(`query($owner:String!){\n ${parts.join('\n ')}\n}`, { owner: org });
270
+ batch.forEach((t, ri) => {
271
+ const node = body.data?.[`r${ri}`];
272
+ if (!node) return;
273
+ const files = t.files.slice(0, 12).map((f, fi) => ({
274
+ path: `.github/workflows/${f}`,
275
+ name: f,
276
+ text: node[`f${fi}`]?.text ?? null,
277
+ byteSize: node[`f${fi}`]?.byteSize ?? 0,
278
+ })).filter(f => f.text);
279
+ out.set(t.name, files);
280
+ });
281
+ log(`workflow files: ${Math.min(i + batchSize, targets.length)}/${targets.length} repos`);
282
+ }
283
+ return out;
284
+ }
285
+
286
+ const shape = (repo, w) => ({
287
+ repo,
288
+ workflow_name: w.name,
289
+ path: w.path,
290
+ run_id: w.id,
291
+ run_number: w.run_number,
292
+ event: w.event,
293
+ status: w.status,
294
+ conclusion: w.conclusion,
295
+ branch: w.head_branch,
296
+ created_at: w.created_at,
297
+ updated_at: w.updated_at,
298
+ url: w.html_url,
299
+ });
300
+
301
+ /**
302
+ * The 30-run window is the N most recent runs across ALL of a repo's workflows,
303
+ * so a workflow that broke and was then abandoned falls out of it entirely and
304
+ * its redness becomes invisible. A repo was reported CI-clean while two of its
305
+ * workflows had been failing on main for seven months -- caught by
306
+ * test/verify-findings.mjs.
307
+ *
308
+ * Fill only the holes: one targeted call per workflow the window did not observe
309
+ * on the default branch. A repo with chatty CI costs nothing extra, and the
310
+ * default-branch claim stops depending on how busy its neighbours were.
311
+ */
312
+ async function backfillDefaultBranchRuns(org, repo, branch, wfFiles, seenPaths, cap = 12) {
313
+ const gaps = wfFiles.filter(f => !seenPaths.has(f)).slice(0, cap);
314
+ const found = [];
315
+ for (const file of gaps) {
316
+ try {
317
+ const data = await rest(
318
+ `repos/${org}/${repo}/actions/workflows/${encodeURIComponent(file)}/runs`
319
+ + `?branch=${encodeURIComponent(branch)}&per_page=1`);
320
+ const w = (data.workflow_runs ?? [])[0];
321
+ if (w) found.push(shape(repo, w));
322
+ } catch { /* workflow never registered, or deleted upstream: nothing to claim */ }
323
+ }
324
+ return found;
325
+ }
326
+
327
+ /** Pass 3 - recent Actions runs. GraphQL has no Actions surface, so REST is the only path. */
328
+ async function collectRuns(org, repos, concurrency = 10) {
329
+ const live = repos.filter(r => !r.isEmpty && (r.wfTree?.entries?.length ?? 0) > 0);
330
+ const results = await pMap(live, async r => {
331
+ const data = await rest(`repos/${org}/${r.name}/actions/runs?per_page=30`);
332
+ const window = (data.workflow_runs ?? []).map(w => shape(r.name, w));
333
+ const branch = r.defaultBranchRef?.name;
334
+ if (!branch) return window;
335
+ const wfFiles = (r.wfTree?.entries ?? [])
336
+ .filter(e => e.type === 'blob' && /\.ya?ml$/i.test(e.name)).map(e => e.name);
337
+ const seen = new Set(window
338
+ .filter(x => x.branch === branch)
339
+ .map(x => (x.path ?? '').split('/').pop()));
340
+ const extra = await backfillDefaultBranchRuns(org, r.name, branch, wfFiles, seen);
341
+ return window.concat(extra);
342
+ }, concurrency);
343
+ log(`actions runs: ${results.filter(r => r.ok).length}/${live.length} repos ok`);
344
+ const runs = [], errors = [];
345
+ results.forEach((res, i) => {
346
+ if (res.ok) runs.push(...res.value);
347
+ else errors.push({ repo: live[i].name, stage: 'actions_runs', message: res.error });
348
+ });
349
+ return { runs, errors };
350
+ }
351
+
352
+ const RED = new Set(['failure', 'timed_out']);
353
+
354
+ /**
355
+ * The runs whose failure the CI rules report: per repo and workflow file, the
356
+ * newest run on the default branch, when it is red. Pure. Runs arrive newest
357
+ * first, so the first sighting of a path is its latest -- the loader's own
358
+ * `is_latest_on_default`. Dependabot's dynamic workflows have no file in the
359
+ * repository and so no step to name; they are left out.
360
+ */
361
+ export function failedStepTargets(runs, repos) {
362
+ const branchOf = new Map((repos ?? []).map(r => [r.name, r.defaultBranchRef?.name ?? null]));
363
+ const seen = new Set(), out = [];
364
+ for (const x of runs ?? []) {
365
+ const branch = branchOf.get(x.repo);
366
+ if (!branch || x.branch !== branch) continue;
367
+ const key = `${x.repo}\n${x.path}`;
368
+ if (seen.has(key)) continue;
369
+ seen.add(key);
370
+ if (RED.has(x.conclusion) && /^\.github\/workflows\//.test(x.path ?? '')) out.push(x);
371
+ }
372
+ return out;
373
+ }
374
+
375
+ /**
376
+ * The failed jobs and steps of one run, from the Actions API. Pure.
377
+ * A job that timed out has no failed step: the step it was in is `cancelled`.
378
+ * That one is kept, and only when the job has no failed step, so a cancelled
379
+ * step never stands in for the one that actually broke.
380
+ */
381
+ export function failedJobs(apiJobs) {
382
+ return (apiJobs ?? []).filter(j => RED.has(j.conclusion)).map(j => {
383
+ const steps = (j.steps ?? []).map(s => ({ number: s.number, name: s.name, conclusion: s.conclusion }));
384
+ const red = steps.filter(s => RED.has(s.conclusion));
385
+ return { name: j.name, conclusion: j.conclusion, steps: red.length ? red : steps.filter(s => s.conclusion === 'cancelled').slice(0, 1) };
386
+ });
387
+ }
388
+
389
+ /**
390
+ * Pass 3a - where each red default-branch run broke. One REST call per run in
391
+ * failedStepTargets (seven red runs on 2026-09-30, five with a workflow file),
392
+ * so nothing is cached. `filter=latest` asks for the last attempt only: the
393
+ * run's conclusion is the last attempt's, and so are the jobs that explain it.
394
+ * A failed call is recorded against the run and the loader stores nothing for
395
+ * it, which the rules read as "not measured".
396
+ */
397
+ export async function collectFailedSteps(org, runs, repos, { get = rest, concurrency = 6 } = {}) {
398
+ const targets = failedStepTargets(runs, repos);
399
+ const results = await pMap(targets, x =>
400
+ get(`repos/${org}/${x.repo}/actions/runs/${x.run_id}/jobs?filter=latest&per_page=100`), concurrency);
401
+ const out = results.map((res, i) => {
402
+ const x = targets[i];
403
+ const base = { repo: x.repo, run_id: x.run_id, path: x.path };
404
+ // `job_count` keeps "no job failed" apart from "no job ran": a run GitHub
405
+ // rejects before it starts (an invalid workflow file) has no jobs at all.
406
+ return res.ok
407
+ ? { ...base, job_count: (res.value?.jobs ?? []).length, jobs: failedJobs(res.value?.jobs) }
408
+ : { ...base, error: String(res.error).slice(0, 160) };
409
+ });
410
+ log(`failed steps: ${out.filter(r => !r.error).length}/${targets.length} red runs read`);
411
+ return { ok: true, runs: out };
412
+ }
413
+
414
+ /**
415
+ * What a repo's workflows need from its settings, read from the workflow text
416
+ * the sweep already holds. Pure. Parse failures are skipped: a file GitHub
417
+ * cannot parse deploys nothing, and WF_PARSE_ERROR already reports it.
418
+ */
419
+ export function deployNeeds(files) {
420
+ let pages = false;
421
+ const envs = new Map(); // lower-cased -> as written
422
+ for (const f of files ?? []) {
423
+ let doc;
424
+ try { doc = parseYaml(f.text, { logLevel: 'silent' }); } catch { continue; }
425
+ if (!doc || typeof doc !== 'object') continue;
426
+ const jobs = doc.jobs && typeof doc.jobs === 'object' && !Array.isArray(doc.jobs)
427
+ ? Object.entries(doc.jobs) : [];
428
+ if (pagesDeployJobs(jobs).length) pages = true;
429
+ for (const [, job] of jobs) {
430
+ const name = jobEnvironment(job);
431
+ // GitHub treats environment names case-insensitively.
432
+ if (name && !envs.has(name.toLowerCase())) envs.set(name.toLowerCase(), name);
433
+ }
434
+ }
435
+ return { pages, environments: [...envs.values()] };
436
+ }
437
+
438
+ /**
439
+ * Pass 3b - the repository settings a deploy depends on.
440
+ *
441
+ * A red default branch can be caused by settings, not code: Pages switched off
442
+ * under a workflow that deploys to
443
+ * it, and an environment whose branch policy still named the old default
444
+ * branch. Neither is visible in a workflow file or a run list, so they are read
445
+ * here, and only where a workflow needs them -- a repo with no deploy-pages step
446
+ * is not asked about Pages, and a repo whose jobs name no environment is not
447
+ * asked about environments. That keeps the REST cost to the repos it concerns.
448
+ *
449
+ * Every answer is stored as the HTTP status plus the few fields a rule reads.
450
+ * The status is kept rather than a verdict because only two statuses mean
451
+ * anything to `repos/{r}/pages` (200 on, 404 off); a 403 or a gateway error must
452
+ * reach the loader as unknown, never as "off". The same holds for every call
453
+ * below, and a throttled call throws to pMap, which records the repo as errored.
454
+ *
455
+ * The default branch's protection is asked only for an environment restricted
456
+ * to "protected branches", because that is the only policy whose answer depends
457
+ * on it. GitHub also lets every branch deploy to such an environment when no
458
+ * branch in the repo is protected, so that is asked too, and a ruleset on the
459
+ * default branch is counted because this cannot tell whether GitHub treats a
460
+ * ruleset as protection for this purpose -- the rule reads a non-zero count as
461
+ * unknown.
462
+ */
463
+ export async function collectDeploySettings(org, repos, workflowFiles, { get = restWithStatus, concurrency = 8 } = {}) {
464
+ const targets = repos
465
+ .filter(r => !r.isArchived && !r.isEmpty)
466
+ .map(r => ({ r, need: deployNeeds(workflowFiles.get(r.name)) }))
467
+ .filter(t => t.need.pages || t.need.environments.length);
468
+
469
+ let calls = 0;
470
+ const ask = path => { calls++; return get(path); };
471
+ const out = {};
472
+
473
+ const results = await pMap(targets, async ({ r, need }) => {
474
+ const base = `repos/${org}/${r.name}`;
475
+ const rec = {};
476
+ if (need.pages) {
477
+ const { status, body } = await ask(`${base}/pages`);
478
+ rec.pages = {
479
+ status,
480
+ build_type: status === 200 ? (body?.build_type ?? null) : null,
481
+ source_branch: status === 200 ? (body?.source?.branch ?? null) : null,
482
+ };
483
+ }
484
+ if (need.environments.length) {
485
+ const named = new Set(need.environments.map(n => n.toLowerCase()));
486
+ const { status, body } = await ask(`${base}/environments?per_page=100`);
487
+ rec.environments = { status, named: need.environments, total_count: null, list: null };
488
+ let wantProtection = false;
489
+ if (status === 200 && Array.isArray(body?.environments)) {
490
+ rec.environments.total_count = body.total_count ?? null;
491
+ rec.environments.list = [];
492
+ for (const e of body.environments) {
493
+ const policy = e.deployment_branch_policy ?? null;
494
+ const row = {
495
+ name: e.name,
496
+ // null is GitHub's "all branches"; an object carries the two flags.
497
+ deployment_branch_policy: policy && {
498
+ protected_branches: policy.protected_branches === true,
499
+ custom_branch_policies: policy.custom_branch_policies === true,
500
+ },
501
+ };
502
+ if (named.has(String(e.name).toLowerCase()) && policy) {
503
+ if (policy.custom_branch_policies === true) {
504
+ const bp = await ask(`${base}/environments/${encodeURIComponent(e.name)}/deployment-branch-policies?per_page=100`);
505
+ row.branch_policies = {
506
+ status: bp.status,
507
+ total_count: bp.status === 200 ? (bp.body?.total_count ?? null) : null,
508
+ list: bp.status === 200 && Array.isArray(bp.body?.branch_policies)
509
+ ? bp.body.branch_policies.map(p => ({ name: p.name, type: p.type ?? null }))
510
+ : null,
511
+ };
512
+ }
513
+ if (policy.protected_branches === true) wantProtection = true;
514
+ }
515
+ rec.environments.list.push(row);
516
+ }
517
+ }
518
+ const branch = r.defaultBranchRef?.name;
519
+ if (wantProtection && branch) {
520
+ const b = await ask(`${base}/branches/${encodeURIComponent(branch)}`);
521
+ const db = { name: branch, status: b.status,
522
+ protected: b.status === 200 && typeof b.body?.protected === 'boolean' ? b.body.protected : null,
523
+ any_protected: null, ruleset_rules: null };
524
+ if (db.protected === false) {
525
+ const any = await ask(`${base}/branches?protected=true&per_page=1`);
526
+ db.any_protected = any.status === 200 && Array.isArray(any.body) ? any.body.length > 0 : null;
527
+ if (db.any_protected === true) {
528
+ const rs = await ask(`${base}/rules/branches/${encodeURIComponent(branch)}?per_page=100`);
529
+ db.ruleset_rules = rs.status === 200 && Array.isArray(rs.body) ? rs.body.length : null;
530
+ }
531
+ }
532
+ rec.default_branch = db;
533
+ }
534
+ }
535
+ return rec;
536
+ }, concurrency);
537
+
538
+ const errors = [];
539
+ results.forEach((res, i) => {
540
+ const name = targets[i].r.name;
541
+ if (res.ok) out[name] = res.value;
542
+ else {
543
+ // Recorded, and left out of `repos`: the loader then has no row for it,
544
+ // which the rules read as not measured.
545
+ errors.push({ repo: name, stage: 'deploy_settings', message: res.error });
546
+ }
547
+ });
548
+ const pagesOff = Object.values(out).filter(x => x.pages?.status === 404).length;
549
+ log(`deploy settings: ${Object.keys(out).length}/${targets.length} repos read in ${calls} REST calls ` +
550
+ `(${pagesOff} with Pages off, ${errors.length} errors)`);
551
+ return { ok: true, calls, repos: out, errors };
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // The committed Atlas map (rules/atlas-map.md). A map runs from tens of KB to
556
+ // over a megabyte, almost all of it lists no org-level question needs: the
557
+ // landings, reach and edges that let Atlas answer about one repository. What
558
+ // the org view reads is the map's identity (blob, engine, the commit it was
559
+ // made from) and each door's outline, so that is all the snapshot keeps.
560
+ //
561
+ // Bump when the trimmed shape changes: a cached record trimmed by an older
562
+ // version is then fetched again rather than served in the old shape.
563
+ // 2 (2026-09-30): commands keep `dir`, the key Atlas writes (1 read
564
+ // `directory`, which no map has); doors keep `unresolvedChecks`.
565
+ export const ATLAS_TRIM_VERSION = 2;
566
+ const ATLAS_CACHE = join(DATA_DIR, 'atlas-map-cache.json');
567
+ const ATLAS_PACKAGE = '@dogfood-lab/atlas';
568
+
569
+ const pick = (obj, keys) => {
570
+ const out = {};
571
+ for (const k of keys) if (obj?.[k] !== undefined) out[k] = obj[k];
572
+ return out;
573
+ };
574
+
575
+ /**
576
+ * One door without its long lists. Pure.
577
+ *
578
+ * Kept: what the door is (file, name, kind), what starts it (triggers), what
579
+ * each command runs (job, step, programs, and `dir` once maps record it),
580
+ * what it sends, and its counts. `jobs`, `findings` and `unresolvedChecks` are
581
+ * kept whole whenever a door carries them -- Atlas 1.24.0 adds all three: the
582
+ * runtime of each job, the D1/D2 door findings, and what a door check could
583
+ * not judge -- so the slices that read them need no collector change.
584
+ * Dropped: landings, reach, runs, readers, mentions and the other per-file
585
+ * lists; each is recoverable from the map at the commit the record names.
586
+ */
587
+ export function trimAtlasDoor(door) {
588
+ if (!door || typeof door !== 'object') return null;
589
+ const out = pick(door, ['file', 'name', 'kind', 'triggers', 'sends']);
590
+ out.commands = Array.isArray(door.commands)
591
+ ? door.commands.map(c => pick(c, ['job', 'step', 'programs', 'dir']))
592
+ : [];
593
+ const counts = {};
594
+ for (const [k, v] of Object.entries(door)) if (/Count$/.test(k) && typeof v === 'number') counts[k] = v;
595
+ out.counts = counts;
596
+ if (door.jobs !== undefined) out.jobs = door.jobs;
597
+ if (door.findings !== undefined) out.findings = door.findings;
598
+ if (door.unresolvedChecks !== undefined) out.unresolvedChecks = door.unresolvedChecks;
599
+ return out;
600
+ }
601
+
602
+ /** A whole map reduced to the record the snapshot keeps. Pure. */
603
+ export function trimAtlasMap(map) {
604
+ return {
605
+ // Maps made before Atlas 1.23.0 carry no engine field; null says so.
606
+ engine: typeof map?.engine === 'string' ? map.engine : null,
607
+ commit: map?.generatedFrom?.commit ?? null,
608
+ doors: Array.isArray(map?.doors) ? map.doors.map(trimAtlasDoor).filter(Boolean) : [],
609
+ };
610
+ }
611
+
612
+ function readAtlasCache() {
613
+ try { return existsSync(ATLAS_CACHE) ? JSON.parse(readFileSync(ATLAS_CACHE, 'utf8')) : {}; } catch { return {}; }
614
+ }
615
+
616
+ /**
617
+ * The engine version the fleet is measured against: the latest published
618
+ * @dogfood-lab/atlas. One registry request, not GitHub. A failure is stored as
619
+ * an error with no version, so the engine-behind check stays silent rather
620
+ * than measuring against nothing.
621
+ */
622
+ async function atlasFleetVersion(fetchImpl = fetch) {
623
+ try {
624
+ const res = await fetchImpl(`https://registry.npmjs.org/${ATLAS_PACKAGE.replace('/', '%2f')}`, {
625
+ headers: { accept: 'application/vnd.npm.install-v1+json' }, signal: AbortSignal.timeout(30_000),
626
+ });
627
+ if (!res.ok) return { version: null, source: 'npm dist-tags latest', error: `http_${res.status}` };
628
+ const j = await res.json();
629
+ return { version: j['dist-tags']?.latest ?? null, source: 'npm dist-tags latest' };
630
+ } catch (e) {
631
+ return { version: null, source: 'npm dist-tags latest', error: String(e.message ?? e).slice(0, 160) };
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Pass 6b - each repository's committed map, by blob id.
637
+ *
638
+ * The id rides the repo pass (`atlasMap` in REPO_FIELDS, no extra GraphQL
639
+ * cost). The blob is fetched only when its id is not in data/atlas-map-cache.json,
640
+ * the lockfile pass's pattern: a blob id names its content, so a cached record
641
+ * for that id is correct forever. The cache holds trimmed records only, and it
642
+ * is never the only holder -- the snapshot carries every record it used, which
643
+ * is what lets `npm run rebuild` work offline.
644
+ *
645
+ * Archived repositories are exempt from the map rule and are not fetched.
646
+ * A fetch or parse failure is recorded against the repo with its blob id, and
647
+ * the loader stores it as a map that exists but could not be read.
648
+ */
649
+ export async function collectAtlasMaps(org, repos, { get = rest, fetchImpl = fetch, cache = readAtlasCache(), writeCache = true } = {}) {
650
+ const nextCache = {};
651
+ const out = {};
652
+ let fetched = 0, cached = 0, errored = 0;
653
+ const targets = repos.filter(r => !r.isArchived && r.atlasMap?.oid);
654
+
655
+ await pMap(targets, async r => {
656
+ const oid = r.atlasMap.oid;
657
+ const size = r.atlasMap.byteSize ?? null;
658
+ const hit = cache[oid];
659
+ if (hit && hit.trim === ATLAS_TRIM_VERSION && hit.record) {
660
+ cached++;
661
+ nextCache[oid] = hit;
662
+ out[r.name] = { blob: oid, size, ...hit.record };
663
+ return;
664
+ }
665
+ try {
666
+ const blob = await get(`repos/${org}/${r.name}/git/blobs/${oid}`);
667
+ const text = Buffer.from(String(blob?.content ?? ''), blob?.encoding === 'base64' ? 'base64' : 'utf8').toString('utf8');
668
+ const record = trimAtlasMap(JSON.parse(text));
669
+ fetched++;
670
+ nextCache[oid] = { trim: ATLAS_TRIM_VERSION, record };
671
+ out[r.name] = { blob: oid, size, ...record };
672
+ } catch (e) {
673
+ errored++;
674
+ out[r.name] = { blob: oid, size, error: String(e.message ?? e).slice(0, 160) };
675
+ }
676
+ }, 6);
677
+
678
+ if (writeCache) {
679
+ try { writeFileSync(ATLAS_CACHE, JSON.stringify(nextCache)); } catch { /* cache is a convenience */ }
680
+ }
681
+ const fleet = await atlasFleetVersion(fetchImpl);
682
+ log(`atlas maps: ${targets.length} committed (${fetched} fetched, ${cached} from cache, ${errored} errors); ` +
683
+ `fleet engine ${fleet.version ?? `unknown (${fleet.error})`}`);
684
+ return { ok: true, fleet_version: fleet, fetched, cached, repos: out };
685
+ }
686
+
687
+ /** Pass 4 - npm dist-tags, to catch repo-version vs published-version drift. */
688
+ async function collectNpm(repos, concurrency = 12) {
689
+ const named = repos
690
+ .map(r => ({ repo: r.name, pkg: safeJson(r.pkg?.text) }))
691
+ .filter(x => x.pkg && x.pkg.name && !x.pkg.private);
692
+
693
+ const results = await pMap(named, async x => {
694
+ const res = await fetch(`https://registry.npmjs.org/${x.pkg.name.replace('/', '%2f')}`, {
695
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
696
+ });
697
+ if (res.status === 404) return { repo: x.repo, pkg_name: x.pkg.name, status: 'unpublished' };
698
+ if (!res.ok) return { repo: x.repo, pkg_name: x.pkg.name, status: `http_${res.status}` };
699
+ const j = await res.json();
700
+ const latest = j['dist-tags']?.latest ?? null;
701
+ return {
702
+ repo: x.repo,
703
+ pkg_name: x.pkg.name,
704
+ status: 'published',
705
+ latest,
706
+ published_at: j.time?.[latest] ?? j.modified ?? null,
707
+ };
708
+ }, concurrency);
709
+
710
+ log(`npm: ${results.filter(r => r.ok).length}/${named.length} packages checked`);
711
+ return results.map((r, i) => r.ok
712
+ ? r.value
713
+ : { repo: named[i].repo, pkg_name: named[i].pkg.name, status: 'error', error: r.error });
714
+ }
715
+
716
+ /**
717
+ * Pass 1b - GitHub computes `mergeable` asynchronously: the first query only
718
+ * triggers the computation and returns UNKNOWN. Re-query those PRs so conflict
719
+ * counts are real. Skipping this undercounted conflicts sevenfold on one sweep.
720
+ */
721
+ async function refreshMergeable(org, repos, batchSize = 8) {
722
+ const pending = [];
723
+ for (const r of repos) {
724
+ for (const p of r.prList?.nodes ?? []) {
725
+ if (p.mergeable === 'UNKNOWN') pending.push({ repo: r.name, number: p.number });
726
+ }
727
+ }
728
+ if (!pending.length) return 0;
729
+ log(`mergeable: ${pending.length} PRs returned UNKNOWN, re-querying`);
730
+ await new Promise(res => setTimeout(res, 3000)); // let GitHub finish computing
731
+
732
+ const byRepo = new Map();
733
+ for (const r of repos) {
734
+ byRepo.set(r.name, new Map((r.prList?.nodes ?? []).map(p => [p.number, p])));
735
+ }
736
+
737
+ let resolved = 0;
738
+ for (let i = 0; i < pending.length; i += batchSize) {
739
+ const batch = pending.slice(i, i + batchSize);
740
+ // GraphQL is whitespace-insensitive; joining with spaces keeps this readable.
741
+ const parts = batch.map((t, k) =>
742
+ `p${k}: repository(owner:$owner, name:${JSON.stringify(t.repo)}) { pullRequest(number:${t.number}) { number mergeable } }`);
743
+ let body;
744
+ try {
745
+ body = await graphql(`query($owner:String!){ ${parts.join(' ')} }`, { owner: org });
746
+ } catch (e) {
747
+ log(` mergeable batch failed: ${e.message?.slice(0, 120)}`);
748
+ continue;
749
+ }
750
+ batch.forEach((t, k) => {
751
+ const pr = body.data?.[`p${k}`]?.pullRequest;
752
+ if (!pr || pr.mergeable === 'UNKNOWN') return;
753
+ const node = byRepo.get(t.repo)?.get(pr.number);
754
+ if (node) { node.mergeable = pr.mergeable; resolved++; }
755
+ });
756
+ }
757
+ log(`mergeable: resolved ${resolved}/${pending.length}`);
758
+ return resolved;
759
+ }
760
+
761
+ // Open Dependabot alerts, plus whether the repo is allowed to fix them.
762
+ //
763
+ // Both are read-only GETs. The org endpoint returns every open alert across the
764
+ // org in one paginated call, so this costs one request plus one per repo -- far
765
+ // cheaper than asking each repo for its alerts.
766
+ //
767
+ // The pairing is the point. An alert count alone says "this repo is exposed";
768
+ // automated-security-fixes says whether anything is coming to fix it. A repo
769
+ // with alerts and the setting OFF will never receive a Dependabot PR, which is
770
+ // how alerts reach seven months old unnoticed.
771
+ async function collectSecurity(org, repos, concurrency = 10) {
772
+ let alerts = [];
773
+ let alertsOk = true;
774
+ try {
775
+ const raw = await restPaged(`/orgs/${org}/dependabot/alerts?state=open&per_page=100`);
776
+ alerts = raw.map(a => ({
777
+ repo: a.repository?.name ?? null,
778
+ number: a.number ?? null,
779
+ severity: a.security_advisory?.severity ?? null,
780
+ ecosystem: a.dependency?.package?.ecosystem ?? null,
781
+ package: a.dependency?.package?.name ?? null,
782
+ manifest: a.dependency?.manifest_path ?? null,
783
+ ghsa: a.security_advisory?.ghsa_id ?? null,
784
+ summary: (a.security_advisory?.summary ?? '').slice(0, 200),
785
+ created_at: a.created_at ?? null,
786
+ // Null when the advisory has no patched release: those cannot be fixed by
787
+ // a version bump and should not be counted as actionable.
788
+ fixed_version: a.security_vulnerability?.first_patched_version?.identifier ?? null,
789
+ url: a.html_url ?? null,
790
+ }));
791
+ } catch (e) {
792
+ alertsOk = false;
793
+ log(`security: org alert sweep failed (${String(e.message ?? e).slice(0, 80)})`);
794
+ }
795
+
796
+ const fixes = {};
797
+ const scanned = {};
798
+ await pMap(repos, async r => {
799
+ // Whether the repo is scanned AT ALL. Without this, an unscanned repo
800
+ // reports zero alerts and is indistinguishable from a clean one -- which
801
+ // is how a repo sits unscanned while the sweep calls it healthy.
802
+ // 204 = enabled, 404 = disabled; the endpoint has no body either way.
803
+ const st = await restStatus(`repos/${org}/${r.name}/vulnerability-alerts`);
804
+ scanned[r.name] = st === 204 ? 1 : st === 404 ? 0 : null;
805
+ try {
806
+ const d = await rest(`repos/${org}/${r.name}/automated-security-fixes`);
807
+ fixes[r.name] = d?.enabled === true ? 1 : 0;
808
+ } catch {
809
+ fixes[r.name] = null; // no permission, or alerts are off entirely
810
+ }
811
+ }, concurrency);
812
+
813
+ log(`security: ${alerts.length} open alerts, ${Object.values(fixes).filter(v => v === 0).length} repos with auto-fixes off, ${Object.values(scanned).filter(v => v === 0).length} not scanned at all`);
814
+ return { ok: alertsOk, alerts, auto_security_fixes: fixes, vulnerability_alerts: scanned };
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // Committed lockfiles, audited by the warehouse itself. See lockfile.mjs for
819
+ // why GitHub's own count cannot be trusted per manifest.
820
+ //
821
+ // Cost: one recursive tree call per repo (REST), plus one blob fetch per
822
+ // lockfile whose SHA changed since the last sweep. The parsed package map is
823
+ // cached by blob SHA in data/lockfile-cache.json (gitignored, rebuildable), so
824
+ // steady state is ~99 REST calls. The advisory lookup itself goes to the npm
825
+ // registry, not GitHub, and is re-run EVERY sweep on purpose: an audit verdict
826
+ // is a function of (lockfile, wall-clock), and caching it by SHA would let a
827
+ // newly published advisory hide behind an unchanged file -- the exact trap
828
+ // that made a green PR go red with no commit on 2026-09-17.
829
+ const NPM_BULK = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk';
830
+ const CACHE_FILE = join(DATA_DIR, 'lockfile-cache.json');
831
+
832
+ function readPackageMapCache() {
833
+ try { return existsSync(CACHE_FILE) ? JSON.parse(readFileSync(CACHE_FILE, 'utf8')) : {}; } catch { return {}; }
834
+ }
835
+
836
+ async function auditPackageMap(map) {
837
+ if (!Object.keys(map).length) return {};
838
+ const res = await fetch(NPM_BULK, {
839
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(map),
840
+ signal: AbortSignal.timeout(30_000),
841
+ });
842
+ if (!res.ok) throw new Error(`npm bulk advisory ${res.status}`);
843
+ return res.json();
844
+ }
845
+
846
+ async function collectLockfiles(org, repos, concurrency = 6) {
847
+ const cache = readPackageMapCache(); // { blobSha: { version, packages: {name:[v]} } }
848
+ const nextCache = {};
849
+ const out = {}; // { repo: [ {path, sha, size, ...} ] }
850
+ let fetched = 0, cached = 0, audited = 0, errored = 0;
851
+
852
+ await pMap(repos.filter(r => !r.isArchived), async r => {
853
+ let tree;
854
+ try {
855
+ tree = await rest(`repos/${org}/${r.name}/git/trees/${r.defaultBranch ?? 'main'}?recursive=1`);
856
+ } catch (e) {
857
+ out[r.name] = [{ path: null, error: `tree: ${String(e.message ?? e).slice(0, 120)}` }];
858
+ errored++; return;
859
+ }
860
+ const files = lockfilePathsFromTree(tree?.tree);
861
+ out[r.name] = [];
862
+ for (const f of files) {
863
+ const row = { path: f.path, sha: f.sha, size: f.size, truncated_tree: tree?.truncated === true };
864
+ try {
865
+ let entry = cache[f.sha];
866
+ if (!entry) {
867
+ const blob = await rest(`repos/${org}/${r.name}/git/blobs/${f.sha}`);
868
+ const text = Buffer.from(String(blob?.content ?? ''), blob?.encoding === 'base64' ? 'base64' : 'utf8').toString('utf8');
869
+ // npm's package-lock.json or pnpm's pnpm-lock.yaml; the cache is
870
+ // keyed by blob SHA, so a cached entry is already the right kind's.
871
+ entry = readLockfile(text, f.kind, t => parseYaml(t, { logLevel: 'silent' }));
872
+ fetched++;
873
+ } else {
874
+ cached++;
875
+ }
876
+ nextCache[f.sha] = entry;
877
+ const bulk = await auditPackageMap(entry.packages);
878
+ const advisories = advisoriesFromBulk(entry.packages, bulk, entry.dev ?? []);
879
+ audited++;
880
+ Object.assign(row, {
881
+ lockfile_version: entry.version,
882
+ packages: Object.keys(entry.packages).length,
883
+ counts: countBySeverity(advisories),
884
+ advisories,
885
+ });
886
+ } catch (e) {
887
+ // UNKNOWN, never clean: the loader writes `error` and the analyzer
888
+ // skips the row rather than reading it as zero exposure.
889
+ row.error = String(e.message ?? e).slice(0, 160);
890
+ errored++;
891
+ }
892
+ out[r.name].push(row);
893
+ }
894
+ }, concurrency);
895
+
896
+ try { writeFileSync(CACHE_FILE, JSON.stringify(nextCache)); } catch { /* cache is a convenience */ }
897
+ const total = Object.values(out).flat().filter(x => x.path).length;
898
+ log(`lockfiles: ${total} audited across ${Object.keys(out).length} repos (${fetched} fetched, ${cached} from cache, ${audited} registry lookups, ${errored} errors)`);
899
+ return out;
900
+ }
901
+
902
+ /**
903
+ * Pass 7 - the Actions invoice.
904
+ *
905
+ * ONE REST call for the whole org. The enhanced billing endpoint returns a line
906
+ * per (day, product, SKU, repository) with gross, discount and net, which is
907
+ * the only authoritative statement of what Actions cost. Everything downstream
908
+ * treats this as truth and the per-job arithmetic as an attribution of it.
909
+ *
910
+ * Returns `{ ok:false }` rather than throwing. The endpoint needs a scope the
911
+ * collector does not otherwise require, and a missing scope must degrade to "no
912
+ * cost data" -- never to a zero that reads as "this org spends nothing".
913
+ */
914
+ async function collectBilling(org, when = new Date()) {
915
+ const year = when.getUTCFullYear(), month = when.getUTCMonth() + 1;
916
+ try {
917
+ const data = await rest(
918
+ `organizations/${org}/settings/billing/usage?year=${year}&month=${month}`);
919
+ const items = (data.usageItems ?? []).map(i => ({
920
+ date: i.date, product: i.product, sku: i.sku,
921
+ unit_type: i.unitType, quantity: i.quantity, price_per_unit: i.pricePerUnit,
922
+ gross: i.grossAmount, discount: i.discountAmount, net: i.netAmount,
923
+ repo: i.repositoryName || null,
924
+ }));
925
+ const gross = items.reduce((a, i) => a + (i.gross ?? 0), 0);
926
+ const net = items.reduce((a, i) => a + (i.net ?? 0), 0);
927
+ log(`billing: ${items.length} line items for ${year}-${String(month).padStart(2, '0')} ` +
928
+ `(gross $${gross.toFixed(2)}, net $${net.toFixed(2)})`);
929
+ return { ok: true, year, month, items };
930
+ } catch (e) {
931
+ // 403 here is the ordinary case on a token without the billing scope.
932
+ log(`billing: UNAVAILABLE (${e.message?.slice(0, 120)})`);
933
+ return { ok: false, year, month, items: [], error: e.message?.slice(0, 300) ?? String(e) };
934
+ }
935
+ }
936
+
937
+ const RUN_COST_CACHE = join(DATA_DIR, 'run-cost-cache.json');
938
+
939
+ // How many repos get the per-job drill-down, and the ceiling on job fetches per
940
+ // sweep. Both exist because this pass is the only expensive thing the collector
941
+ // does: a job list is one REST call per run, and an org can produce thousands
942
+ // of runs a month. The billing pass already tells us which repos carry the cost,
943
+ // so the drill follows that ranking instead of sweeping everything -- ~1 call
944
+ // per run in the top repos on a cold cache, near zero afterwards.
945
+ const DRILL_REPOS = Number(process.env.HK_COST_REPOS ?? 12);
946
+ export const DRILL_BUDGET = Number(process.env.HK_COST_BUDGET ?? 1500);
947
+
948
+ function readRunCostCache() {
949
+ try { return existsSync(RUN_COST_CACHE) ? JSON.parse(readFileSync(RUN_COST_CACHE, 'utf8')) : {}; }
950
+ catch { return {}; }
951
+ }
952
+
953
+ /**
954
+ * Pass 8 - per-run cost attribution for the repos that carry the spend.
955
+ *
956
+ * The invoice says which repo. Only the jobs say which WORKFLOW, which matrix
957
+ * cell and which runner, and GitHub's own /timing endpoints return zeros for
958
+ * all of it (verified 2026-09-21: workflow timing returns `{"billable":{}}` and
959
+ * run timing returns total_ms 0 on runs that plainly billed minutes). So the
960
+ * durations are read per job and priced here.
961
+ *
962
+ * Caching is keyed by run id and holds ONLY completed runs. A finished run is
963
+ * immutable, so its billable minutes are a pure function of the run -- the
964
+ * condition that makes a cache correct. An in-progress run is deliberately
965
+ * never cached and never priced: its cost is still moving, and freezing a
966
+ * partial duration would under-report it permanently. This is the same reason
967
+ * the lockfile pass caches parsed package maps by blob SHA but refuses to cache
968
+ * advisory verdicts.
969
+ *
970
+ * The window is the billing month, so the result is directly comparable with
971
+ * the invoice. Repos outside the drill get NO rows rather than empty ones --
972
+ * the analyzer reads absence as "not measured", never as "costs nothing".
973
+ */
974
+ async function collectRunCosts(org, repos, billing, concurrency = 8) {
975
+ if (!billing.ok) { log('run costs: skipped (no billing data to rank repos by)'); return { ok: false, repos: [], runs: [] }; }
976
+
977
+ const { rates, estimated, unmapped } = ratesFromUsage(billing.items);
978
+ const priced = withFallback(rates);
979
+ if (estimated) log('run costs: WARNING billing revealed no rates; using published list prices');
980
+ if (unmapped.length) log(`run costs: unmapped SKUs (left unpriced): ${unmapped.join(', ')}`);
981
+
982
+ const spend = new Map();
983
+ for (const i of billing.items) {
984
+ if (i.product !== 'actions' || i.sku === 'Actions storage' || !i.repo) continue;
985
+ spend.set(i.repo, (spend.get(i.repo) ?? 0) + (i.gross ?? 0));
986
+ }
987
+ const live = new Set(repos.filter(r => !r.isEmpty).map(r => r.name));
988
+ const targets = [...spend.entries()]
989
+ .filter(([name]) => live.has(name))
990
+ .sort((a, b) => b[1] - a[1])
991
+ .slice(0, DRILL_REPOS)
992
+ .map(([name, gross]) => ({ name, gross }));
993
+
994
+ const since = `${billing.year}-${String(billing.month).padStart(2, '0')}-01`;
995
+ const cache = readRunCostCache();
996
+ const nextCache = {};
997
+ let fetched = 0, cachedHits = 0, skippedLive = 0, budgetHit = false;
998
+
999
+ const results = await pMap(targets, async t => {
1000
+ const runs = [];
1001
+ for (let page = 1; page <= 10; page++) {
1002
+ const data = await rest(`repos/${org}/${t.name}/actions/runs`
1003
+ + `?per_page=100&page=${page}&created=%3E%3D${since}`);
1004
+ const batch = data.workflow_runs ?? [];
1005
+ runs.push(...batch);
1006
+ if (batch.length < 100) break;
1007
+ }
1008
+ const out = [];
1009
+ for (const run of runs) {
1010
+ if (run.status !== 'completed') { skippedLive++; continue; }
1011
+ const key = String(run.id);
1012
+ let jobs = cache[key];
1013
+ if (jobs) { cachedHits++; }
1014
+ else {
1015
+ if (fetched >= DRILL_BUDGET) { budgetHit = true; continue; }
1016
+ fetched++;
1017
+ const collected = [];
1018
+ for (let page = 1; page <= 5; page++) {
1019
+ const d = await rest(`repos/${org}/${t.name}/actions/runs/${run.id}/jobs`
1020
+ + `?per_page=100&page=${page}&filter=all`);
1021
+ const batch = d.jobs ?? [];
1022
+ // Only the three fields pricing needs. Storing whole job objects would
1023
+ // grow the cache by two orders of magnitude for no extra answer.
1024
+ collected.push(...batch.map(j => ({
1025
+ name: j.name, labels: j.labels ?? [],
1026
+ started_at: j.started_at, completed_at: j.completed_at,
1027
+ conclusion: j.conclusion,
1028
+ })));
1029
+ if (batch.length < 100) break;
1030
+ }
1031
+ jobs = collected;
1032
+ }
1033
+ nextCache[key] = jobs;
1034
+ const p = priceRun(jobs, priced);
1035
+ out.push({
1036
+ repo: t.name, run_id: run.id, workflow_name: run.name,
1037
+ path: run.path ?? null, event: run.event, conclusion: run.conclusion,
1038
+ created_at: run.created_at,
1039
+ ...p,
1040
+ jobs: jobs.map(j => {
1041
+ const one = priceRun([j], priced);
1042
+ return {
1043
+ name: j.name,
1044
+ runner_labels: (j.labels ?? []).join(','),
1045
+ // Classified once, here, so no downstream query has to guess from
1046
+ // the label string.
1047
+ runner_class: runnerClass(j.labels ?? []),
1048
+ conclusion: j.conclusion,
1049
+ minutes: one.billable_minutes + one.self_hosted_minutes + one.unpriced_minutes,
1050
+ cost_usd: one.cost_usd,
1051
+ };
1052
+ }),
1053
+ });
1054
+ }
1055
+ return out;
1056
+ }, concurrency);
1057
+
1058
+ try { writeFileSync(RUN_COST_CACHE, JSON.stringify(nextCache)); }
1059
+ catch { /* cache is a convenience, never a correctness requirement */ }
1060
+
1061
+ const runs = [], errors = [];
1062
+ results.forEach((res, i) => {
1063
+ if (res.ok) runs.push(...res.value);
1064
+ else errors.push({ repo: targets[i].name, stage: 'run_costs', message: res.error });
1065
+ });
1066
+ const total = runs.reduce((a, r) => a + r.cost_usd, 0);
1067
+ log(`run costs: ${runs.length} runs priced across ${targets.length} repos ` +
1068
+ `($${total.toFixed(2)} attributed; ${fetched} fetched, ${cachedHits} cached, ` +
1069
+ `${skippedLive} still running)${budgetHit ? ` -- BUDGET ${DRILL_BUDGET} HIT, attribution INCOMPLETE` : ''}`);
1070
+
1071
+ return {
1072
+ ok: true, since, rates: priced, rates_estimated: estimated, unmapped_skus: unmapped,
1073
+ budget_hit: budgetHit, drilled: targets.map(t => t.name),
1074
+ repos: targets, runs, errors,
1075
+ };
1076
+ }
1077
+
1078
+ export async function collect(org) {
1079
+ const ctx = { started: Date.now(), takenAt: new Date().toISOString(), stage: 'start', login: null };
1080
+ return recordingFailure(ctx, () => sweep(org, ctx));
1081
+ }
1082
+
1083
+ // ctx.stage names the pass in flight, so a failed line says where it died.
1084
+ async function sweep(org, ctx) {
1085
+ const { started, takenAt } = ctx;
1086
+ const login = ctx.login = whoami();
1087
+ log(`org=${org} collector=${COLLECTOR_VERSION} as=${login}`);
1088
+
1089
+ ctx.stage = 'repos'; const { repos, total, rateLimit } = await collectRepos(org);
1090
+ ctx.stage = 'mergeable'; const mergeableResolved = await refreshMergeable(org, repos);
1091
+ ctx.stage = 'workflow_files'; const workflowFiles = await collectWorkflowFiles(org, repos);
1092
+ ctx.stage = 'runs'; const { runs, errors } = await collectRuns(org, repos);
1093
+ ctx.stage = 'failed_steps'; const failedSteps = await collectFailedSteps(org, runs, repos);
1094
+ ctx.stage = 'deploy_settings'; const deploySettings = await collectDeploySettings(org, repos, workflowFiles);
1095
+ ctx.stage = 'npm'; const npm = await collectNpm(repos);
1096
+ ctx.stage = 'security'; const security = await collectSecurity(org, repos);
1097
+ ctx.stage = 'lockfiles'; const lockfiles = await collectLockfiles(org, repos);
1098
+ ctx.stage = 'atlas_maps'; const atlasMaps = await collectAtlasMaps(org, repos);
1099
+ ctx.stage = 'billing'; const billing = await collectBilling(org);
1100
+ ctx.stage = 'run_costs'; const runCosts = await collectRunCosts(org, repos, billing);
1101
+ ctx.stage = 'write';
1102
+
1103
+ const snapshot = {
1104
+ schema: 1,
1105
+ taken_at: takenAt,
1106
+ org,
1107
+ collector_version: COLLECTOR_VERSION,
1108
+ gh_login: login,
1109
+ repo_count: repos.length,
1110
+ declared_total: total,
1111
+ duration_ms: Date.now() - started,
1112
+ rate_limit_remaining: rateLimit?.remaining ?? null,
1113
+ mergeable_resolved: mergeableResolved,
1114
+ repos,
1115
+ workflow_files: Object.fromEntries(workflowFiles),
1116
+ workflow_runs: runs,
1117
+ failed_steps: failedSteps,
1118
+ deploy_settings: { ok: deploySettings.ok, calls: deploySettings.calls, repos: deploySettings.repos },
1119
+ npm,
1120
+ security,
1121
+ lockfiles,
1122
+ // fetched/cached counts are left out: they describe this machine's cache,
1123
+ // not the org, and would make two identical observations look different.
1124
+ atlas_maps: { ok: atlasMaps.ok, fleet_version: atlasMaps.fleet_version, repos: atlasMaps.repos },
1125
+ billing,
1126
+ run_costs: runCosts,
1127
+ errors: errors.concat(deploySettings.errors ?? [], runCosts.errors ?? [],
1128
+ failedSteps.runs.filter(x => x.error).map(x => ({ repo: x.repo, stage: 'failed_steps', message: x.error }))),
1129
+ };
1130
+
1131
+ const dir = join(DATA_DIR, 'snapshots');
1132
+ mkdirSync(dir, { recursive: true });
1133
+ const fingerprint = snapshotFingerprint(snapshot);
1134
+
1135
+ // Skip writing a snapshot that observed exactly what the previous one did.
1136
+ // Ten sweeps landed inside two hours on 2026-09-08 and two of them
1137
+ // (00-02-37, 00-04-27) were byte-identical apart from the timestamp -- 9 MB
1138
+ // of working tree recording zero drift. Drift between snapshots is the whole
1139
+ // point of keeping them, and an identical snapshot contains none.
1140
+ //
1141
+ // This is NOT a deletion and does not soften the append-only rule: nothing on
1142
+ // disk is touched, and the sweep is still recorded in data/sweeps.jsonl. We
1143
+ // drop the payload, not the fact that we looked.
1144
+ //
1145
+ // Only the immediately previous snapshot is compared. A sweep that matches an
1146
+ // OLDER one after something moved and moved back is a real change and must be
1147
+ // written. Any doubt resolves toward writing: an unreadable previous snapshot
1148
+ // is treated as different, never as a match.
1149
+ const prevFile = latestSnapshotOnDisk(dir);
1150
+ let prevSnap = null;
1151
+ if (prevFile) {
1152
+ try { prevSnap = JSON.parse(readFileSync(prevFile, 'utf8')); }
1153
+ catch (e) { log(`previous snapshot unreadable (${e.message}); writing this one`); }
1154
+ }
1155
+ if (prevSnap && snapshotFingerprint(prevSnap) === fingerprint) {
1156
+ recordSweep({
1157
+ at: takenAt, result: 'duplicate', identical_to: basename(prevFile),
1158
+ content_hash: fingerprint, duration_ms: snapshot.duration_ms,
1159
+ repo_count: snapshot.repo_count, collector_version: COLLECTOR_VERSION,
1160
+ });
1161
+ log(`identical to ${basename(prevFile)} - no snapshot written, sweep recorded in data/sweeps.jsonl`);
1162
+ // Return the snapshot that EXISTS on disk, so a caller loading this result
1163
+ // puts a row in the database that `npm run rebuild` can reproduce.
1164
+ return { snapshot: prevSnap, file: prevFile, duplicate: true, fingerprint };
1165
+ }
1166
+
1167
+ const file = join(dir, `${takenAt.replace(/[:.]/g, '-')}.json`);
1168
+ writeFileSync(file, JSON.stringify(snapshot, null, 1));
1169
+ recordSweep({
1170
+ at: takenAt, result: 'written', file: basename(file),
1171
+ content_hash: fingerprint, duration_ms: snapshot.duration_ms,
1172
+ repo_count: snapshot.repo_count, collector_version: COLLECTOR_VERSION,
1173
+ });
1174
+ log(`wrote ${file} (${(JSON.stringify(snapshot).length / 1e6).toFixed(2)} MB) in ${snapshot.duration_ms}ms`);
1175
+ return { snapshot, file, duplicate: false, fingerprint };
1176
+ }
1177
+
1178
+ const invokedDirectly = process.argv[1] &&
1179
+ import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
1180
+
1181
+ if (invokedDirectly) {
1182
+ Promise.resolve().then(() => collect(resolveOrg(process.argv[2])))
1183
+ .catch(e => { console.error(formatError(e, { debug: isDebug() })); process.exit(exitCodeFor(e)); });
1184
+ }