@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/gh.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Thin transport over the already-authenticated `gh` CLI.
|
|
2
|
+
// Deliberately NOT a new MCP server: `gh` already holds the org-scoped token,
|
|
3
|
+
// so sourcing it avoids minting/storing any new credential in this repo.
|
|
4
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { verbose } from './log.mjs';
|
|
10
|
+
|
|
11
|
+
const pexec = promisify(execFile);
|
|
12
|
+
const TMP = mkdtempSync(join(tmpdir(), 'hk-'));
|
|
13
|
+
let GH = null;
|
|
14
|
+
|
|
15
|
+
export function resolveGh() {
|
|
16
|
+
if (GH) return GH;
|
|
17
|
+
if (process.env.GH_PATH) return (GH = process.env.GH_PATH);
|
|
18
|
+
try {
|
|
19
|
+
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', ['gh'], {
|
|
20
|
+
encoding: 'utf8', shell: true,
|
|
21
|
+
});
|
|
22
|
+
GH = out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)[0];
|
|
23
|
+
} catch { GH = 'gh'; }
|
|
24
|
+
return GH;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_BUF = 64 * 1024 * 1024;
|
|
28
|
+
let seq = 0;
|
|
29
|
+
|
|
30
|
+
// ---- rate limiting ------------------------------------------------------
|
|
31
|
+
//
|
|
32
|
+
// GitHub enforces two limits and they fail very differently.
|
|
33
|
+
//
|
|
34
|
+
// PRIMARY a per-account hourly quota. Visible in `rate_limit`, resets on a
|
|
35
|
+
// known clock. Waiting it out can mean an hour, so we surface it
|
|
36
|
+
// rather than sleep.
|
|
37
|
+
// SECONDARY burst/concurrency. NOT visible in `rate_limit` -- on 2026-09-17 a
|
|
38
|
+
// sweep took a hard 403 "API rate limit exceeded" while that
|
|
39
|
+
// endpoint still reported core 5000/5000. Short-lived, so we back
|
|
40
|
+
// off and retry.
|
|
41
|
+
//
|
|
42
|
+
// The message text does not reliably distinguish them: the secondary limit also
|
|
43
|
+
// says "API rate limit exceeded". So the quota is what disambiguates -- if the
|
|
44
|
+
// quota is healthy and we were refused anyway, it was secondary.
|
|
45
|
+
//
|
|
46
|
+
// The failure that actually costs you is neither of these: it is a throttled
|
|
47
|
+
// call being read as an empty result. A 403 misread as "this repo has no
|
|
48
|
+
// dependabot.yml" inverts a finding. Every path here THROWS rather than
|
|
49
|
+
// returning a value a caller could mistake for data.
|
|
50
|
+
|
|
51
|
+
const RATE_LIMIT_TEXT = /rate limit|abuse detection|secondary rate|too many requests/i;
|
|
52
|
+
const RETRY_AFTER = /retry-after:\s*(\d+)/i;
|
|
53
|
+
|
|
54
|
+
export class RateLimitError extends Error {
|
|
55
|
+
constructor(kind, waitMs, detail) {
|
|
56
|
+
super(`GitHub ${kind} rate limit hit${waitMs ? `; retry in ${Math.ceil(waitMs / 1000)}s` : ''}${detail ? ` -- ${detail}` : ''}`);
|
|
57
|
+
this.name = 'RateLimitError';
|
|
58
|
+
this.kind = kind; // 'primary' | 'secondary'
|
|
59
|
+
this.waitMs = waitMs;
|
|
60
|
+
this.isRateLimit = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Does this failure look like throttling? Pure, so it is unit-testable. */
|
|
65
|
+
export function looksRateLimited(text) {
|
|
66
|
+
return RATE_LIMIT_TEXT.test(String(text ?? ''));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A gateway error is GitHub failing, not GitHub answering: nothing was read, so
|
|
70
|
+
// a retry cannot double-count anything, and every call here is a read. Two
|
|
71
|
+
// sweeps on 2026-09-30 each died on a lone "gh: HTTP 502" at a different page,
|
|
72
|
+
// and a third on "unexpected end of JSON input" -- gh's wording for a body cut
|
|
73
|
+
// off mid-stream, the same server-side timeout arriving by a different road.
|
|
74
|
+
const TRANSIENT_TEXT = /HTTP 50[234]\b|unexpected end of JSON input/;
|
|
75
|
+
|
|
76
|
+
/** Does this failure look like a transient gateway error? Pure. */
|
|
77
|
+
export function looksTransient(text) {
|
|
78
|
+
return TRANSIENT_TEXT.test(String(text ?? ''));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Exponential backoff with jitter. Pure. */
|
|
82
|
+
export function backoffMs(attempt, base = 2000, cap = 60000) {
|
|
83
|
+
const exp = Math.min(cap, base * 2 ** attempt);
|
|
84
|
+
return Math.round(exp / 2 + Math.random() * (exp / 2));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
88
|
+
|
|
89
|
+
/** Current quota. `rate_limit` is explicitly exempt, so this is free to call. */
|
|
90
|
+
export async function budget() {
|
|
91
|
+
const { stdout } = await pexec(resolveGh(), ['api', 'rate_limit'], {
|
|
92
|
+
maxBuffer: 1 << 20, windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
const d = JSON.parse(stdout).resources;
|
|
95
|
+
return { core: d.core, graphql: d.graphql, checkedAt: Date.now() };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refuse to start a sweep that the remaining quota cannot finish.
|
|
100
|
+
* A full collect costs roughly 90 GraphQL points and 80+ REST calls; running it
|
|
101
|
+
* at low headroom produces a half-collected snapshot, which is worse than none
|
|
102
|
+
* because `load.mjs` will happily store it.
|
|
103
|
+
*/
|
|
104
|
+
export async function preflight({ rest: needRest = 200, graphql: needGraphql = 200 } = {}) {
|
|
105
|
+
const b = await budget().catch(() => null);
|
|
106
|
+
if (!b) return { ok: true, unknown: true }; // never block on an unreadable probe
|
|
107
|
+
|
|
108
|
+
const quotaOk = b.core.remaining >= needRest && b.graphql.remaining >= needGraphql;
|
|
109
|
+
const out = {
|
|
110
|
+
ok: quotaOk,
|
|
111
|
+
unknown: false,
|
|
112
|
+
limit: quotaOk ? null : 'primary',
|
|
113
|
+
core: b.core.remaining,
|
|
114
|
+
graphql: b.graphql.remaining,
|
|
115
|
+
resetsInS: Math.max(0, Math.round(b.core.reset - Date.now() / 1000)),
|
|
116
|
+
};
|
|
117
|
+
if (!quotaOk) return out;
|
|
118
|
+
|
|
119
|
+
// The quota says nothing about the SECONDARY limit, which is the one that
|
|
120
|
+
// actually stops a sweep. Verified live on 2026-09-17: this function returned
|
|
121
|
+
// core 5000/5000 while the very next real request came back 403 "API rate
|
|
122
|
+
// limit exceeded". A quota check alone would have waved the sweep into a wall.
|
|
123
|
+
//
|
|
124
|
+
// Only a real request can detect it, so spend exactly one. `rate_limit` is
|
|
125
|
+
// exempt and therefore useless as a canary; `user` is the cheapest endpoint
|
|
126
|
+
// that is not.
|
|
127
|
+
try {
|
|
128
|
+
await pexec(resolveGh(), ['api', 'user', '--jq', '.login'], {
|
|
129
|
+
maxBuffer: 1 << 20, windowsHide: true,
|
|
130
|
+
});
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const text = `${e.stdout ?? ''}${e.stderr ?? ''}${e.message ?? ''}`;
|
|
133
|
+
if (looksRateLimited(text)) {
|
|
134
|
+
return { ...out, ok: false, limit: 'secondary' };
|
|
135
|
+
}
|
|
136
|
+
// Anything else (auth, network) is not ours to judge -- let the sweep try
|
|
137
|
+
// and fail with its own error rather than blocking on a guess.
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Run `gh` with retry on throttling. Primary exhaustion throws immediately
|
|
144
|
+
* (waiting could be an hour); secondary backs off and retries.
|
|
145
|
+
*/
|
|
146
|
+
async function ghExec(args, { retries = 4, maxBuffer = MAX_BUF } = {}) {
|
|
147
|
+
// The subcommand and its target only. A GraphQL body lives in a temp file
|
|
148
|
+
// and is never echoed; `gh` holds the token, so no argument here can be one.
|
|
149
|
+
verbose('[gh]', args.slice(0, 2).join(' '));
|
|
150
|
+
for (let attempt = 0; ; attempt++) {
|
|
151
|
+
try {
|
|
152
|
+
return await pexec(resolveGh(), args, { maxBuffer, windowsHide: true });
|
|
153
|
+
} catch (e) {
|
|
154
|
+
const text = `${e.stdout ?? ''}${e.stderr ?? ''}${e.message ?? ''}`;
|
|
155
|
+
if (looksTransient(text) && attempt < retries) {
|
|
156
|
+
await sleep(backoffMs(attempt));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!looksRateLimited(text)) throw e;
|
|
160
|
+
|
|
161
|
+
const b = await budget().catch(() => null);
|
|
162
|
+
if (b && b.core.remaining < 50) {
|
|
163
|
+
const waitMs = Math.max(0, b.core.reset * 1000 - Date.now());
|
|
164
|
+
throw new RateLimitError('primary', waitMs, `core ${b.core.remaining}/${b.core.limit}`);
|
|
165
|
+
}
|
|
166
|
+
if (attempt >= retries) {
|
|
167
|
+
throw new RateLimitError('secondary', 0, `gave up after ${retries + 1} attempts`);
|
|
168
|
+
}
|
|
169
|
+
const hinted = RETRY_AFTER.exec(text);
|
|
170
|
+
await sleep(hinted ? Number(hinted[1]) * 1000 : backoffMs(attempt));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
/** POST a GraphQL document. Body goes through a temp file so query size is unbounded. */
|
|
177
|
+
export async function graphql(query, variables = {}) {
|
|
178
|
+
const file = join(TMP, `q${seq++}.json`);
|
|
179
|
+
writeFileSync(file, JSON.stringify({ query, variables }));
|
|
180
|
+
const { stdout } = await ghExec(['api', 'graphql', '--input', file]);
|
|
181
|
+
const body = JSON.parse(stdout);
|
|
182
|
+
// GraphQL returns 200 with partial data + errors. Surface them, keep the data.
|
|
183
|
+
if (body.errors?.length) {
|
|
184
|
+
const fatal = !body.data;
|
|
185
|
+
const msg = body.errors.map(e => e.message).join('; ');
|
|
186
|
+
if (fatal) throw new Error(`GraphQL: ${msg}`);
|
|
187
|
+
body._softErrors = msg;
|
|
188
|
+
}
|
|
189
|
+
return body;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function rest(path) {
|
|
193
|
+
const { stdout } = await ghExec(['api', path, '--cache', '0']);
|
|
194
|
+
return JSON.parse(stdout);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Paginated GET. `gh api --paginate` merges top-level JSON arrays, which is
|
|
198
|
+
// what every endpoint we page over returns. Read-only, like rest().
|
|
199
|
+
export async function restPaged(path) {
|
|
200
|
+
const { stdout } = await ghExec(['api', path, '--paginate', '--cache', '0']);
|
|
201
|
+
return JSON.parse(stdout);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Some GitHub endpoints answer with a bare 204 or 404 and no body, so neither
|
|
205
|
+
// rest() nor restPaged() can read them: JSON.parse('') throws either way, which
|
|
206
|
+
// makes "enabled" and "disabled" indistinguishable. Return the status instead.
|
|
207
|
+
export async function restStatus(path) {
|
|
208
|
+
try {
|
|
209
|
+
const { stdout } = await ghExec(['api', path, '-i', '--cache', '0']);
|
|
210
|
+
const m = /^HTTP\/[\d.]+ (\d{3})/m.exec(stdout);
|
|
211
|
+
return m ? Number(m[1]) : null;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// A throttled probe is NOT a 404. Returning null here would tell the caller
|
|
214
|
+
// "this endpoint says disabled", which is how a rate limit becomes a wrong
|
|
215
|
+
// finding. Let it propagate.
|
|
216
|
+
if (e.isRateLimit) throw e;
|
|
217
|
+
const m = /^HTTP\/[\d.]+ (\d{3})/m.exec(String(e.stdout ?? '') + String(e.stderr ?? ''));
|
|
218
|
+
return m ? Number(m[1]) : null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Split `gh api -i` output into its status and its JSON body. Pure.
|
|
224
|
+
*
|
|
225
|
+
* `-i` prints the status line, the headers, a blank line, then the body; on a
|
|
226
|
+
* non-2xx answer gh also appends its own "gh: Not Found (HTTP 404)" line after
|
|
227
|
+
* the body, which is not part of the JSON. The body is parsed up to its last
|
|
228
|
+
* closing brace or bracket so that line is dropped. A body that does not parse
|
|
229
|
+
* comes back as null beside its status: the status is still an answer.
|
|
230
|
+
*/
|
|
231
|
+
export function parseIncluded(text) {
|
|
232
|
+
const s = String(text ?? '');
|
|
233
|
+
const m = /^HTTP\/[\d.]+ (\d{3})/m.exec(s);
|
|
234
|
+
if (!m) return { status: null, body: null };
|
|
235
|
+
const split = /\r?\n\r?\n/.exec(s.slice(m.index));
|
|
236
|
+
let body = null;
|
|
237
|
+
if (split) {
|
|
238
|
+
const rest = s.slice(m.index + split.index + split[0].length);
|
|
239
|
+
const end = Math.max(rest.lastIndexOf('}'), rest.lastIndexOf(']'));
|
|
240
|
+
if (end >= 0) {
|
|
241
|
+
try { body = JSON.parse(rest.slice(0, end + 1)); } catch { body = null; }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { status: Number(m[1]), body };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* GET that answers with its status and body instead of throwing on a non-2xx.
|
|
249
|
+
*
|
|
250
|
+
* Needed where a 404 IS the observation: `repos/{r}/pages` answers 404 when
|
|
251
|
+
* Pages is off, which rest() cannot tell from a missing repository or a
|
|
252
|
+
* permissions problem. The caller decides what each status means; a status of
|
|
253
|
+
* null (no HTTP answer at all) must be read as unknown. Throttling still throws,
|
|
254
|
+
* for the reason restStatus() gives.
|
|
255
|
+
*/
|
|
256
|
+
export async function restWithStatus(path) {
|
|
257
|
+
try {
|
|
258
|
+
const { stdout } = await ghExec(['api', path, '-i', '--cache', '0']);
|
|
259
|
+
return parseIncluded(stdout);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (e.isRateLimit) throw e;
|
|
262
|
+
return parseIncluded(String(e.stdout ?? '') + String(e.stderr ?? ''));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function whoami() {
|
|
267
|
+
try {
|
|
268
|
+
return execFileSync(resolveGh(), ['api', 'user', '--jq', '.login'], { encoding: 'utf8' }).trim();
|
|
269
|
+
} catch { return 'unknown'; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Bounded-concurrency map that never rejects: failures come back as {error}. */
|
|
273
|
+
export async function pMap(items, fn, concurrency = 8) {
|
|
274
|
+
const out = new Array(items.length);
|
|
275
|
+
let i = 0;
|
|
276
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
277
|
+
while (true) {
|
|
278
|
+
const idx = i++;
|
|
279
|
+
if (idx >= items.length) return;
|
|
280
|
+
try { out[idx] = { ok: true, value: await fn(items[idx], idx) }; }
|
|
281
|
+
catch (e) { out[idx] = { ok: false, error: e.message?.slice(0, 400) ?? String(e) }; }
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
await Promise.all(workers);
|
|
285
|
+
return out;
|
|
286
|
+
}
|