@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.
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "org": "your-org",
3
3
  "metaRepos": [".github"],
4
- "homeOrgs": ["your-org"]
4
+ "homeOrgs": ["your-org"],
5
+ "watch": []
5
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/housekeeping",
3
- "version": "1.4.0",
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
- graphql: 300,
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.5.0';
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.replace('/', '%2f')}`, {
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.replace('/', '%2f')}`, {
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
- return { org, metaRepos: [...meta], homeOrgs: home ? [...home] : null };
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);
@@ -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
- /** Job ids with a step that uses actions/deploy-pages, at any version. */
188
- export function pagesDeployJobs(jobEntries) {
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