@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 +10 -0
- package/package.json +1 -1
- package/src/analyze.mjs +53 -3
- package/src/cli.mjs +2 -2
- package/src/mcp.mjs +2 -1
- package/src/report.mjs +2 -2
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.
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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`);
|