@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.
package/src/cli.mjs ADDED
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ // housekeeping CLI - query the org operational-health warehouse.
3
+ //
4
+ // Results go to stdout; progress and errors go to stderr. Exit codes are in
5
+ // errors.mjs: 0 ok, 1 user error, 2 runtime error, 3 partial.
6
+ import { readFileSync } from 'node:fs';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { openDb, loadSnapshot, latestSnapshotFile, DB_PATH } from './load.mjs';
9
+ import { analyze, healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED } from './analyze.mjs';
10
+ import { collect, DRILL_BUDGET } from './collect.mjs';
11
+ import { preflight } from './gh.mjs';
12
+ import { resolveOrg } from './config.mjs';
13
+ import { writeReport } from './report.mjs';
14
+ import { EXIT, HkError, userError, exitCodeFor, formatError, readOnlyQuery, sweepGaps } from './errors.mjs';
15
+ import { info, isDebug, levelFromFlags, parseLevel, setLevel } from './log.mjs';
16
+
17
+ // Opened by the first command that needs it, so `hk help` and a mistyped
18
+ // command never create a database.
19
+ let db = null;
20
+ const latestSid = () => {
21
+ const row = db.prepare('SELECT MAX(id) AS id FROM snapshot').get();
22
+ if (!row?.id) throw userError('NO_SNAPSHOT', 'no snapshot is loaded', 'run `hk refresh` to sweep, or `hk load <file>` to load one');
23
+ return row.id;
24
+ };
25
+
26
+ function table(rows, { max = 200 } = {}) {
27
+ if (!rows.length) return console.log('(no rows)');
28
+ const cols = Object.keys(rows[0]);
29
+ const shown = rows.slice(0, max);
30
+ const w = cols.map(c => Math.min(60, Math.max(c.length,
31
+ ...shown.map(r => String(r[c] ?? '').length))));
32
+ const line = (cells) => cells.map((s, i) => String(s ?? '').slice(0, w[i]).padEnd(w[i])).join(' ');
33
+ console.log(line(cols));
34
+ console.log(w.map(n => '-'.repeat(n)).join(' '));
35
+ for (const r of shown) console.log(line(cols.map(c => r[c])));
36
+ if (rows.length > shown.length) console.log(`... ${rows.length - shown.length} more rows`);
37
+ }
38
+
39
+ const REPORTS = {
40
+ summary: sid => {
41
+ const s = db.prepare('SELECT * FROM snapshot WHERE id=?').get(sid);
42
+ console.log(`snapshot ${sid} org=${s.org} taken=${s.taken_at} collector=${s.collector_version}`);
43
+ table(db.prepare(`SELECT
44
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1) repos,
45
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_archived=1) archived,
46
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_private=1) private,
47
+ (SELECT COUNT(*) FROM issue WHERE snapshot_id=?1) open_issues,
48
+ (SELECT COUNT(*) FROM pull_request WHERE snapshot_id=?1) open_prs,
49
+ (SELECT COUNT(*) FROM workflow WHERE snapshot_id=?1) workflows,
50
+ (SELECT COUNT(*) FROM workflow_run WHERE snapshot_id=?1) runs,
51
+ (SELECT COUNT(*) FROM release WHERE snapshot_id=?1) releases,
52
+ (SELECT COUNT(*) FROM finding WHERE snapshot_id=?1) findings,
53
+ (SELECT COUNT(*) FROM collect_error WHERE snapshot_id=?1) collect_errors`).all(sid));
54
+ },
55
+ // Must stay identical to report.mjs's "default branch is red" query: only
56
+ // latest-on-default runs from push-like events count as a broken mainline.
57
+ ci: sid => table(db.prepare(`
58
+ SELECT DISTINCT r.name, r.default_branch branch, r.ci_rollup rollup,
59
+ r.open_prs, r.days_since_push push_age,
60
+ (SELECT GROUP_CONCAT(wr.workflow_name, '; ') FROM workflow_run wr
61
+ WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
62
+ AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}) failing
63
+ FROM repo r WHERE r.snapshot_id=? AND r.is_archived=0
64
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
65
+ SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
66
+ AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}))
67
+ ORDER BY r.name`).all(sid)),
68
+ findings: sid => table(db.prepare(`
69
+ SELECT severity, category, code, COUNT(*) n, COUNT(DISTINCT repo) repos
70
+ FROM finding WHERE snapshot_id=?
71
+ GROUP BY severity, category, code
72
+ ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1
73
+ WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, n DESC`).all(sid)),
74
+ health: sid => table(healthScores(db, latestOr(sid)).slice(0, 40)),
75
+ prs: sid => table(db.prepare(`
76
+ SELECT repo, number, age_days age, stale_days stale, is_draft draft, mergeable,
77
+ author, SUBSTR(title,1,54) title
78
+ FROM pull_request WHERE snapshot_id=? ORDER BY age_days DESC`).all(sid), { max: 400 }),
79
+ issues: sid => table(db.prepare(`
80
+ SELECT repo, number, age_days age, stale_days stale, comments c,
81
+ author, SUBSTR(title,1,54) title
82
+ FROM issue WHERE snapshot_id=? ORDER BY stale_days DESC`).all(sid), { max: 400 }),
83
+ actions: sid => table(db.prepare(`
84
+ SELECT repo, path, on_triggers, has_paths_filter paths, has_concurrency conc,
85
+ has_workflow_dispatch disp, runners, job_count jobs
86
+ FROM workflow WHERE snapshot_id=? ORDER BY repo, path`).all(sid), { max: 400 }),
87
+ // Gross vs net are printed side by side and never summed. On a public repo
88
+ // GitHub meters at full price and discounts to zero: gross is compute worth
89
+ // managing, net is money owed.
90
+ cost: sid => {
91
+ const lines = db.prepare(
92
+ "SELECT COUNT(*) n FROM billing_usage WHERE snapshot_id=? AND product='actions'").get(sid);
93
+ if (!lines.n) {
94
+ const err = db.prepare(
95
+ "SELECT message FROM collect_error WHERE snapshot_id=? AND stage='billing'").all(sid);
96
+ return console.log(err.length
97
+ ? `no cost data - billing API unreadable: ${err[0].message}`
98
+ : 'no cost data in this snapshot - run `hk refresh`');
99
+ }
100
+ table(db.prepare(`SELECT b.repo,
101
+ printf('%.2f', SUM(b.gross)) gross, printf('%.2f', SUM(b.net)) net,
102
+ CASE WHEN r.is_private=1 THEN 'private' ELSE 'public' END vis,
103
+ ROUND(SUM(CASE WHEN b.sku='Actions Linux' THEN b.quantity ELSE 0 END)) linux_min,
104
+ ROUND(SUM(CASE WHEN b.sku='Actions Windows' THEN b.quantity ELSE 0 END)) win_min,
105
+ ROUND(SUM(CASE WHEN b.sku LIKE 'Actions macOS%' THEN b.quantity ELSE 0 END)) mac_min
106
+ FROM billing_usage b LEFT JOIN repo r ON r.snapshot_id=b.snapshot_id AND r.name=b.repo
107
+ WHERE b.snapshot_id=? AND b.product='actions' AND b.sku<>'Actions storage'
108
+ AND b.repo IS NOT NULL
109
+ GROUP BY b.repo ORDER BY SUM(b.gross) DESC`).all(sid), { max: 25 });
110
+ },
111
+ // Per-workflow attribution, restricted to repos whose priced runs agreed with
112
+ // the invoice. A cost claim on a repo that diverged is a guess.
113
+ 'cost-workflows': (sid, repo) => table(db.prepare(`
114
+ SELECT c.repo, c.workflow_name workflow, COUNT(*) runs,
115
+ SUM(c.billable_minutes) min, printf('%.2f', SUM(c.cost_usd)) cost,
116
+ printf('%.1f', AVG(c.billed_job_count)) jobs_per_run,
117
+ SUM(CASE WHEN c.conclusion='failure' THEN 1 ELSE 0 END) failed
118
+ FROM run_cost c
119
+ JOIN cost_reconciliation x ON x.snapshot_id=c.snapshot_id AND x.repo=c.repo
120
+ WHERE c.snapshot_id=? AND x.trustworthy=1 AND (?2 IS NULL OR c.repo=?2)
121
+ GROUP BY c.repo, c.workflow_name ORDER BY SUM(c.cost_usd) DESC`).all(sid, repo ?? null)),
122
+ 'cost-jobs': (sid, repo) => table(db.prepare(`
123
+ SELECT j.repo, c.workflow_name workflow,
124
+ TRIM(CASE WHEN j.name LIKE '%(%)' THEN SUBSTR(j.name,1,INSTR(j.name,' (')-1)
125
+ ELSE j.name END) job,
126
+ COUNT(*) cells, SUM(j.minutes) min, printf('%.2f', SUM(j.cost_usd)) cost
127
+ FROM run_cost_job j JOIN run_cost c
128
+ ON c.snapshot_id=j.snapshot_id AND c.run_id=j.run_id
129
+ WHERE j.snapshot_id=? AND (?2 IS NULL OR j.repo=?2)
130
+ GROUP BY j.repo, c.workflow_name, job
131
+ ORDER BY SUM(j.cost_usd) DESC`).all(sid, repo ?? null), { max: 40 }),
132
+ versions: sid => table(db.prepare(`
133
+ SELECT name, pkg_name, pkg_version pkg, latest_tag tag,
134
+ latest_release_tag release, npm_latest_version npm, npm_status,
135
+ (SELECT COUNT(*) FROM tag t WHERE t.snapshot_id=r.snapshot_id
136
+ AND t.repo=r.name AND t.is_semver=1 AND t.has_release=0) untagged_rel
137
+ FROM repo r WHERE snapshot_id=? AND pkg_name IS NOT NULL
138
+ ORDER BY name`).all(sid), { max: 200 }),
139
+ repos: sid => table(db.prepare(`
140
+ SELECT name, is_private priv, is_archived arch, primary_language lang,
141
+ disk_usage_kb kb, days_since_push push_age, open_issues iss, open_prs prs,
142
+ release_count rels, ci_rollup, workflow_count wfs
143
+ FROM repo WHERE snapshot_id=? ORDER BY name`).all(sid), { max: 200 }),
144
+ repo: (sid, name) => {
145
+ if (!name) throw userError('USAGE', '`hk repo` needs a repository name', 'hk repo <name>');
146
+ table(db.prepare('SELECT * FROM repo WHERE snapshot_id=? AND name=?').all(sid, name));
147
+ console.log('\n-- findings --');
148
+ table(db.prepare('SELECT severity, code, message, evidence FROM finding WHERE snapshot_id=? AND repo=?').all(sid, name));
149
+ console.log('\n-- workflows --');
150
+ table(db.prepare('SELECT path, on_triggers, has_paths_filter, has_concurrency, runners FROM workflow WHERE snapshot_id=? AND repo=?').all(sid, name));
151
+ console.log('\n-- open PRs --');
152
+ table(db.prepare('SELECT number, age_days, mergeable, author, SUBSTR(title,1,60) title FROM pull_request WHERE snapshot_id=? AND repo=?').all(sid, name));
153
+ },
154
+ };
155
+
156
+ function latestOr(sid) { return sid; }
157
+
158
+ // ---- commands ---------------------------------------------------------------
159
+ // One table drives both the dispatch and `hk help`, so the help text cannot
160
+ // describe a command that does not exist or omit one that does.
161
+
162
+ async function refresh(args) {
163
+ const org = resolveOrg(args.find(a => !a.startsWith('--')));
164
+ db = openDb();
165
+
166
+ // A sweep costs a few hundred API calls. Starting one without the headroom
167
+ // to finish produces a PARTIALLY collected snapshot, and load.mjs stores it
168
+ // without complaint -- a half-swept org then reads as a healthy one. Refuse
169
+ // up front instead. `--force` is there for the case where you accept a
170
+ // partial and know it.
171
+ const force = args.includes('--force');
172
+ // 300 covers the base sweep. The cost drill is the expensive part -- one
173
+ // REST call per priced run on a cold cache -- so its budget is added here
174
+ // rather than discovered halfway through. Capped at 900 because a warm
175
+ // cache spends almost none of it, and refusing an otherwise-healthy sweep
176
+ // over a reserve it will not use is its own failure. If the drill does run
177
+ // out it halts and marks the attribution incomplete; it never truncates
178
+ // silently.
179
+ const pre = await preflight({
180
+ rest: 300 + Math.min(DRILL_BUDGET, 900),
181
+ graphql: 300,
182
+ });
183
+ if (!pre.ok && !force) {
184
+ if (pre.limit === 'secondary') {
185
+ // The quota is fine and we are still blocked: burst/concurrency limit.
186
+ // It is short-lived and has no published clock, so there is nothing to
187
+ // wait *for* -- just fewer callers.
188
+ throw new HkError('RATE_LIMITED',
189
+ `refusing to start: GitHub's secondary rate limit is active (the quota is healthy at core ${pre.core}, graphql ${pre.graphql})`,
190
+ { hint: 'too many concurrent callers on this token; retry in a few minutes with fewer', retryable: true });
191
+ }
192
+ throw new HkError('RATE_LIMITED',
193
+ `refusing to start: core ${pre.core}, graphql ${pre.graphql} calls remain, and a full sweep would be cut short`,
194
+ { hint: `the quota resets in ${pre.resetsInS}s; or re-run with --force to accept a partial snapshot`, retryable: true });
195
+ }
196
+ if (pre.unknown) info('[refresh] rate-limit probe unreadable; continuing without a budget check.');
197
+
198
+ const { snapshot } = await collect(org);
199
+ const sid = loadSnapshot(db, snapshot);
200
+ const n = analyze(db, sid);
201
+ info(`[refresh] snapshot ${sid}: ${snapshot.repo_count} repos, ${n} findings`);
202
+ info(`[refresh] wrote ${writeReport(db, sid)}`);
203
+ REPORTS.summary(sid);
204
+
205
+ const gaps = sweepGaps(snapshot);
206
+ if (gaps.length) {
207
+ // The snapshot is stored and the report is written: what was collected is
208
+ // true. But it is not everything that was asked for, and a caller that
209
+ // only reads the exit code must be able to tell.
210
+ for (const g of gaps) console.error(`partial: ${g}`);
211
+ return EXIT.PARTIAL;
212
+ }
213
+ return EXIT.OK;
214
+ }
215
+
216
+ function load(args) {
217
+ db = openDb();
218
+ const file = args[0] ?? latestSnapshotFile();
219
+ let snapshot;
220
+ try { snapshot = JSON.parse(readFileSync(file, 'utf8')); }
221
+ catch (e) {
222
+ throw userError('SNAPSHOT_UNREADABLE', `cannot read a snapshot from ${file}: ${e.message}`,
223
+ 'pass the path of a file under data/snapshots/');
224
+ }
225
+ const sid = loadSnapshot(db, snapshot);
226
+ analyze(db, sid);
227
+ info(`[load] snapshot ${sid} <- ${file}`);
228
+ }
229
+
230
+ function report(args) {
231
+ db = openDb();
232
+ const sid = args[0] ? Number(args[0]) : latestSid();
233
+ if (!Number.isInteger(sid)) throw userError('USAGE', `"${args[0]}" is not a snapshot id`, 'hk snapshots lists them');
234
+ info(`[report] wrote ${writeReport(db, sid)}`);
235
+ }
236
+
237
+ function sql(args) {
238
+ const q = readOnlyQuery(args.join(' '));
239
+ // A connection that cannot write, as well as a statement check: the
240
+ // warehouse is derived data, and `hk sql` is documented as read-only.
241
+ openDb().close(); // make sure the file and schema exist
242
+ db = new DatabaseSync(DB_PATH, { readOnly: true });
243
+ let rows;
244
+ try { rows = db.prepare(q).all(); }
245
+ catch (e) { throw userError('SQL_FAILED', e.message, 'hk sql "SELECT name FROM sqlite_master" lists the tables'); }
246
+ table(rows, { max: 500 });
247
+ }
248
+
249
+ const viaReport = name => args => { db = openDb(); return REPORTS[name](latestSid(), ...args); };
250
+
251
+ const COMMANDS = {
252
+ refresh: ['[org] [--force]', 'sweep the org: collect, load, analyze, write the report (network)', refresh],
253
+ load: ['[file]', 'load a snapshot JSON already on disk (the newest by default)', load],
254
+ report: ['[snapshot-id]', 'write reports/AUDIT-<date>.md for a loaded snapshot', report],
255
+ summary: ['', 'snapshot totals (the default command)', viaReport('summary')],
256
+ repos: ['', 'every repo, one line each', viaReport('repos')],
257
+ repo: ['<name>', 'one repo in full, with findings', viaReport('repo')],
258
+ findings: ['', 'finding counts by severity, category and code', viaReport('findings')],
259
+ health: ['', 'per-repo health score, worst first', viaReport('health')],
260
+ ci: ['', 'repos whose default branch is red', viaReport('ci')],
261
+ actions: ['', 'every workflow and its rule-compliance flags', viaReport('actions')],
262
+ cost: ['', 'Actions cost per repo, gross and net side by side', viaReport('cost')],
263
+ 'cost-workflows': ['[repo]', 'which workflow spent the minutes', viaReport('cost-workflows')],
264
+ 'cost-jobs': ['[repo]', 'which job, with expanded matrix cells', viaReport('cost-jobs')],
265
+ versions: ['', 'package.json against git tag against npm', viaReport('versions')],
266
+ prs: ['', 'every open pull request by age', viaReport('prs')],
267
+ issues: ['', 'every open issue by staleness', viaReport('issues')],
268
+ snapshots: ['', 'the snapshots loaded into the database', () => {
269
+ db = openDb();
270
+ table(db.prepare('SELECT id, taken_at, org, repo_count, duration_ms FROM snapshot ORDER BY id').all());
271
+ }],
272
+ sql: ['"<query>"', 'one read-only SELECT or WITH statement', sql],
273
+ help: ['', 'this text', () => console.log(usage())],
274
+ };
275
+
276
+ function usage() {
277
+ const rows = Object.entries(COMMANDS).map(([name, [args, help]]) => [`hk ${name}${args ? ' ' + args : ''}`, help]);
278
+ const w = Math.max(...rows.map(r => r[0].length));
279
+ return [
280
+ 'housekeeping - an operational-health warehouse for a GitHub organization',
281
+ '',
282
+ ...rows.map(([l, r]) => ` ${l.padEnd(w)} ${r}`),
283
+ '',
284
+ 'Flags, on any command:',
285
+ ' --quiet errors only',
286
+ ' --verbose one line per GitHub call',
287
+ ' --debug stack traces on errors',
288
+ ' --help, -h this text',
289
+ '',
290
+ 'Exit codes: 0 ok, 1 user error, 2 runtime error, 3 partial (finished, with gaps).',
291
+ ].join('\n');
292
+ }
293
+
294
+ const GLOBAL_FLAGS = new Set(['--quiet', '--verbose', '--debug', '--help', '-h']);
295
+
296
+ async function main(argv) {
297
+ const flags = new Set(argv.filter(a => GLOBAL_FLAGS.has(a)));
298
+ const [cmd = 'summary', ...args] = argv.filter(a => !GLOBAL_FLAGS.has(a));
299
+ if (flags.has('--help') || flags.has('-h')) { console.log(usage()); return EXIT.OK; }
300
+ // Validated here, once, so a mistyped HK_LOG is reported before any work
301
+ // starts and not from the middle of a sweep.
302
+ setLevel(levelFromFlags(flags) ?? parseLevel(process.env.HK_LOG));
303
+ const entry = Object.hasOwn(COMMANDS, cmd) ? COMMANDS[cmd] : null;
304
+ if (!entry) throw userError('USAGE', `unknown command "${cmd}"`, '`hk help` lists the commands');
305
+ return (await entry[2](args)) ?? EXIT.OK;
306
+ }
307
+
308
+ try {
309
+ process.exitCode = await main(process.argv.slice(2));
310
+ } catch (e) {
311
+ // isDebug() can itself throw on a malformed HK_LOG; that must not mask `e`.
312
+ let debug = process.argv.includes('--debug');
313
+ try { debug = debug || isDebug(); } catch { /* reported by the command that read it */ }
314
+ console.error(formatError(e, { debug }));
315
+ process.exitCode = exitCodeFor(e);
316
+ }