@mcptoolshop/housekeeping 1.3.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 CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project are recorded here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [1.3.1] - 2026-09-30
7
+
8
+ ### Fixed
9
+
10
+ - `CI_FAILING` no longer fires on a default branch whose only non-green checks
11
+ are cancelled runs replaced by a green run of the same check on the same
12
+ commit. GitHub's check rollup counts a cancelled run as a failure, so a
13
+ commit pushed twice read as red forever. `hk ci`, the MCP server and the
14
+ report read the same decision, and the finding now names the failing checks.
15
+
6
16
  ## [1.3.0] - 2026-09-30
7
17
 
8
18
  The first release on npm, as `@mcptoolshop/housekeeping`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/housekeeping",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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/analyze.mjs CHANGED
@@ -25,6 +25,43 @@ export const RUN_IS_LIVE_MAINLINE_SIGNAL = `
25
25
  WHERE w.snapshot_id=wr.snapshot_id AND w.repo=wr.repo AND w.path=wr.path
26
26
  AND (','||w.on_triggers||',' LIKE '%,push,%'
27
27
  OR ','||w.on_triggers||',' LIKE '%,pull_request,%')))`;
28
+
29
+ // The one definition of "the default branch head's checks are red", for the
30
+ // same three readers: the analyzer decides (CI_FAILING, below) and the CLI,
31
+ // the MCP server and the report ask whether it did, never the raw rollup.
32
+ export const HEAD_CHECKS_RED = `EXISTS (SELECT 1 FROM finding f
33
+ WHERE f.snapshot_id=r.snapshot_id AND f.repo=r.name AND f.code='CI_FAILING')`;
34
+
35
+ // Check-run states that mean a check ran and did not pass. CANCELLED is not
36
+ // among them: `cancel-in-progress` cancels a superseded run as designed.
37
+ const FAILED_STATES = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'STARTUP_FAILURE', 'ACTION_REQUIRED']);
38
+
39
+ /**
40
+ * Which checks on the default branch head actually fail. Pure.
41
+ *
42
+ * GitHub's rollup is FAILURE when any check run on the commit is, and it
43
+ * counts a CANCELLED run as a failure. The same commit pushed twice starts
44
+ * two runs; the concurrency rule cancels the first and the second passes,
45
+ * and the rollup still reads FAILURE forever (measured 2026-09-30 on two
46
+ * repos, every non-green check CANCELLED beside a SUCCESS of the same name).
47
+ * So a check fails here only when one of its runs is in a failed state and
48
+ * none of its runs on this commit succeeded. A check that was only ever
49
+ * cancelled is not a failure either: a failed run is not a cancelled one.
50
+ *
51
+ * `contexts` are check_context rows for the head ({ name, state }). Returns
52
+ * [{ name, states }] for the failing checks, or null when there are no rows
53
+ * -- not measured, so the caller falls back to the rollup.
54
+ */
55
+ export function failingHeadChecks(contexts) {
56
+ if (!contexts?.length) return null;
57
+ const byName = new Map();
58
+ for (const c of contexts) {
59
+ if (!byName.has(c.name)) byName.set(c.name, []);
60
+ byName.get(c.name).push(c.state ?? 'RUNNING');
61
+ }
62
+ return [...byName].filter(([, states]) => !states.includes('SUCCESS') && states.some(s => FAILED_STATES.has(s)))
63
+ .map(([name, states]) => ({ name, states }));
64
+ }
28
65
  // A checker declared in package.json but invoked by no script is a gate that
29
66
  // exists on paper and never runs. The case that earned the rule: a repo carried
30
67
  // `typescript` with no typecheck script -- build is tsup and tests are vitest,
@@ -710,10 +747,23 @@ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
710
747
  }
711
748
 
712
749
  // ---- CI health -------------------------------------------------------
750
+ // The rollup is read through the head's own checks when the snapshot has
751
+ // them: a rollup red only with cancelled runs that a green run of the
752
+ // same check replaced is not a failing mainline (failingHeadChecks).
713
753
  if (r.ci_rollup === 'FAILURE' || r.ci_rollup === 'ERROR') {
714
- F(name, 'CI_FAILING', 'high', 'ci',
715
- `Default-branch check rollup is ${r.ci_rollup}.`,
716
- `${r.default_branch}@${(r.head_oid ?? '').slice(0, 7)}`);
754
+ const head = (ctxBy.get(name) ?? []).filter(c => c.source === 'default');
755
+ const failing = failingHeadChecks(head);
756
+ const at = `${r.default_branch}@${(r.head_oid ?? '').slice(0, 7)}`;
757
+ // The collector reads the first 100 checks of a commit (most seen: 19).
758
+ // A red rollup whose failing check could lie past that page is read as
759
+ // the rollup says, never as clean.
760
+ if (failing === null || (failing.length === 0 && head.length >= 100)) {
761
+ F(name, 'CI_FAILING', 'high', 'ci', `Default-branch check rollup is ${r.ci_rollup}.`, at);
762
+ } else if (failing.length) {
763
+ F(name, 'CI_FAILING', 'high', 'ci',
764
+ `Default-branch checks failing: ${failing.map(c => `${c.name} (${[...new Set(c.states)].join(', ')})`).join('; ')}.`,
765
+ `${at}; rollup ${r.ci_rollup}`);
766
+ }
717
767
  }
718
768
  // A run only speaks for the mainline if its workflow could still be
719
769
  // triggered by a push today. Match the run back to the workflow file it
package/src/cli.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { DatabaseSync } from 'node:sqlite';
8
8
  import { openDb, loadSnapshot, latestSnapshotFile, DB_PATH } from './load.mjs';
9
- import { analyze, healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL } from './analyze.mjs';
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
12
  import { resolveOrg } from './config.mjs';
@@ -61,7 +61,7 @@ const REPORTS = {
61
61
  WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
62
62
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}) failing
63
63
  FROM repo r WHERE r.snapshot_id=? AND r.is_archived=0
64
- AND (r.ci_rollup IN ('FAILURE','ERROR') OR EXISTS (
64
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
65
65
  SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
66
66
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}))
67
67
  ORDER BY r.name`).all(sid)),
package/src/mcp.mjs CHANGED
@@ -12,6 +12,7 @@ import { existsSync, readFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { EXIT, formatError, readOnlyQuery, structured, userError } from './errors.mjs';
14
14
  import { DATA_DIR, PACKAGE_ROOT } from './paths.mjs';
15
+ import { HEAD_CHECKS_RED } from './analyze.mjs';
15
16
 
16
17
  const DB_FILE = process.env.HK_DB || join(DATA_DIR, 'housekeeping.db');
17
18
  const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
@@ -115,7 +116,7 @@ const TOOLS = [
115
116
  WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name AND wr.is_latest_on_default=1
116
117
  AND wr.conclusion='failure' AND wr.event NOT IN ('dynamic','schedule')) failing
117
118
  FROM repo r WHERE r.snapshot_id=? AND r.is_archived=0
118
- AND (r.ci_rollup IN ('FAILURE','ERROR') OR EXISTS (
119
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
119
120
  SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
120
121
  AND wr.is_latest_on_default=1 AND wr.conclusion='failure'
121
122
  AND wr.event NOT IN ('dynamic','schedule')))
package/src/report.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { writeFileSync, mkdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { openDb } from './load.mjs';
7
- import { healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, doorFindingRows, ATLAS_DOOR_CHECKS_SINCE, engineBehindCounts } from './analyze.mjs';
7
+ import { healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED, doorFindingRows, ATLAS_DOOR_CHECKS_SINCE, engineBehindCounts } from './analyze.mjs';
8
8
  import { exitCodeFor, formatError, userError } from './errors.mjs';
9
9
  import { REPORTS_DIR } from './paths.mjs';
10
10
 
@@ -77,7 +77,7 @@ export function buildReport(db, sid) {
77
77
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}) failing_workflows
78
78
  FROM repo r
79
79
  WHERE r.snapshot_id=? AND r.is_archived=0
80
- AND (r.ci_rollup IN ('FAILURE','ERROR') OR EXISTS (
80
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
81
81
  SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
82
82
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}))
83
83
  ORDER BY r.name`);