@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/CHANGELOG.md +103 -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 +1360 -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 +298 -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/config.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Which org a checkout audits, and which of its repos are not products.
|
|
2
|
+
//
|
|
3
|
+
// The rules do not depend on whose org they read, so the org's name and its
|
|
4
|
+
// exemptions live in a file beside the code, never in the code. The file is
|
|
5
|
+
// optional: without one, the org is given on the command line and only the
|
|
6
|
+
// `.github` repo -- which exists to hold org-wide defaults in any org -- is
|
|
7
|
+
// treated as a non-product.
|
|
8
|
+
//
|
|
9
|
+
// housekeeping.config.json
|
|
10
|
+
// { "org": "your-org", "metaRepos": [".github", "design-assets"] }
|
|
11
|
+
//
|
|
12
|
+
// No network and no database here, so every branch is unit-testable.
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { userError } from './errors.mjs';
|
|
16
|
+
import { WORK_DIR } from './paths.mjs';
|
|
17
|
+
|
|
18
|
+
export const CONFIG_PATH = process.env.HK_CONFIG ?? join(WORK_DIR, 'housekeeping.config.json');
|
|
19
|
+
|
|
20
|
+
const KEYS = ['org', 'metaRepos'];
|
|
21
|
+
const invalid = message => userError('CONFIG_INVALID', message,
|
|
22
|
+
'fix housekeeping.config.json; housekeeping.config.example.json shows the shape');
|
|
23
|
+
const DEFAULT_META = ['.github'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse config text. Pure. Every malformed shape THROWS rather than falling
|
|
27
|
+
* back to a default: `metaRepos` suppresses hygiene findings, so a typo that
|
|
28
|
+
* silently emptied it would file findings against repos that are exempt, and a
|
|
29
|
+
* typo in the key name (`metaRepo`) would do the same while looking configured.
|
|
30
|
+
*/
|
|
31
|
+
export function parseConfig(text) {
|
|
32
|
+
let raw;
|
|
33
|
+
try { raw = JSON.parse(text); }
|
|
34
|
+
catch (e) { throw invalid(`housekeeping config is not valid JSON: ${e.message}`); }
|
|
35
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
36
|
+
throw invalid('housekeeping config must be a JSON object');
|
|
37
|
+
}
|
|
38
|
+
const unknown = Object.keys(raw).filter(k => !KEYS.includes(k));
|
|
39
|
+
if (unknown.length) {
|
|
40
|
+
throw invalid(`housekeeping config has unknown key(s): ${unknown.join(', ')} (known: ${KEYS.join(', ')})`);
|
|
41
|
+
}
|
|
42
|
+
const org = raw.org ?? null;
|
|
43
|
+
if (org !== null && (typeof org !== 'string' || !org.trim())) {
|
|
44
|
+
throw invalid('housekeeping config: "org" must be a non-empty string');
|
|
45
|
+
}
|
|
46
|
+
const meta = raw.metaRepos ?? DEFAULT_META;
|
|
47
|
+
if (!Array.isArray(meta) || meta.some(m => typeof m !== 'string' || !m.trim())) {
|
|
48
|
+
throw invalid('housekeeping config: "metaRepos" must be an array of repo names');
|
|
49
|
+
}
|
|
50
|
+
return { org, metaRepos: [...meta] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read the config file. A missing file is the defaults; an unreadable or malformed one throws. */
|
|
54
|
+
export function loadConfig(path = CONFIG_PATH) {
|
|
55
|
+
let text;
|
|
56
|
+
try { text = readFileSync(path, 'utf8'); }
|
|
57
|
+
catch (e) {
|
|
58
|
+
if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META] };
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
return parseConfig(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The org a sweep should collect: the command-line argument, else the config.
|
|
66
|
+
* Throws when neither names one -- a sweep must never guess whose org to read.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveOrg(arg, config = loadConfig()) {
|
|
69
|
+
const org = arg ?? config.org;
|
|
70
|
+
if (!org) {
|
|
71
|
+
throw userError('NO_ORG', 'no org given',
|
|
72
|
+
'pass one (hk refresh <org>) or set "org" in housekeeping.config.json');
|
|
73
|
+
}
|
|
74
|
+
return org;
|
|
75
|
+
}
|
package/src/cost.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Actions cost arithmetic: runner classification, billable minutes, and the
|
|
2
|
+
// price table.
|
|
3
|
+
//
|
|
4
|
+
// Split from collect.mjs because the two change for different reasons. This
|
|
5
|
+
// module changes when GitHub changes how it *bills* (rounding, runner classes,
|
|
6
|
+
// SKU names); collect.mjs changes when GitHub changes how it *reports*. Mixing
|
|
7
|
+
// them means a pricing fix has to be made inside a network path that cannot be
|
|
8
|
+
// unit-tested.
|
|
9
|
+
//
|
|
10
|
+
// Two facts drive every function here, both verified against live billing data
|
|
11
|
+
// on 2026-09-21:
|
|
12
|
+
//
|
|
13
|
+
// 1. GitHub bills per JOB, rounded UP to the whole minute, then multiplied by
|
|
14
|
+
// the runner class rate. A run's billable minutes are therefore NOT its
|
|
15
|
+
// wall-clock duration: six 10-second jobs bill 6 minutes, not 1. Measured:
|
|
16
|
+
// one run billed 119 minutes across 9 jobs whose wall time summed to well
|
|
17
|
+
// under 30.
|
|
18
|
+
//
|
|
19
|
+
// 2. Public repositories on standard runners are metered and then discounted
|
|
20
|
+
// to zero. The usage API returns grossAmount == discountAmount and
|
|
21
|
+
// netAmount == 0 for every such line. Gross is real compute; net is real
|
|
22
|
+
// money. A rule that conflates them either invents a bill that does not
|
|
23
|
+
// exist or hides waste because nobody is charged for it. Both are wrong,
|
|
24
|
+
// so both numbers are carried separately all the way to the report.
|
|
25
|
+
|
|
26
|
+
/** Runner classes we can price. OTHER is deliberately unpriced -- see below. */
|
|
27
|
+
export const RUNNER_CLASSES = ['UBUNTU', 'WINDOWS', 'MACOS', 'SELF_HOSTED', 'OTHER'];
|
|
28
|
+
|
|
29
|
+
// GitHub's published list prices, used ONLY when the billing API is unreadable.
|
|
30
|
+
// The real rates come from ratesFromUsage(): an account can be billed 0.006 for
|
|
31
|
+
// Linux where the list price is 0.008, and a hardcoded table would then
|
|
32
|
+
// overstate every number by a third. The fallback exists so a missing billing scope
|
|
33
|
+
// degrades to "approximate" rather than "no cost data at all", and anything
|
|
34
|
+
// priced this way is flagged `estimated` for the whole pipeline.
|
|
35
|
+
export const FALLBACK_RATES = { UBUNTU: 0.008, WINDOWS: 0.016, MACOS: 0.08 };
|
|
36
|
+
|
|
37
|
+
// Zero-cost classes. Self-hosted runners bill nothing; OTHER is anything we
|
|
38
|
+
// could not identify, which must stay unpriced rather than be guessed at the
|
|
39
|
+
// Linux rate -- a larger runner costs several times standard and would be
|
|
40
|
+
// silently undercounted.
|
|
41
|
+
const UNPRICED = new Set(['SELF_HOSTED', 'OTHER']);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Classify a job's `labels` into a billing class.
|
|
45
|
+
*
|
|
46
|
+
* Order matters. `runs-on: [self-hosted, linux, x64]` carries a `linux` label
|
|
47
|
+
* and would otherwise be priced as a hosted Ubuntu runner -- billing a repo for
|
|
48
|
+
* hardware it owns. Self-hosted is therefore checked first, unconditionally.
|
|
49
|
+
*
|
|
50
|
+
* Larger runners (`ubuntu-latest-4-core`, `windows-latest-8-core`, and
|
|
51
|
+
* org-named custom labels) bill at their own rates under their own SKUs, which
|
|
52
|
+
* this module does not know. They return OTHER so they are counted as jobs and
|
|
53
|
+
* minutes but contribute no cost, and the reconciliation against the billing
|
|
54
|
+
* API surfaces the gap instead of absorbing it.
|
|
55
|
+
*/
|
|
56
|
+
export function runnerClass(labels) {
|
|
57
|
+
const list = (Array.isArray(labels) ? labels : [labels])
|
|
58
|
+
.filter(Boolean).map(l => String(l).toLowerCase());
|
|
59
|
+
if (!list.length) return 'OTHER';
|
|
60
|
+
if (list.some(l => l === 'self-hosted')) return 'SELF_HOSTED';
|
|
61
|
+
// A core-count suffix means a larger runner on a different SKU.
|
|
62
|
+
if (list.some(l => /-\d+-core\b/.test(l))) return 'OTHER';
|
|
63
|
+
if (list.some(l => /^macos/.test(l))) return 'MACOS';
|
|
64
|
+
if (list.some(l => /^windows/.test(l))) return 'WINDOWS';
|
|
65
|
+
if (list.some(l => /^ubuntu/.test(l))) return 'UBUNTU';
|
|
66
|
+
return 'OTHER';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Billable minutes for one job: wall time rounded UP to the whole minute.
|
|
71
|
+
*
|
|
72
|
+
* Returns 0 -- never null -- for a job that did not run. A skipped job carries
|
|
73
|
+
* a null `started_at`, and a job cancelled before dispatch can report
|
|
74
|
+
* completed_at <= started_at. Both are free, and both must be distinguishable
|
|
75
|
+
* from "not measured", which is the absence of the row entirely.
|
|
76
|
+
*/
|
|
77
|
+
export function billableMinutes(startedAt, completedAt) {
|
|
78
|
+
if (!startedAt || !completedAt) return 0;
|
|
79
|
+
const ms = new Date(completedAt) - new Date(startedAt);
|
|
80
|
+
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
|
81
|
+
return Math.ceil(ms / 60000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Exact SKU spellings observed in the enhanced billing usage API. Matching is
|
|
85
|
+
// exact rather than by substring because "Actions Linux" and "Actions Linux
|
|
86
|
+
// 4-core" differ by a multiple, and a substring match would price the larger
|
|
87
|
+
// runner as standard.
|
|
88
|
+
const SKU_CLASS = new Map([
|
|
89
|
+
['actions linux', 'UBUNTU'],
|
|
90
|
+
['actions windows', 'WINDOWS'],
|
|
91
|
+
['actions macos', 'MACOS'],
|
|
92
|
+
['actions macos 3-core', 'MACOS'],
|
|
93
|
+
['actions macos 12-core', 'MACOS'],
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Derive this account's actual per-minute rates from its own billing lines.
|
|
98
|
+
*
|
|
99
|
+
* Self-calibrating on purpose: rates vary by plan and change without notice, so
|
|
100
|
+
* reading them back out of the invoice is the only way to stay correct without
|
|
101
|
+
* a maintenance burden. Returns the rates it could establish plus the SKUs it
|
|
102
|
+
* could not map, so an unrecognised runner class shows up in the report as a
|
|
103
|
+
* named gap instead of vanishing into a wrong total.
|
|
104
|
+
*/
|
|
105
|
+
export function ratesFromUsage(items = []) {
|
|
106
|
+
const seen = new Map(); // class -> Set(rate)
|
|
107
|
+
const unmapped = new Set();
|
|
108
|
+
for (const i of items) {
|
|
109
|
+
if (i?.product !== 'actions') continue;
|
|
110
|
+
const sku = String(i.sku ?? '').trim().toLowerCase();
|
|
111
|
+
if (!sku || sku === 'actions storage') continue;
|
|
112
|
+
const cls = SKU_CLASS.get(sku);
|
|
113
|
+
if (!cls) { unmapped.add(String(i.sku)); continue; }
|
|
114
|
+
// Both spellings on purpose. The billing API answers in camelCase, but
|
|
115
|
+
// collect.mjs normalises to the snake_case shape the snapshot and the
|
|
116
|
+
// `billing_usage` table use, and this function is handed BOTH: raw items
|
|
117
|
+
// during collection, stored rows when re-deriving from a snapshot. Reading
|
|
118
|
+
// only `pricePerUnit` silently found no rates at all on 2026-09-21 and fell
|
|
119
|
+
// back to list prices, overstating every figure by up to 60% -- the run
|
|
120
|
+
// logged "billing revealed no rates" and kept going. Matching one spelling
|
|
121
|
+
// is the bug; matching both is the fix.
|
|
122
|
+
const rate = i.price_per_unit ?? i.pricePerUnit;
|
|
123
|
+
if (!(rate > 0)) continue;
|
|
124
|
+
if (!seen.has(cls)) seen.set(cls, new Set());
|
|
125
|
+
seen.get(cls).add(rate);
|
|
126
|
+
}
|
|
127
|
+
const rates = {};
|
|
128
|
+
const ambiguous = [];
|
|
129
|
+
for (const [cls, set] of seen) {
|
|
130
|
+
const vals = [...set];
|
|
131
|
+
// Two rates for one class means a SKU split we do not model (e.g. both a
|
|
132
|
+
// 3-core and a 12-core macOS line). Take the dearest so an estimate errs
|
|
133
|
+
// toward over-reporting cost, and say so rather than picking silently.
|
|
134
|
+
if (vals.length > 1) ambiguous.push(`${cls}: ${vals.join(', ')}`);
|
|
135
|
+
rates[cls] = Math.max(...vals);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
rates,
|
|
139
|
+
estimated: Object.keys(rates).length === 0,
|
|
140
|
+
unmapped: [...unmapped],
|
|
141
|
+
ambiguous,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Rates with the fallback filled in for any class billing did not reveal. */
|
|
146
|
+
export function withFallback(rates = {}) {
|
|
147
|
+
return { ...FALLBACK_RATES, ...rates };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Price one run from its jobs.
|
|
152
|
+
*
|
|
153
|
+
* `jobs` are raw objects from GET /actions/runs/{id}/jobs. Returns per-class
|
|
154
|
+
* minutes alongside the total so a caller can ask the question this whole
|
|
155
|
+
* module exists for -- "few minutes, most of the cost?" -- without re-deriving
|
|
156
|
+
* the split. `unpriced_minutes` is minutes we counted but could not price;
|
|
157
|
+
* non-zero means the cost figure is a floor, not a total.
|
|
158
|
+
*/
|
|
159
|
+
export function priceRun(jobs = [], rates = FALLBACK_RATES) {
|
|
160
|
+
const minutes = { UBUNTU: 0, WINDOWS: 0, MACOS: 0, SELF_HOSTED: 0, OTHER: 0 };
|
|
161
|
+
let cost = 0, counted = 0;
|
|
162
|
+
for (const j of jobs) {
|
|
163
|
+
const m = billableMinutes(j?.started_at, j?.completed_at);
|
|
164
|
+
if (m <= 0) continue;
|
|
165
|
+
const cls = runnerClass(j?.labels ?? []);
|
|
166
|
+
minutes[cls] += m;
|
|
167
|
+
counted++;
|
|
168
|
+
if (!UNPRICED.has(cls)) cost += m * (rates[cls] ?? 0);
|
|
169
|
+
}
|
|
170
|
+
const billable = minutes.UBUNTU + minutes.WINDOWS + minutes.MACOS;
|
|
171
|
+
return {
|
|
172
|
+
job_count: jobs.length,
|
|
173
|
+
billed_job_count: counted,
|
|
174
|
+
billable_minutes: billable,
|
|
175
|
+
ubuntu_minutes: minutes.UBUNTU,
|
|
176
|
+
windows_minutes: minutes.WINDOWS,
|
|
177
|
+
macos_minutes: minutes.MACOS,
|
|
178
|
+
self_hosted_minutes: minutes.SELF_HOSTED,
|
|
179
|
+
unpriced_minutes: minutes.OTHER,
|
|
180
|
+
cost_usd: Number(cost.toFixed(6)),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Compare computed attribution against what GitHub actually billed.
|
|
186
|
+
*
|
|
187
|
+
* This is the verifier for everything else in the pipeline. The per-job sum is
|
|
188
|
+
* derived from a different endpoint than the invoice, so agreement is real
|
|
189
|
+
* evidence and divergence is a real warning: it means the runs we priced are
|
|
190
|
+
* not the runs that were billed (a truncated window, a deleted run, a runner
|
|
191
|
+
* class we cannot price). Rules consult `trustworthy` before making any claim
|
|
192
|
+
* about a repo's cost, because a confident number built on a partial sample is
|
|
193
|
+
* exactly the failure this repo keeps catching.
|
|
194
|
+
*
|
|
195
|
+
* `tolerance` is fractional. 0.15 was chosen because same-window comparisons on
|
|
196
|
+
* the five costliest repos of the first measured sweep landed within 2%, so
|
|
197
|
+
* 15% is loose enough to absorb rounding and mid-sweep runs while still
|
|
198
|
+
* catching a window that missed real spend.
|
|
199
|
+
*
|
|
200
|
+
* KNOWN DIVERGENCE, unexplained. On that sweep, 11 of 12 drilled repos landed
|
|
201
|
+
* between 0.99 and 1.08 but one came back at 1.236 -- all Linux, no unpriced
|
|
202
|
+
* or self-hosted minutes, so it is a minute count that is too high rather than
|
|
203
|
+
* a wrong rate. The two worst ratios also had the shortest average jobs (1.51
|
|
204
|
+
* and 1.33 min), which is what per-job round-up would do, but the correlation
|
|
205
|
+
* is not clean -- a third repo averaged 1.6 min and reconciled at 1.010 -- so
|
|
206
|
+
* rounding is a hypothesis, not a finding. Do NOT widen the tolerance to make
|
|
207
|
+
* it fit. The gate is doing its job: a repo that does not reconcile is
|
|
208
|
+
* excluded from every cost rule until someone works out where the minutes
|
|
209
|
+
* come from.
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* Reconcile every drilled repo, producing the rows `cost_reconciliation` holds.
|
|
213
|
+
*
|
|
214
|
+
* Pure so the gate can be tested without a database.
|
|
215
|
+
*
|
|
216
|
+
* `ratesEstimated` voids trust on its own, independent of whether the two sides
|
|
217
|
+
* agree. The two checks fail for different reasons and neither subsumes the
|
|
218
|
+
* other: the ratio catches a wrong SET of runs, the flag catches a wrong RATE.
|
|
219
|
+
* A rate error smaller than the tolerance would pass the ratio check while
|
|
220
|
+
* being wrong everywhere, so the flag is not redundant. When this actually
|
|
221
|
+
* happened on 2026-09-21 both fired -- list prices put every repo at 1.32-1.43x
|
|
222
|
+
* its invoice -- but that was luck about the size of the error, not a property
|
|
223
|
+
* of the check.
|
|
224
|
+
*/
|
|
225
|
+
export function reconcileRepos({ billingItems = [], runs = [], drilled = [], ratesEstimated = false } = {}) {
|
|
226
|
+
const billed = new Map();
|
|
227
|
+
for (const i of billingItems) {
|
|
228
|
+
if (i.product !== 'actions' || i.sku === 'Actions storage' || !i.repo) continue;
|
|
229
|
+
const cur = billed.get(i.repo) ?? { gross: 0, net: 0 };
|
|
230
|
+
cur.gross += i.gross ?? 0;
|
|
231
|
+
cur.net += i.net ?? 0;
|
|
232
|
+
billed.set(i.repo, cur);
|
|
233
|
+
}
|
|
234
|
+
const computed = new Map();
|
|
235
|
+
for (const r of runs) {
|
|
236
|
+
computed.set(r.repo, (computed.get(r.repo) ?? 0) + (r.cost_usd ?? 0));
|
|
237
|
+
}
|
|
238
|
+
return drilled.map(repo => {
|
|
239
|
+
const b = billed.get(repo) ?? { gross: 0, net: 0 };
|
|
240
|
+
const c = computed.get(repo) ?? 0;
|
|
241
|
+
const rec = reconcile(c, b.gross);
|
|
242
|
+
return {
|
|
243
|
+
repo,
|
|
244
|
+
computed_usd: Number(c.toFixed(4)),
|
|
245
|
+
billed_gross_usd: Number(b.gross.toFixed(4)),
|
|
246
|
+
billed_net_usd: Number(b.net.toFixed(4)),
|
|
247
|
+
ratio: rec.ratio,
|
|
248
|
+
trustworthy: (rec.trustworthy && !ratesEstimated) ? 1 : 0,
|
|
249
|
+
reason: ratesEstimated ? 'rates estimated from list prices - not measured' : rec.reason,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function reconcile(computedUsd, billedGrossUsd, tolerance = 0.15) {
|
|
255
|
+
const billed = Number(billedGrossUsd ?? 0);
|
|
256
|
+
const computed = Number(computedUsd ?? 0);
|
|
257
|
+
if (!(billed > 0)) {
|
|
258
|
+
// No invoice line to check against. Never claim agreement we cannot show.
|
|
259
|
+
return { ratio: null, delta_usd: null, trustworthy: false, reason: 'no billing line' };
|
|
260
|
+
}
|
|
261
|
+
const ratio = computed / billed;
|
|
262
|
+
const ok = Math.abs(1 - ratio) <= tolerance;
|
|
263
|
+
return {
|
|
264
|
+
ratio: Number(ratio.toFixed(4)),
|
|
265
|
+
delta_usd: Number((computed - billed).toFixed(4)),
|
|
266
|
+
trustworthy: ok,
|
|
267
|
+
reason: ok ? 'within tolerance'
|
|
268
|
+
: ratio < 1 ? 'computed below billed - window misses runs that were charged'
|
|
269
|
+
: 'computed above billed - double counting or a rate that is too high',
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The one shape an error leaves this tool in, for the CLI and the MCP server.
|
|
2
|
+
//
|
|
3
|
+
// { code, message, hint, retryable, cause? }
|
|
4
|
+
//
|
|
5
|
+
// A message says what happened; a hint says what to do about it. A stack trace
|
|
6
|
+
// is for the person debugging the tool, not the person using it, so it is
|
|
7
|
+
// printed only on request (--debug).
|
|
8
|
+
//
|
|
9
|
+
// Exit codes:
|
|
10
|
+
// 0 ok
|
|
11
|
+
// 1 user error the command, its arguments or the config file are wrong
|
|
12
|
+
// 2 runtime error GitHub, the database or the disk failed; retry or repair
|
|
13
|
+
// 3 partial the command finished, and part of what it was asked for
|
|
14
|
+
// is missing (a sweep with collection errors, or a cost
|
|
15
|
+
// pass that ran out of budget)
|
|
16
|
+
export const EXIT = Object.freeze({ OK: 0, USER: 1, RUNTIME: 2, PARTIAL: 3 });
|
|
17
|
+
|
|
18
|
+
export class HkError extends Error {
|
|
19
|
+
constructor(code, message, { hint = null, cause, retryable = false, exit = EXIT.RUNTIME } = {}) {
|
|
20
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
21
|
+
this.name = 'HkError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.hint = hint;
|
|
24
|
+
this.retryable = retryable;
|
|
25
|
+
this.exit = exit;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A mistake in how the tool was invoked or configured. Never retryable as-is. */
|
|
30
|
+
export const userError = (code, message, hint = null) =>
|
|
31
|
+
new HkError(code, message, { hint, exit: EXIT.USER });
|
|
32
|
+
|
|
33
|
+
/** Any thrown value as the structured shape. Pure. */
|
|
34
|
+
export function structured(e) {
|
|
35
|
+
if (e instanceof HkError) {
|
|
36
|
+
const out = { code: e.code, message: e.message, hint: e.hint, retryable: e.retryable };
|
|
37
|
+
if (e.cause !== undefined) out.cause = String(e.cause?.message ?? e.cause);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
// gh.mjs throws its own class for throttling; it predates this module and
|
|
41
|
+
// carries the facts a hint needs.
|
|
42
|
+
if (e?.isRateLimit) {
|
|
43
|
+
return {
|
|
44
|
+
code: 'RATE_LIMITED',
|
|
45
|
+
message: e.message,
|
|
46
|
+
hint: e.kind === 'primary'
|
|
47
|
+
? 'the hourly quota is spent; wait for it to reset'
|
|
48
|
+
: 'a burst limit, not the quota; retry in a few minutes with fewer concurrent callers',
|
|
49
|
+
retryable: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
code: 'UNEXPECTED',
|
|
54
|
+
message: String(e?.message ?? e),
|
|
55
|
+
hint: 're-run with --debug for the stack trace',
|
|
56
|
+
retryable: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The process exit code for a thrown value. Anything unclassified is a runtime error. */
|
|
61
|
+
export function exitCodeFor(e) {
|
|
62
|
+
return e instanceof HkError ? e.exit : EXIT.RUNTIME;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Lines for stderr. The stack appears only with `debug`. Pure. */
|
|
66
|
+
export function formatError(e, { debug = false } = {}) {
|
|
67
|
+
const s = structured(e);
|
|
68
|
+
const lines = [`error [${s.code}] ${s.message}`];
|
|
69
|
+
if (s.cause) lines.push(`cause: ${s.cause}`);
|
|
70
|
+
if (s.hint) lines.push(`hint: ${s.hint}`);
|
|
71
|
+
if (s.retryable) lines.push('retryable: yes');
|
|
72
|
+
if (debug && e?.stack) lines.push('', e.stack);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Admit one read-only statement, or refuse. Returns the statement to run.
|
|
78
|
+
* Shared by `hk sql` and the MCP `hk_sql` tool so the two cannot disagree
|
|
79
|
+
* about what "read-only" means.
|
|
80
|
+
*/
|
|
81
|
+
export function readOnlyQuery(query) {
|
|
82
|
+
const q = String(query ?? '').trim().replace(/;\s*$/, '');
|
|
83
|
+
if (!q) throw userError('USAGE', 'no query given', 'hk sql "SELECT ..."');
|
|
84
|
+
if (!/^(select|with)\b/i.test(q)) {
|
|
85
|
+
throw userError('SQL_REFUSED', 'only SELECT and WITH statements are allowed',
|
|
86
|
+
'the warehouse is derived data; change it by loading a snapshot, not by SQL');
|
|
87
|
+
}
|
|
88
|
+
if (/;/.test(q)) throw userError('SQL_REFUSED', 'multiple statements are not allowed', 'send one statement');
|
|
89
|
+
return q;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Did a sweep finish with part of its work missing? Returns the reasons, which
|
|
94
|
+
* is empty for a complete sweep. Pure.
|
|
95
|
+
*/
|
|
96
|
+
export function sweepGaps(snapshot) {
|
|
97
|
+
const gaps = [];
|
|
98
|
+
const errors = snapshot?.errors?.length ?? 0;
|
|
99
|
+
if (errors) gaps.push(`${errors} collection error(s) recorded`);
|
|
100
|
+
if (snapshot?.run_costs?.budget_hit) gaps.push('the cost pass ran out of budget; cost attribution is incomplete');
|
|
101
|
+
return gaps;
|
|
102
|
+
}
|