@mcptoolshop/housekeeping 0.0.0 → 1.3.0
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/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/README.es.md +217 -0
- package/README.fr.md +195 -0
- package/README.hi.md +195 -0
- package/README.it.md +195 -0
- package/README.ja.md +199 -0
- package/README.md +246 -5
- package/README.pt-BR.md +222 -0
- package/README.zh.md +197 -0
- package/housekeeping.config.example.json +4 -0
- package/package.json +55 -5
- package/rules/atlas-map.md +30 -0
- package/rules/github-actions.md +59 -0
- package/rules/repo-first.md +12 -0
- package/rules/shipcheck-product-standards.md +37 -0
- package/src/analyze.mjs +1310 -0
- package/src/cli.mjs +316 -0
- package/src/collect.mjs +1184 -0
- package/src/config.mjs +75 -0
- package/src/cost.mjs +271 -0
- package/src/errors.mjs +102 -0
- package/src/gh.mjs +286 -0
- package/src/load.mjs +907 -0
- package/src/lockfile.mjs +272 -0
- package/src/log.mjs +41 -0
- package/src/mcp.mjs +297 -0
- package/src/paths.mjs +31 -0
- package/src/report.mjs +570 -0
- package/src/schema.sql +385 -0
- package/src/workflow-risks.mjs +455 -0
package/src/collect.mjs
ADDED
|
@@ -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
|
+
}
|