@mcptoolshop/housekeeping 1.4.0 → 1.5.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 +30 -0
- package/README.es.md +125 -78
- package/README.fr.md +119 -114
- package/README.hi.md +74 -78
- package/README.it.md +117 -75
- package/README.ja.md +82 -79
- package/README.md +2 -0
- package/README.pt-BR.md +87 -69
- package/README.zh.md +72 -89
- package/housekeeping.config.example.json +2 -1
- package/package.json +1 -1
- package/src/cli.mjs +85 -2
- package/src/collect.mjs +74 -4
- package/src/config.mjs +13 -4
- package/src/load.mjs +55 -3
- package/src/schema.sql +26 -0
- package/src/workflow-risks.mjs +35 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcptoolshop/housekeeping",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Operational-health warehouse for a GitHub organization: CI, issues, PRs, releases, Actions cost and repo hygiene, collected into SQLite and audited against written rules.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github",
|
package/src/cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { openDb, loadSnapshot, latestSnapshotFile, DB_PATH } from './load.mjs';
|
|
|
9
9
|
import { analyze, healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED } from './analyze.mjs';
|
|
10
10
|
import { collect, DRILL_BUDGET } from './collect.mjs';
|
|
11
11
|
import { preflight } from './gh.mjs';
|
|
12
|
-
import { resolveOrg } from './config.mjs';
|
|
12
|
+
import { resolveOrg, loadConfig } from './config.mjs';
|
|
13
13
|
import { writeReport } from './report.mjs';
|
|
14
14
|
import { EXIT, HkError, userError, exitCodeFor, formatError, readOnlyQuery, sweepGaps } from './errors.mjs';
|
|
15
15
|
import { info, isDebug, levelFromFlags, parseLevel, setLevel } from './log.mjs';
|
|
@@ -141,6 +141,35 @@ const REPORTS = {
|
|
|
141
141
|
disk_usage_kb kb, days_since_push push_age, open_issues iss, open_prs prs,
|
|
142
142
|
release_count rels, ci_rollup, workflow_count wfs
|
|
143
143
|
FROM repo WHERE snapshot_id=? ORDER BY name`).all(sid), { max: 200 }),
|
|
144
|
+
// Branches by repo: what a cleanup would delete (merged), what it must not
|
|
145
|
+
// (merged_moved: commits after the merge), and what has no PR at all.
|
|
146
|
+
branches: (sid, repo) => {
|
|
147
|
+
if (!db.prepare('SELECT 1 FROM repo_branches WHERE snapshot_id=? LIMIT 1').get(sid)) {
|
|
148
|
+
return console.log('no branch data in this snapshot (collector 1.6.0 or later) - run `hk refresh`');
|
|
149
|
+
}
|
|
150
|
+
if (repo) {
|
|
151
|
+
return table(db.prepare(`SELECT name, state, age_days age, pr_number pr, SUBSTR(head_oid,1,7) head
|
|
152
|
+
FROM branch WHERE snapshot_id=? AND repo=? ORDER BY state, age_days DESC`).all(sid, repo), { max: 500 });
|
|
153
|
+
}
|
|
154
|
+
table(db.prepare(`SELECT s.repo, CASE s.delete_on_merge WHEN 1 THEN 'on' WHEN 0 THEN 'off' END auto_delete,
|
|
155
|
+
s.branch_count branches,
|
|
156
|
+
SUM(b.state='merged') merged, SUM(b.state='merged_moved') moved,
|
|
157
|
+
SUM(b.state='closed') closed, SUM(b.state='no_pr' AND b.age_days>=${STALE_BRANCH_DAYS}) no_pr_stale,
|
|
158
|
+
SUM(b.state IN ('open_pr','open_base')) open
|
|
159
|
+
FROM repo_branches s LEFT JOIN branch b ON b.snapshot_id=s.snapshot_id AND b.repo=s.repo
|
|
160
|
+
WHERE s.snapshot_id=? GROUP BY s.repo HAVING s.branch_count > 1
|
|
161
|
+
ORDER BY merged + moved + closed + no_pr_stale DESC, s.repo`).all(sid), { max: 200 });
|
|
162
|
+
},
|
|
163
|
+
watch: (sid, ...names) => {
|
|
164
|
+
const repos = names.length ? names : loadConfig().watch;
|
|
165
|
+
if (!repos.length) {
|
|
166
|
+
throw userError('USAGE', 'no repositories to watch',
|
|
167
|
+
'name them (hk watch <repo> ...) or set "watch" in housekeeping.config.json');
|
|
168
|
+
}
|
|
169
|
+
const s = db.prepare('SELECT taken_at, collector_version FROM snapshot WHERE id=?').get(sid);
|
|
170
|
+
console.log(`watch: ${repos.join(', ')}\nsnapshot ${sid}, taken ${s.taken_at} (collector ${s.collector_version})`);
|
|
171
|
+
for (const name of repos) watchRepo(sid, name);
|
|
172
|
+
},
|
|
144
173
|
repo: (sid, name) => {
|
|
145
174
|
if (!name) throw userError('USAGE', '`hk repo` needs a repository name', 'hk repo <name>');
|
|
146
175
|
table(db.prepare('SELECT * FROM repo WHERE snapshot_id=? AND name=?').all(sid, name));
|
|
@@ -155,6 +184,56 @@ const REPORTS = {
|
|
|
155
184
|
|
|
156
185
|
function latestOr(sid) { return sid; }
|
|
157
186
|
|
|
187
|
+
// A branch with no PR and no commit for this long is listed as stale. The
|
|
188
|
+
// number is the Publisher's sweep brief of 2026-10-11, not an org rule.
|
|
189
|
+
const STALE_BRANCH_DAYS = 7;
|
|
190
|
+
|
|
191
|
+
// One watched repo: open PRs by idle time, open alerts, and the branches a
|
|
192
|
+
// cleanup would touch. Every section says when it was not measured, because
|
|
193
|
+
// an empty list from an unscanned repo reads exactly like a clean one.
|
|
194
|
+
function watchRepo(sid, name) {
|
|
195
|
+
const r = db.prepare('SELECT is_archived FROM repo WHERE snapshot_id=? AND name=?').get(sid, name);
|
|
196
|
+
console.log(`\n== ${name}`);
|
|
197
|
+
if (!r) return console.log('not in this snapshot (misspelt, or not in the org)');
|
|
198
|
+
if (r.is_archived) console.log('archived');
|
|
199
|
+
|
|
200
|
+
const prs = db.prepare(`SELECT number, stale_days idle, age_days age, is_draft draft, mergeable,
|
|
201
|
+
base_ref base, head_ref head, SUBSTR(title,1,60) title
|
|
202
|
+
FROM pull_request WHERE snapshot_id=? AND repo=? ORDER BY stale_days DESC, number`).all(sid, name);
|
|
203
|
+
console.log(`\n-- open PRs (${prs.length}), longest idle first --`);
|
|
204
|
+
table(prs);
|
|
205
|
+
|
|
206
|
+
const sec = db.prepare('SELECT vuln_alerts FROM repo_security WHERE snapshot_id=? AND repo=?').get(sid, name);
|
|
207
|
+
console.log('\n-- open Dependabot alerts --');
|
|
208
|
+
if (!sec) console.log('not measured (the security pass did not read this repo)');
|
|
209
|
+
else if (sec.vuln_alerts === 0) console.log('NOT SCANNED: vulnerability alerts are off, so no alert can appear');
|
|
210
|
+
else {
|
|
211
|
+
if (sec.vuln_alerts === null) console.log('(whether alerts are on could not be read)');
|
|
212
|
+
table(db.prepare(`SELECT number, severity, package, manifest, fixed_version fix, age_days age, ghsa
|
|
213
|
+
FROM security_alert WHERE snapshot_id=? AND repo=?
|
|
214
|
+
ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, number`)
|
|
215
|
+
.all(sid, name));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const set = db.prepare('SELECT delete_on_merge, branch_count FROM repo_branches WHERE snapshot_id=? AND repo=?').get(sid, name);
|
|
219
|
+
console.log('\n-- branches --');
|
|
220
|
+
if (!set) return console.log('not measured (no branch data for this repo in this snapshot)');
|
|
221
|
+
const auto = set.delete_on_merge === 1 ? 'on' : set.delete_on_merge === 0 ? 'off' : 'unknown';
|
|
222
|
+
const rows = db.prepare(`SELECT name, state, age_days age, pr_number pr, SUBSTR(head_oid,1,7) head
|
|
223
|
+
FROM branch WHERE snapshot_id=? AND repo=? ORDER BY name`).all(sid, name);
|
|
224
|
+
const of = st => rows.filter(b => b.state === st);
|
|
225
|
+
const stale = of('no_pr').filter(b => b.age_days >= STALE_BRANCH_DAYS);
|
|
226
|
+
console.log(`${set.branch_count} branches; auto-delete head branches: ${auto}; ` +
|
|
227
|
+
`open ${of('open_pr').length + of('open_base').length}, merged ${of('merged').length}, ` +
|
|
228
|
+
`moved after merge ${of('merged_moved').length}, closed ${of('closed').length}, ` +
|
|
229
|
+
`no PR ${of('no_pr').length} (${stale.length} idle ${STALE_BRANCH_DAYS}+ days)`);
|
|
230
|
+
const list = (label, bs) => { if (bs.length) { console.log(`\n${label}:`); table(bs); } };
|
|
231
|
+
list('merged, tip = merged head (deletable; restorable from the PR page)', of('merged'));
|
|
232
|
+
list('MOVED after merge (commits exist only here; do not delete unconfirmed)', of('merged_moved'));
|
|
233
|
+
list('closed without merging', of('closed'));
|
|
234
|
+
list(`no PR, idle ${STALE_BRANCH_DAYS}+ days`, stale);
|
|
235
|
+
}
|
|
236
|
+
|
|
158
237
|
// ---- commands ---------------------------------------------------------------
|
|
159
238
|
// One table drives both the dispatch and `hk help`, so the help text cannot
|
|
160
239
|
// describe a command that does not exist or omit one that does.
|
|
@@ -178,7 +257,9 @@ async function refresh(args) {
|
|
|
178
257
|
// silently.
|
|
179
258
|
const pre = await preflight({
|
|
180
259
|
rest: 300 + Math.min(DRILL_BUDGET, 900),
|
|
181
|
-
|
|
260
|
+
// ~195 for the repo pages and workflow files, ~1 per repo for the
|
|
261
|
+
// branch pass (collector 1.6.0, measured 2026-10-11), with headroom.
|
|
262
|
+
graphql: 450,
|
|
182
263
|
});
|
|
183
264
|
if (!pre.ok && !force) {
|
|
184
265
|
if (pre.limit === 'secondary') {
|
|
@@ -265,6 +346,8 @@ const COMMANDS = {
|
|
|
265
346
|
versions: ['', 'package.json against git tag against npm', viaReport('versions')],
|
|
266
347
|
prs: ['', 'every open pull request by age', viaReport('prs')],
|
|
267
348
|
issues: ['', 'every open issue by staleness', viaReport('issues')],
|
|
349
|
+
branches: ['[repo]', 'branches by state: merged, moved after merge, closed, no PR', viaReport('branches')],
|
|
350
|
+
watch: ['[repo ...]', 'open PRs, alerts and branches for the watch list in the config', viaReport('watch')],
|
|
268
351
|
snapshots: ['', 'the snapshots loaded into the database', () => {
|
|
269
352
|
db = openDb();
|
|
270
353
|
table(db.prepare('SELECT id, taken_at, org, repo_count, duration_ms FROM snapshot ORDER BY id').all());
|
package/src/collect.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { priceRun, ratesFromUsage, withFallback, runnerClass } from './cost.mjs'
|
|
|
14
14
|
import { pagesDeployJobs, jobEnvironment } from './workflow-risks.mjs';
|
|
15
15
|
import { DATA_DIR } from './paths.mjs';
|
|
16
16
|
|
|
17
|
-
export const COLLECTOR_VERSION = '1.
|
|
17
|
+
export const COLLECTOR_VERSION = '1.6.0';
|
|
18
18
|
|
|
19
19
|
const REPO_FIELDS = `
|
|
20
20
|
id name description url homepageUrl
|
|
@@ -622,7 +622,7 @@ function readAtlasCache() {
|
|
|
622
622
|
*/
|
|
623
623
|
async function atlasFleetVersion(fetchImpl = fetch) {
|
|
624
624
|
try {
|
|
625
|
-
const res = await fetchImpl(`https://registry.npmjs.org/${ATLAS_PACKAGE
|
|
625
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(ATLAS_PACKAGE)}`, {
|
|
626
626
|
headers: { accept: 'application/vnd.npm.install-v1+json' }, signal: AbortSignal.timeout(30_000),
|
|
627
627
|
});
|
|
628
628
|
if (!res.ok) return { version: null, source: 'npm dist-tags latest', error: `http_${res.status}` };
|
|
@@ -694,7 +694,7 @@ async function collectNpm(repos, concurrency = 12) {
|
|
|
694
694
|
.filter(x => x.pkg && x.pkg.name && !x.pkg.private);
|
|
695
695
|
|
|
696
696
|
const results = await pMap(named, async x => {
|
|
697
|
-
const res = await fetch(`https://registry.npmjs.org/${x.pkg.name
|
|
697
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(x.pkg.name)}`, {
|
|
698
698
|
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
699
699
|
});
|
|
700
700
|
if (res.status === 404) return { repo: x.repo, pkg_name: x.pkg.name, status: 'unpublished' };
|
|
@@ -817,6 +817,74 @@ async function collectSecurity(org, repos, concurrency = 10) {
|
|
|
817
817
|
return { ok: alertsOk, alerts, auto_security_fixes: fixes, vulnerability_alerts: scanned };
|
|
818
818
|
}
|
|
819
819
|
|
|
820
|
+
// Every branch, the pull requests it was the head of, and whether the repo
|
|
821
|
+
// deletes a head branch when its pull request merges.
|
|
822
|
+
//
|
|
823
|
+
// A merged PR's branch is restorable from the PR page only while its tip is
|
|
824
|
+
// the commit the PR merged; a branch pushed to after its merge carries work
|
|
825
|
+
// that exists nowhere else. So the head commit is kept beside each PR's
|
|
826
|
+
// headRefOid, and the loader tells the two apart.
|
|
827
|
+
//
|
|
828
|
+
// Cost: one GraphQL query per non-archived, non-empty repo, plus one per
|
|
829
|
+
// further 100 branches. Measured 2026-10-11 by rateLimit(dryRun): 1 point per
|
|
830
|
+
// repo at first:100 with five associated PRs per branch (8 repos: 8 points).
|
|
831
|
+
const BRANCH_QUERY = `
|
|
832
|
+
query($owner:String!, $name:String!, $cursor:String) {
|
|
833
|
+
repository(owner:$owner, name:$name) {
|
|
834
|
+
deleteBranchOnMerge
|
|
835
|
+
refs(refPrefix:"refs/heads/", first:100, after:$cursor) {
|
|
836
|
+
totalCount
|
|
837
|
+
pageInfo { hasNextPage endCursor }
|
|
838
|
+
nodes {
|
|
839
|
+
name
|
|
840
|
+
target { ... on Commit { oid committedDate } }
|
|
841
|
+
associatedPullRequests(first:5, orderBy:{field:UPDATED_AT, direction:DESC}) {
|
|
842
|
+
nodes { number state headRefOid }
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}`;
|
|
848
|
+
|
|
849
|
+
export async function collectBranches(org, repos, { query = graphql, concurrency = 6 } = {}) {
|
|
850
|
+
const out = {};
|
|
851
|
+
const errors = [];
|
|
852
|
+
const targets = repos.filter(r => !r.isArchived && !r.isEmpty);
|
|
853
|
+
await pMap(targets, async r => {
|
|
854
|
+
const branches = [];
|
|
855
|
+
let cursor = null, total = 0, deleteOnMerge = null;
|
|
856
|
+
try {
|
|
857
|
+
for (;;) {
|
|
858
|
+
const body = await query(BRANCH_QUERY, { owner: org, name: r.name, cursor });
|
|
859
|
+
const repo = body.data?.repository;
|
|
860
|
+
if (!repo) throw new Error('repository not visible');
|
|
861
|
+
deleteOnMerge = repo.deleteBranchOnMerge ?? null;
|
|
862
|
+
total = repo.refs?.totalCount ?? 0;
|
|
863
|
+
for (const b of repo.refs?.nodes ?? []) {
|
|
864
|
+
branches.push({
|
|
865
|
+
name: b.name,
|
|
866
|
+
oid: b.target?.oid ?? null,
|
|
867
|
+
committed_at: b.target?.committedDate ?? null,
|
|
868
|
+
prs: (b.associatedPullRequests?.nodes ?? []).map(p => ({
|
|
869
|
+
number: p.number, state: p.state, head_oid: p.headRefOid ?? null,
|
|
870
|
+
})),
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
cursor = repo.refs?.pageInfo?.hasNextPage ? repo.refs.pageInfo.endCursor : null;
|
|
874
|
+
if (!cursor) break;
|
|
875
|
+
}
|
|
876
|
+
out[r.name] = { delete_branch_on_merge: deleteOnMerge, total, branches };
|
|
877
|
+
} catch (e) {
|
|
878
|
+
// A repo that could not be read gets no record at all, never an empty
|
|
879
|
+
// one: no branches would read as "nothing to clean up".
|
|
880
|
+
errors.push({ repo: r.name, stage: 'branches', message: String(e.message ?? e).slice(0, 200) });
|
|
881
|
+
}
|
|
882
|
+
}, concurrency);
|
|
883
|
+
const n = Object.values(out).reduce((s, x) => s + x.branches.length, 0);
|
|
884
|
+
log(`branches: ${n} in ${Object.keys(out).length} repos, ${errors.length} unreadable`);
|
|
885
|
+
return { ok: errors.length === 0, repos: out, errors };
|
|
886
|
+
}
|
|
887
|
+
|
|
820
888
|
// ---------------------------------------------------------------------------
|
|
821
889
|
// Committed lockfiles, audited by the warehouse itself. See lockfile.mjs for
|
|
822
890
|
// why GitHub's own count cannot be trusted per manifest.
|
|
@@ -1097,6 +1165,7 @@ async function sweep(org, ctx) {
|
|
|
1097
1165
|
ctx.stage = 'deploy_settings'; const deploySettings = await collectDeploySettings(org, repos, workflowFiles);
|
|
1098
1166
|
ctx.stage = 'npm'; const npm = await collectNpm(repos);
|
|
1099
1167
|
ctx.stage = 'security'; const security = await collectSecurity(org, repos);
|
|
1168
|
+
ctx.stage = 'branches'; const branches = await collectBranches(org, repos);
|
|
1100
1169
|
ctx.stage = 'lockfiles'; const lockfiles = await collectLockfiles(org, repos);
|
|
1101
1170
|
ctx.stage = 'atlas_maps'; const atlasMaps = await collectAtlasMaps(org, repos);
|
|
1102
1171
|
ctx.stage = 'billing'; const billing = await collectBilling(org);
|
|
@@ -1121,13 +1190,14 @@ async function sweep(org, ctx) {
|
|
|
1121
1190
|
deploy_settings: { ok: deploySettings.ok, calls: deploySettings.calls, repos: deploySettings.repos },
|
|
1122
1191
|
npm,
|
|
1123
1192
|
security,
|
|
1193
|
+
branches: { ok: branches.ok, repos: branches.repos },
|
|
1124
1194
|
lockfiles,
|
|
1125
1195
|
// fetched/cached counts are left out: they describe this machine's cache,
|
|
1126
1196
|
// not the org, and would make two identical observations look different.
|
|
1127
1197
|
atlas_maps: { ok: atlasMaps.ok, fleet_version: atlasMaps.fleet_version, repos: atlasMaps.repos },
|
|
1128
1198
|
billing,
|
|
1129
1199
|
run_costs: runCosts,
|
|
1130
|
-
errors: errors.concat(deploySettings.errors ?? [], runCosts.errors ?? [],
|
|
1200
|
+
errors: errors.concat(deploySettings.errors ?? [], runCosts.errors ?? [], branches.errors,
|
|
1131
1201
|
failedSteps.runs.filter(x => x.error).map(x => ({ repo: x.repo, stage: 'failed_steps', message: x.error }))),
|
|
1132
1202
|
};
|
|
1133
1203
|
|
package/src/config.mjs
CHANGED
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
//
|
|
9
9
|
// housekeeping.config.json
|
|
10
10
|
// { "org": "your-org", "metaRepos": [".github", "design-assets"],
|
|
11
|
-
// "homeOrgs": ["your-org", "your-other-org"]
|
|
11
|
+
// "homeOrgs": ["your-org", "your-other-org"],
|
|
12
|
+
// "watch": ["repo-a", "repo-b"] }
|
|
12
13
|
//
|
|
13
14
|
// `homeOrgs` names the organizations whose projects count as the org's own.
|
|
14
15
|
// A fork of a project outside them is exempt from the map rule. Unset, it is
|
|
15
16
|
// the swept org alone.
|
|
16
17
|
//
|
|
18
|
+
// `watch` names the repos `hk watch` reports on when it is given none: a short
|
|
19
|
+
// list someone reviews every week. It narrows a view, never the sweep, and
|
|
20
|
+
// changes no finding.
|
|
21
|
+
//
|
|
17
22
|
// No network and no database here, so every branch is unit-testable.
|
|
18
23
|
import { readFileSync } from 'node:fs';
|
|
19
24
|
import { join } from 'node:path';
|
|
@@ -22,7 +27,7 @@ import { WORK_DIR } from './paths.mjs';
|
|
|
22
27
|
|
|
23
28
|
export const CONFIG_PATH = process.env.HK_CONFIG ?? join(WORK_DIR, 'housekeeping.config.json');
|
|
24
29
|
|
|
25
|
-
const KEYS = ['org', 'metaRepos', 'homeOrgs'];
|
|
30
|
+
const KEYS = ['org', 'metaRepos', 'homeOrgs', 'watch'];
|
|
26
31
|
const invalid = message => userError('CONFIG_INVALID', message,
|
|
27
32
|
'fix housekeeping.config.json; housekeeping.config.example.json shows the shape');
|
|
28
33
|
const DEFAULT_META = ['.github'];
|
|
@@ -56,7 +61,11 @@ export function parseConfig(text) {
|
|
|
56
61
|
if (home !== null && (!Array.isArray(home) || !home.length || home.some(o => typeof o !== 'string' || !o.trim()))) {
|
|
57
62
|
throw invalid('housekeeping config: "homeOrgs" must be a non-empty array of organization names');
|
|
58
63
|
}
|
|
59
|
-
|
|
64
|
+
const watch = raw.watch ?? [];
|
|
65
|
+
if (!Array.isArray(watch) || watch.some(w => typeof w !== 'string' || !w.trim())) {
|
|
66
|
+
throw invalid('housekeeping config: "watch" must be an array of repo names');
|
|
67
|
+
}
|
|
68
|
+
return { org, metaRepos: [...meta], homeOrgs: home ? [...home] : null, watch: [...watch] };
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
/** Read the config file. A missing file is the defaults; an unreadable or malformed one throws. */
|
|
@@ -64,7 +73,7 @@ export function loadConfig(path = CONFIG_PATH) {
|
|
|
64
73
|
let text;
|
|
65
74
|
try { text = readFileSync(path, 'utf8'); }
|
|
66
75
|
catch (e) {
|
|
67
|
-
if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META], homeOrgs: null };
|
|
76
|
+
if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META], homeOrgs: null, watch: [] };
|
|
68
77
|
throw e;
|
|
69
78
|
}
|
|
70
79
|
return parseConfig(text);
|
package/src/load.mjs
CHANGED
|
@@ -103,8 +103,10 @@ export function openDb(path = DB_PATH, { fresh = false } = {}) {
|
|
|
103
103
|
*
|
|
104
104
|
* `atlasEnv` maps a job key to the `environment` the repository's Atlas map
|
|
105
105
|
* records for it (Atlas 1.24.0 and later), when the map has this file.
|
|
106
|
+
* `isPrivate` is the repository's visibility, which decides whether a job
|
|
107
|
+
* gated to public repositories can run at all.
|
|
106
108
|
*/
|
|
107
|
-
export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = null) {
|
|
109
|
+
export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = null, isPrivate = false) {
|
|
108
110
|
const out = {
|
|
109
111
|
path, name: path.split('/').pop(), state: 'unknown',
|
|
110
112
|
has_paths_filter: 0, has_workflow_dispatch: 0, has_concurrency: 0,
|
|
@@ -197,7 +199,7 @@ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = nul
|
|
|
197
199
|
out.schedule_gaps = gaps ? gaps.join('\n') : null;
|
|
198
200
|
|
|
199
201
|
// The deploy doors, joined in analyze.mjs to the settings they depend on.
|
|
200
|
-
out.pages_deploy_jobs = pagesDeployJobs(jobEntries).join('\n');
|
|
202
|
+
out.pages_deploy_jobs = pagesDeployJobs(jobEntries, { isPrivate }).join('\n');
|
|
201
203
|
// The environment a job deploys to is a fact Atlas also records, so where
|
|
202
204
|
// the map names one it is preferred: one definition for both tools. This
|
|
203
205
|
// file's own reading is kept beside it, so a disagreement stays visible.
|
|
@@ -467,6 +469,38 @@ export function failedStepRows(record, text) {
|
|
|
467
469
|
return rows;
|
|
468
470
|
}
|
|
469
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Classify one repo's branches (schema.sql, table `branch`). Pure.
|
|
474
|
+
*
|
|
475
|
+
* Order matters, and each step was a real case on 2026-10-11:
|
|
476
|
+
* - an open PR beats a merged one: a branch reused for a second PR is live;
|
|
477
|
+
* - being an open PR's BASE beats being merged: a stacked PR targets it, and
|
|
478
|
+
* deleting it would retarget or close that PR;
|
|
479
|
+
* - "merged" needs the tip to equal the merged head. Four branches had commits
|
|
480
|
+
* pushed after their PR merged, which the PR page's restore cannot bring back.
|
|
481
|
+
*/
|
|
482
|
+
export function branchRows(record, { defaultBranch = null, openBases = [] } = {}) {
|
|
483
|
+
const bases = new Set(openBases);
|
|
484
|
+
const rows = [];
|
|
485
|
+
for (const b of record?.branches ?? []) {
|
|
486
|
+
if (b.name === defaultBranch) continue;
|
|
487
|
+
const prs = b.prs ?? [];
|
|
488
|
+
const open = prs.find(p => p.state === 'OPEN');
|
|
489
|
+
const merged = prs.filter(p => p.state === 'MERGED');
|
|
490
|
+
const exact = merged.find(p => p.head_oid && p.head_oid === b.oid);
|
|
491
|
+
let state, pr = null;
|
|
492
|
+
if (open) { state = 'open_pr'; pr = open.number; }
|
|
493
|
+
else if (bases.has(b.name)) state = 'open_base';
|
|
494
|
+
else if (exact) { state = 'merged'; pr = exact.number; }
|
|
495
|
+
else if (merged.length) { state = 'merged_moved'; pr = merged[0].number; }
|
|
496
|
+
else if (prs.length) { state = 'closed'; pr = prs[0].number; }
|
|
497
|
+
else state = 'no_pr';
|
|
498
|
+
rows.push({ name: b.name, state, head_oid: b.oid ?? null,
|
|
499
|
+
committed_at: b.committed_at ?? null, age_days: daysSince(b.committed_at), pr_number: pr });
|
|
500
|
+
}
|
|
501
|
+
return rows;
|
|
502
|
+
}
|
|
503
|
+
|
|
470
504
|
export function isWorkspaceRoot(pkg, rootEntryNames = [], pnpmWorkspaceText) {
|
|
471
505
|
if (pkg?.workspaces) return true;
|
|
472
506
|
const names = new Set(rootEntryNames.map(n => String(n).toLowerCase()));
|
|
@@ -542,6 +576,8 @@ function loadSnapshotInner(db, snap) {
|
|
|
542
576
|
lock: insertInto('lockfile'),
|
|
543
577
|
lockAdv: insertInto('lockfile_advisory'),
|
|
544
578
|
bprot: insertInto('branch_protection'),
|
|
579
|
+
branch: insertInto('branch'),
|
|
580
|
+
repoBranches: insertInto('repo_branches'),
|
|
545
581
|
ctx: insertInto('check_context'),
|
|
546
582
|
bill: insertInto('billing_usage'),
|
|
547
583
|
runCost: insertInto('run_cost'),
|
|
@@ -692,7 +728,7 @@ function loadSnapshotInner(db, snap) {
|
|
|
692
728
|
const atlasEnvByFile = new Map(mapDoors.filter(d => Array.isArray(d.jobs)).map(d => [d.file,
|
|
693
729
|
new Map(d.jobs.filter(j => j && typeof j.name === 'string' && j.environment).map(j => [j.name, j.environment]))]));
|
|
694
730
|
for (const f of wfFiles) {
|
|
695
|
-
const w = inspectWorkflow(f.text, f.path, r.defaultBranchRef?.name ?? null, atlasEnvByFile.get(f.path) ?? null);
|
|
731
|
+
const w = inspectWorkflow(f.text, f.path, r.defaultBranchRef?.name ?? null, atlasEnvByFile.get(f.path) ?? null, !!r.isPrivate);
|
|
696
732
|
stmts.wf.run(sid, r.name, w.path, w.name, w.state,
|
|
697
733
|
w.has_paths_filter, w.has_workflow_dispatch, w.has_concurrency,
|
|
698
734
|
w.runners, w.uses_macos, w.uses_windows,
|
|
@@ -791,6 +827,22 @@ function loadSnapshotInner(db, snap) {
|
|
|
791
827
|
}
|
|
792
828
|
}
|
|
793
829
|
|
|
830
|
+
// ---- branches ------------------------------------------------------------
|
|
831
|
+
// Absent `branches` (an older snapshot) or a repo missing from it (archived,
|
|
832
|
+
// empty, unreadable) leaves no rows: not measured, never "nothing to clean".
|
|
833
|
+
if (snap.branches) {
|
|
834
|
+
for (const r of snap.repos) {
|
|
835
|
+
const rec = snap.branches.repos?.[r.name];
|
|
836
|
+
if (!rec) continue;
|
|
837
|
+
stmts.repoBranches.run(sid, r.name,
|
|
838
|
+
rec.delete_branch_on_merge === null ? null : bool(rec.delete_branch_on_merge), rec.total ?? null);
|
|
839
|
+
const openBases = (r.prList?.nodes ?? []).map(p => p.baseRefName).filter(Boolean);
|
|
840
|
+
for (const b of branchRows(rec, { defaultBranch: r.defaultBranchRef?.name ?? null, openBases })) {
|
|
841
|
+
stmts.branch.run(sid, r.name, b.name, b.state, b.head_oid, b.committed_at, b.age_days, b.pr_number);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
794
846
|
// ---- committed lockfiles, audited directly ------------------------------
|
|
795
847
|
// Absent `lockfiles` means the pass did not run (older snapshot): no rows,
|
|
796
848
|
// and the analyzer stays silent rather than reporting zero exposure.
|
package/src/schema.sql
CHANGED
|
@@ -371,6 +371,31 @@ CREATE TABLE IF NOT EXISTS cost_reconciliation (
|
|
|
371
371
|
ratio REAL, trustworthy INTEGER, reason TEXT
|
|
372
372
|
);
|
|
373
373
|
|
|
374
|
+
-- One row per non-default branch (collect.mjs collectBranches). `state` is
|
|
375
|
+
-- decided at load time (load.mjs branchRows), first match wins:
|
|
376
|
+
-- open_pr the head of an open pull request
|
|
377
|
+
-- open_base the base of an open pull request (a stacked PR targets it)
|
|
378
|
+
-- merged its PR merged and the tip IS the merged head: restorable
|
|
379
|
+
-- from the PR page, so deleting it loses nothing
|
|
380
|
+
-- merged_moved its PR merged, but the tip moved after: those commits exist
|
|
381
|
+
-- only on this branch
|
|
382
|
+
-- closed only closed, unmerged pull requests
|
|
383
|
+
-- no_pr never the head of a pull request
|
|
384
|
+
-- Data, not findings: no org rule names stale branches. No rows for a repo
|
|
385
|
+
-- means not measured (archived, empty, unreadable, or an older snapshot).
|
|
386
|
+
CREATE TABLE IF NOT EXISTS branch (
|
|
387
|
+
snapshot_id INTEGER, repo TEXT, name TEXT, state TEXT,
|
|
388
|
+
head_oid TEXT, committed_at TEXT, age_days INTEGER,
|
|
389
|
+
pr_number INTEGER -- the PR the state was decided by, if any
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
-- Per-repo branch settings, one row per repo the branch pass read.
|
|
393
|
+
CREATE TABLE IF NOT EXISTS repo_branches (
|
|
394
|
+
snapshot_id INTEGER, repo TEXT,
|
|
395
|
+
delete_on_merge INTEGER, -- "Automatically delete head branches": 1 on, 0 off
|
|
396
|
+
branch_count INTEGER -- every branch, the default included
|
|
397
|
+
);
|
|
398
|
+
|
|
374
399
|
CREATE TABLE IF NOT EXISTS finding (
|
|
375
400
|
snapshot_id INTEGER, repo TEXT, code TEXT, severity TEXT,
|
|
376
401
|
category TEXT, message TEXT, evidence TEXT
|
|
@@ -387,6 +412,7 @@ CREATE INDEX IF NOT EXISTS idx_wf_snap ON workflow(snapshot_id, repo);
|
|
|
387
412
|
CREATE INDEX IF NOT EXISTS idx_wfrun_snap ON workflow_run(snapshot_id, repo);
|
|
388
413
|
CREATE INDEX IF NOT EXISTS idx_finding_snap ON finding(snapshot_id, severity, code);
|
|
389
414
|
CREATE INDEX IF NOT EXISTS idx_bprot_snap ON branch_protection(snapshot_id, repo);
|
|
415
|
+
CREATE INDEX IF NOT EXISTS idx_branch_snap ON branch(snapshot_id, repo);
|
|
390
416
|
CREATE INDEX IF NOT EXISTS idx_ctx_snap ON check_context(snapshot_id, repo);
|
|
391
417
|
CREATE INDEX IF NOT EXISTS idx_bill_snap ON billing_usage(snapshot_id, repo);
|
|
392
418
|
CREATE INDEX IF NOT EXISTS idx_rcost_snap ON run_cost(snapshot_id, repo);
|
package/src/workflow-risks.mjs
CHANGED
|
@@ -184,14 +184,47 @@ export function auditSteps(doc, jobs) {
|
|
|
184
184
|
|
|
185
185
|
const jobSteps = job => (job && typeof job === 'object' && Array.isArray(job.steps) ? job.steps : []);
|
|
186
186
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Job ids with a step that uses actions/deploy-pages, at any version.
|
|
189
|
+
*
|
|
190
|
+
* In a private repository a job that skips itself there is left out: Pages on
|
|
191
|
+
* a private repo is a plan feature, and a deploy gated on
|
|
192
|
+
* `!github.event.repository.private` is how a workflow says "only when public".
|
|
193
|
+
* That job never reaches deploy-pages, so Pages being off refuses nothing.
|
|
194
|
+
*/
|
|
195
|
+
export function pagesDeployJobs(jobEntries, { isPrivate = false } = {}) {
|
|
196
|
+
const byId = new Map(jobEntries);
|
|
189
197
|
return jobEntries
|
|
190
198
|
.filter(([, job]) => jobSteps(job).some(st =>
|
|
191
199
|
typeof st?.uses === 'string' && /^actions\/deploy-pages(?:@|$)/i.test(st.uses.trim())))
|
|
200
|
+
.filter(([id]) => !(isPrivate && skipsWhenPrivate(byId, id)))
|
|
192
201
|
.map(([id]) => id);
|
|
193
202
|
}
|
|
194
203
|
|
|
204
|
+
// A term that is false exactly when the repository is private.
|
|
205
|
+
const PUBLIC_ONLY = /^(?:!\s*github\.event\.repository\.private|github\.event\.repository\.private\s*==\s*false|github\.event\.repository\.private\s*!=\s*true|github\.event\.repository\.visibility\s*==\s*'public')$/;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* True when the job cannot run in a private repository: its own `if:` is a
|
|
209
|
+
* conjunction with a public-only term, or it has no `if:` of its own (so
|
|
210
|
+
* GitHub's implicit success() applies) and a job it needs cannot run. Any
|
|
211
|
+
* `||`, or anything else this cannot read, is false: unknown never excuses.
|
|
212
|
+
*/
|
|
213
|
+
export function skipsWhenPrivate(byId, id, seen = new Set()) {
|
|
214
|
+
if (seen.has(id)) return false;
|
|
215
|
+
seen.add(id);
|
|
216
|
+
const job = byId.get(id);
|
|
217
|
+
if (!job || typeof job !== 'object') return false;
|
|
218
|
+
if (job.if !== undefined) {
|
|
219
|
+
if (typeof job.if !== 'string') return false;
|
|
220
|
+
const expr = job.if.trim().replace(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/, '$1');
|
|
221
|
+
if (expr.includes('||')) return false;
|
|
222
|
+
return expr.split('&&').some(t => PUBLIC_ONLY.test(t.trim().replace(/^\((.*)\)$/, '$1').trim()));
|
|
223
|
+
}
|
|
224
|
+
const needs = typeof job.needs === 'string' ? [job.needs] : (Array.isArray(job.needs) ? job.needs : []);
|
|
225
|
+
return needs.some(n => skipsWhenPrivate(byId, n, seen));
|
|
226
|
+
}
|
|
227
|
+
|
|
195
228
|
/**
|
|
196
229
|
* The environment a job names, or null when it names none or names it with an
|
|
197
230
|
* expression. `environment:` is either a string or `{ name, url }`. A name built
|