diff --git a/.kit/shared/memory.jsonl b/.kit/shared/memory.jsonl index e68f63a8..a5bfd262 100644 --- a/.kit/shared/memory.jsonl +++ b/.kit/shared/memory.jsonl @@ -45,3 +45,4 @@ {"id":"9c7fa6","area":"cli","kind":"convention","title":"A config section is declared in three places, and the third one warns","body":"Adding a .kit.toml section means kitConfig (type), CONFIG_SECTIONS (config-surface.ts, generates docs/CONFIGURATION.md) AND KNOWN_SECTIONS (config.ts), which loadConfig warns from. The first two were pinned to each other; the third had drifted by two — [supply_chain] and [coverage] are real, honoured sections that printed 'unknown section … (likely a typo)' on every kit invocation. A warning that fires on correct configuration trains the operator to ignore the one that fires on a real typo. config-surface.test.ts now pins all three in both directions.","refs":[],"author":"Peter Sandström ","ts":"2026-08-24T11:36:15.667Z","source_ref":"5855126","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"0D2CmPQKGkbvtwM5zfM80w3jUa1h1F0AY8Nit6EFMzREVdIbI/PU+ULLVgERFb8G+f2zw46CBt1jSk4qyDAnCw=="} {"id":"f47c5a","area":"cli","kind":"convention","title":"A gate that exists but is never invoked is the default failure, not the exception","body":"kit adopted its own ADR gate in #403 and no workflow, hook or agent instruction ever called it — armed and unfired for a month, while the rules provably caught violations. A gate nobody runs emits nothing, and nothing reads exactly like a clean run. self-audit-ci already proves every script a workflow points AT exists; the inverse (a gate that exists is pointed at by something) had no rule. When adding a gate to this repo, wire the invocation AND pin it with a test that strips comments and forbids continue-on-error / || true — a gate named in a comment is not a gate, and one that cannot fail the build is a report. General case tracked in #533.","refs":[],"author":"Peter Sandström ","ts":"2026-08-25T12:06:22.827Z","source_ref":"a49e85c","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"CNhZy+Of23FnzPSF6O95FvxAriwbLWBumewW/9QtgPN606vE4IiidXydMmKQj7JyfBJPOWRBCZM1c7QoSIr4Dw=="} {"id":"d1814d","area":"cli","kind":"decision","title":"pre-commit excludes full npm test until suite timeouts are fixed","body":"kit-public uses externally managed .githooks. [hooks].pre-commit should require staged security scan + build, not full npm test: a real pre-commit run on 2026-08-27 hit Node test file timeouts in dist/policy-gate.test.js and dist/secrets-propagate.test.js. Re-add full npm test only after those suite timeouts are fixed or the suite is split for hook use.","refs":[],"author":"Peter Sandström ","ts":"2026-08-27T09:22:36.795Z","source_ref":"3cfa838","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"bbdKUzm6w6dIFEacsfFMdkuLhhwBjZ732zvs08X0ZPlv6NQ7J3CgDJ2GpZhf+M8k7Yff8gvlpPej4b+sYHWOBA=="} +{"id":"f57f88","area":"ops","kind":"convention","title":"post-merge health loop","body":"After merge, release, deploy, or tag push, do not rely on email or GitHub check conclusions alone. Run the connected health inbox command, treat red and unknown as not green, inspect each connected source, and keep acting or waiting until every connected sensor is green before telling the user the arc is closed. For this repo before a release is published, run the local built CLI via node dist/cli.js health.","refs":[],"author":"Peter Sandström ","ts":"2026-08-28T14:47:56.828Z","source_ref":"cc8e4a0","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"f2AEPcCAs2OVwuS6fbIm2JZG7UMoUZN4+54DI4V2b9Fmc6pDCfLkCkqnmH1/51XQd8431xlVfhrkw5mQkuuCDw=="} diff --git a/src/cli-health.test.ts b/src/cli-health.test.ts index db495309..33af891d 100644 --- a/src/cli-health.test.ts +++ b/src/cli-health.test.ts @@ -18,6 +18,7 @@ describe("formatHealth", () => { it("counts red findings and renders a line per finding", () => { const out = formatHealth(findings); assert.equal(out.redCount, 1); + assert.equal(out.nonGreenCount, 2); assert.equal(out.lines.length, 3); assert.ok(out.lines.some((l) => l.includes("workflow failing: CI"))); assert.ok(out.lines.some((l) => l.includes("acme/webapp"))); @@ -26,5 +27,6 @@ describe("formatHealth", () => { it("redCount is 0 when nothing is red", () => { const out = formatHealth([{ sensor: "a", source: "s", status: "green", title: "ok" }]); assert.equal(out.redCount, 0); + assert.equal(out.nonGreenCount, 0); }); }); diff --git a/src/cli-shared.ts b/src/cli-shared.ts index 3c382b1b..b2c6cd24 100644 --- a/src/cli-shared.ts +++ b/src/cli-shared.ts @@ -45,9 +45,13 @@ export async function buildHealthCtx(config: kitConfig): Promise { ...Object.keys(pkg.devDependencies ?? {}), ]; const { detectServices } = await import("./service-registry.js"); - services = await detectServices({ deps, fileExists: async (p) => existsSync(resolve(cwd, p)) }); + const detected = await detectServices({ + deps, + fileExists: async (p) => existsSync(resolve(cwd, p)), + }); + services = Array.from(new Set([...Object.keys(config.services ?? {}), ...detected])); } catch { - services = []; + services = Object.keys(config.services ?? {}); } return { cwd, @@ -55,6 +59,7 @@ export async function buildHealthCtx(config: kitConfig): Promise { gitRemote: remote.ok && remote.stdout.trim().length > 0, gitlabCi: existsSync(resolve(cwd, ".gitlab-ci.yml")), bitbucketPipelines: existsSync(resolve(cwd, "bitbucket-pipelines.yml")), + githubDependabot: existsSync(resolve(cwd, ".github", "dependabot.yml")), vercel, services, }; diff --git a/src/commands/info.ts b/src/commands/info.ts index 6eedc5b4..510a5af0 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -58,7 +58,7 @@ export async function cmdHealth(): Promise { config, { operation: "health", operationType: "read", metadata: {} }, async () => { - const { runHealth, selectSensors, defaultHealthDeps, formatHealth } = + const { runHealth, selectSensors, defaultHealthDeps, formatHealth, healthOk } = await import("../health.js"); const { syncHealthFindings } = await import("../health-track.js"); @@ -69,12 +69,12 @@ export async function cmdHealth(): Promise { await syncHealthFindings(findings); // mirror red into PAL (fail-open) if (jsonMode) { - const redCount = findings.filter((f) => f.status === "red").length; - console.log(JSON.stringify({ ok: redCount === 0, findings }, null, 2)); - return redCount === 0; + const ok = healthOk(findings); + console.log(JSON.stringify({ ok, findings }, null, 2)); + return ok; } - const { lines, redCount } = formatHealth(findings); + const { lines, redCount, nonGreenCount } = formatHealth(findings); console.log(`${c.bold}kit health${c.reset} ${c.dim}${sensors.length} sensor(s)${c.reset}`); if (findings.length === 0) { console.log(` ${c.dim}no connected external systems detected${c.reset}`); @@ -83,8 +83,10 @@ export async function cmdHealth(): Promise { const color = line.startsWith("✗") ? c.red : line.startsWith("?") ? c.yellow : c.green; console.log(` ${color}${line}${c.reset}`); } - if (redCount > 0) console.log(`${c.red}${redCount} red${c.reset}`); - return redCount === 0; + if (nonGreenCount > 0) { + console.log(`${c.red}${nonGreenCount} not green (${redCount} red)${c.reset}`); + } + return nonGreenCount === 0; }, ); } diff --git a/src/health-sensors/github-actions.test.ts b/src/health-sensors/github-actions.test.ts index 5b7a46b1..5840efdd 100644 --- a/src/health-sensors/github-actions.test.ts +++ b/src/health-sensors/github-actions.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { parseGitHubRuns, failingWorkflows, + pendingWorkflows, activeWorkflowNames, githubActionsSensor, } from "./github-actions.js"; @@ -103,6 +104,30 @@ describe("parseGitHubRuns / failingWorkflows", () => { ["CI"], ); }); + + it("keeps a newer in-progress run as not-green instead of falling back to old success", () => { + const parsed = parseGitHubRuns( + JSON.stringify([ + { + name: "CI", + status: "in_progress", + conclusion: "", + createdAt: "2026-08-28T14:38:28Z", + databaseId: 10, + }, + { + name: "CI", + status: "completed", + conclusion: "success", + createdAt: "2026-08-28T13:30:43Z", + databaseId: 9, + }, + ]), + ); + assert.deepEqual(pendingWorkflows(parsed), [ + { name: "CI", status: "in_progress", createdAt: "2026-08-28T14:38:28Z" }, + ]); + }); }); describe("activeWorkflowNames", () => { @@ -197,6 +222,35 @@ describe("githubActionsSensor.probe", () => { assert.equal(out[0].status, "green"); }); + it("emits unknown while the latest workflow run is still pending", async () => { + const pending = JSON.stringify([ + { + name: "CI", + status: "in_progress", + conclusion: "", + createdAt: "2026-08-28T14:38:28Z", + databaseId: 10, + }, + { + name: "CI", + status: "completed", + conclusion: "success", + createdAt: "2026-08-28T13:30:43Z", + databaseId: 9, + }, + ]); + const out = await githubActionsSensor.probe( + ctx, + deps({ + "gh repo": { stdout: JSON.stringify({ nameWithOwner: "acme/webapp" }), ok: true }, + "gh run": { stdout: pending, ok: true }, + }), + ); + assert.equal(out.length, 1); + assert.equal(out[0].status, "unknown"); + assert.match(out[0].title, /pending/); + }); + it("returns unknown when gh is not authed", async () => { const out = await githubActionsSensor.probe( ctx, diff --git a/src/health-sensors/github-actions.ts b/src/health-sensors/github-actions.ts index 3876f6f0..c7ae5af9 100644 --- a/src/health-sensors/github-actions.ts +++ b/src/health-sensors/github-actions.ts @@ -8,7 +8,13 @@ export interface GhRun { databaseId: number; } -const FAIL_CONCLUSIONS = new Set(["failure", "timed_out", "startup_failure"]); +const FAIL_CONCLUSIONS = new Set([ + "failure", + "timed_out", + "startup_failure", + "cancelled", + "action_required", +]); export function parseGitHubRuns(json: string): GhRun[] { try { @@ -40,17 +46,28 @@ export function failingWorkflows( runs: GhRun[], active?: Set, ): { name: string; createdAt: string }[] { + return latestWorkflowRuns(runs, active) + .filter((r) => r.status === "completed" && FAIL_CONCLUSIONS.has(r.conclusion)) + .map((r) => ({ name: r.name, createdAt: r.createdAt })); +} + +export function pendingWorkflows( + runs: GhRun[], + active?: Set, +): { name: string; status: string; createdAt: string }[] { + return latestWorkflowRuns(runs, active) + .filter((r) => r.status !== "completed") + .map((r) => ({ name: r.name, status: r.status, createdAt: r.createdAt })); +} + +function latestWorkflowRuns(runs: GhRun[], active?: Set): GhRun[] { const latest = new Map(); for (const r of runs) { - if (r.status !== "completed") continue; const cur = latest.get(r.name); if (!cur || r.createdAt > cur.createdAt) latest.set(r.name, r); } const filterDisabled = active !== undefined && active.size > 0; - return [...latest.values()] - .filter((r) => FAIL_CONCLUSIONS.has(r.conclusion)) - .filter((r) => !filterDisabled || active.has(r.name)) - .map((r) => ({ name: r.name, createdAt: r.createdAt })); + return [...latest.values()].filter((r) => !filterDisabled || active.has(r.name)); } export const githubActionsSensor: HealthSensor = { @@ -112,8 +129,10 @@ export const githubActionsSensor: HealthSensor = { const wfRes = await deps.runCli("gh", ["workflow", "list", "--json", "name,state"]); const active = wfRes.ok ? activeWorkflowNames(wfRes.stdout) : new Set(); - const failing = failingWorkflows(parseGitHubRuns(listRes.stdout), active); - if (failing.length === 0) { + const runs = parseGitHubRuns(listRes.stdout); + const pending = pendingWorkflows(runs, active); + const failing = failingWorkflows(runs, active); + if (pending.length === 0 && failing.length === 0) { return [ { sensor: "github-actions", @@ -123,14 +142,25 @@ export const githubActionsSensor: HealthSensor = { }, ]; } - return failing.map((w) => ({ - sensor: "github-actions", - source: nwo, - status: "red" as const, - severity: "high" as const, - title: `GitHub Actions workflow failing: ${w.name}`, - detail: `latest run of "${w.name}" failed (${w.createdAt})`, - suggestedClass: "code" as const, - })); + return [ + ...pending.map((w) => ({ + sensor: "github-actions", + source: nwo, + status: "unknown" as const, + severity: "medium" as const, + title: `GitHub Actions workflow pending: ${w.name}`, + detail: `latest run of "${w.name}" is ${w.status} (${w.createdAt}); wait before declaring green`, + suggestedClass: "human" as const, + })), + ...failing.map((w) => ({ + sensor: "github-actions", + source: nwo, + status: "red" as const, + severity: "high" as const, + title: `GitHub Actions workflow failing: ${w.name}`, + detail: `latest run of "${w.name}" failed (${w.createdAt})`, + suggestedClass: "code" as const, + })), + ]; }, }; diff --git a/src/health-sensors/github-dependabot.test.ts b/src/health-sensors/github-dependabot.test.ts new file mode 100644 index 00000000..96e8146c --- /dev/null +++ b/src/health-sensors/github-dependabot.test.ts @@ -0,0 +1,122 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + dependabotPrFindings, + githubDependabotSensor, + parseDependabotPrs, +} from "./github-dependabot.js"; +import type { HealthCtx, HealthDeps } from "../health.js"; + +const ctx: HealthCtx = { cwd: "/tmp/repo", config: {}, gitRemote: true, githubDependabot: true }; + +function deps(over: Record = {}): HealthDeps { + return { + runCli: async (cmd, args) => { + const key = `${cmd} ${args[0]}`; + const r = over[key]; + if (r) return { stdout: r.stdout, stderr: "", exitCode: r.ok ? 0 : 1, ok: r.ok }; + return { stdout: "", stderr: "", exitCode: 0, ok: true }; + }, + httpGet: async () => ({ ok: true, status: 200, body: "" }), + }; +} + +describe("parseDependabotPrs / dependabotPrFindings", () => { + it("turns failing Dependabot PR checks into red health findings", () => { + const prs = parseDependabotPrs( + JSON.stringify([ + { + number: 12, + title: "Bump x", + url: "https://github.com/acme/web/pull/12", + mergeStateStatus: "CLEAN", + statusCheckRollup: [ + { __typename: "CheckRun", name: "CI", status: "COMPLETED", conclusion: "FAILURE" }, + ], + }, + ]), + ); + const out = dependabotPrFindings("acme/web", prs); + assert.equal(out.length, 1); + assert.equal(out[0].status, "red"); + assert.match(out[0].title, /checks failing/); + }); + + it("turns action-required Dependabot PR checks into red health findings", () => { + const prs = parseDependabotPrs( + JSON.stringify([ + { + number: 15, + title: "Bump w", + url: "https://github.com/acme/web/pull/15", + mergeStateStatus: "CLEAN", + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "CI", + status: "COMPLETED", + conclusion: "ACTION_REQUIRED", + }, + ], + }, + ]), + ); + const out = dependabotPrFindings("acme/web", prs); + assert.equal(out.length, 1); + assert.equal(out[0].status, "red"); + assert.match(out[0].title, /checks failing/); + }); + + it("turns pending Dependabot PR checks into unknown health findings", () => { + const prs = parseDependabotPrs( + JSON.stringify([ + { + number: 13, + title: "Bump y", + url: "https://github.com/acme/web/pull/13", + mergeStateStatus: "CLEAN", + statusCheckRollup: [{ __typename: "CheckRun", name: "CI", status: "IN_PROGRESS" }], + }, + ]), + ); + const out = dependabotPrFindings("acme/web", prs); + assert.equal(out.length, 1); + assert.equal(out[0].status, "unknown"); + assert.match(out[0].title, /checks pending/); + }); + + it("turns a ready open Dependabot PR into an action item", () => { + const prs = parseDependabotPrs( + JSON.stringify([ + { + number: 14, + title: "Bump z", + url: "https://github.com/acme/web/pull/14", + mergeStateStatus: "CLEAN", + statusCheckRollup: [ + { __typename: "CheckRun", name: "CI", status: "COMPLETED", conclusion: "SUCCESS" }, + ], + }, + ]), + ); + const out = dependabotPrFindings("acme/web", prs); + assert.equal(out.length, 1); + assert.equal(out[0].status, "red"); + assert.equal(out[0].severity, "low"); + assert.match(out[0].title, /ready for review/); + }); +}); + +describe("githubDependabotSensor.probe", () => { + it("is green when no Dependabot PRs are open", async () => { + const out = await githubDependabotSensor.probe( + ctx, + deps({ + "gh repo": { stdout: JSON.stringify({ nameWithOwner: "acme/web" }), ok: true }, + "gh pr": { stdout: "[]", ok: true }, + }), + ); + assert.equal(out.length, 1); + assert.equal(out[0].status, "green"); + }); +}); diff --git a/src/health-sensors/github-dependabot.ts b/src/health-sensors/github-dependabot.ts new file mode 100644 index 00000000..fdcc1bcc --- /dev/null +++ b/src/health-sensors/github-dependabot.ts @@ -0,0 +1,181 @@ +import type { HealthCtx, HealthDeps, HealthFinding, HealthSensor } from "../health.js"; + +export interface DependabotCheck { + __typename?: string; + name?: string; + status?: string; + conclusion?: string; + state?: string; +} + +export interface DependabotPr { + number: number; + title: string; + url?: string; + isDraft?: boolean; + mergeStateStatus?: string; + statusCheckRollup?: DependabotCheck[]; +} + +const FAIL_STATES = new Set([ + "FAILURE", + "ERROR", + "TIMED_OUT", + "STARTUP_FAILURE", + "CANCELLED", + "ACTION_REQUIRED", +]); +const PENDING_STATES = new Set(["PENDING", "EXPECTED", "REQUESTED", "QUEUED", "IN_PROGRESS"]); + +export function parseDependabotPrs(json: string): DependabotPr[] { + try { + const arr = JSON.parse(json) as DependabotPr[]; + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} + +export function dependabotPrFindings(repo: string, prs: DependabotPr[]): HealthFinding[] { + if (prs.length === 0) { + return [ + { + sensor: "github-dependabot", + source: repo, + status: "green", + title: "Dependabot: no open PRs", + }, + ]; + } + + return prs.map((pr) => { + const label = `#${pr.number} ${pr.title}`; + const mergeState = (pr.mergeStateStatus ?? "").toUpperCase(); + if (mergeState === "DIRTY") { + return { + sensor: "github-dependabot", + source: repo, + status: "red", + severity: "high", + title: `Dependabot PR has merge conflicts: ${label}`, + detail: pr.url, + suggestedClass: "code", + }; + } + if (mergeState === "UNKNOWN") { + return { + sensor: "github-dependabot", + source: repo, + status: "unknown", + severity: "medium", + title: `Dependabot PR mergeability pending: ${label}`, + detail: pr.url, + suggestedClass: "human", + }; + } + + const checkState = dependabotCheckState(pr.statusCheckRollup ?? []); + if (checkState === "failing") { + return { + sensor: "github-dependabot", + source: repo, + status: "red", + severity: "high", + title: `Dependabot PR checks failing: ${label}`, + detail: pr.url, + suggestedClass: "code", + }; + } + if (checkState === "pending") { + return { + sensor: "github-dependabot", + source: repo, + status: "unknown", + severity: "medium", + title: `Dependabot PR checks pending: ${label}`, + detail: pr.url, + suggestedClass: "human", + }; + } + + return { + sensor: "github-dependabot", + source: repo, + status: "red", + severity: "low", + title: `Dependabot PR ready for review: ${label}`, + detail: pr.url, + suggestedClass: "code", + }; + }); +} + +function dependabotCheckState(checks: DependabotCheck[]): "ready" | "pending" | "failing" { + if (checks.some((c) => checkFailing(c))) return "failing"; + if (checks.some((c) => checkPending(c))) return "pending"; + return "ready"; +} + +function checkFailing(check: DependabotCheck): boolean { + const conclusion = check.conclusion?.toUpperCase(); + const state = check.state?.toUpperCase(); + return Boolean((conclusion && FAIL_STATES.has(conclusion)) || (state && FAIL_STATES.has(state))); +} + +function checkPending(check: DependabotCheck): boolean { + const status = check.status?.toUpperCase(); + const state = check.state?.toUpperCase(); + return Boolean( + (status && status !== "COMPLETED") || + (state && PENDING_STATES.has(state)) || + (!status && !state && !check.conclusion), + ); +} + +export const githubDependabotSensor: HealthSensor = { + id: "github-dependabot", + async probe(_ctx: HealthCtx, deps: HealthDeps): Promise { + const repoRes = await deps.runCli("gh", ["repo", "view", "--json", "nameWithOwner"]); + if (!repoRes.ok) { + return [ + { + sensor: "github-dependabot", + source: "(no gh auth / no remote)", + status: "unknown", + title: "Dependabot probe could not resolve the repo", + detail: "gh repo view failed", + }, + ]; + } + let repo: string; + try { + repo = (JSON.parse(repoRes.stdout) as { nameWithOwner?: string }).nameWithOwner ?? ""; + } catch { + repo = ""; + } + + const prRes = await deps.runCli("gh", [ + "pr", + "list", + "--state", + "open", + "--author", + "app/dependabot", + "--json", + "number,title,url,isDraft,mergeStateStatus,statusCheckRollup,updatedAt", + ]); + if (!prRes.ok) { + return [ + { + sensor: "github-dependabot", + source: repo || "(unknown repo)", + status: "unknown", + title: "Dependabot PR list failed", + detail: prRes.stderr || "gh pr list returned non-zero", + }, + ]; + } + + return dependabotPrFindings(repo || "(unknown repo)", parseDependabotPrs(prRes.stdout)); + }, +}; diff --git a/src/health-sensors/posthog.test.ts b/src/health-sensors/posthog.test.ts new file mode 100644 index 00000000..5e681c2d --- /dev/null +++ b/src/health-sensors/posthog.test.ts @@ -0,0 +1,119 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + activePostHogHealthIssues, + parsePostHogHealthIssues, + posthogApiBase, + posthogSensor, +} from "./posthog.js"; +import type { HealthCtx, HealthDeps, HttpResponse } from "../health.js"; + +const issues = JSON.stringify({ + results: [ + { + id: "1", + kind: "no_live_events", + severity: "critical", + status: "active", + title: "No live events", + dismissed: false, + snoozed_until: null, + }, + ], +}); + +function deps(over: { http?: HttpResponse; urls?: string[] } = {}): HealthDeps { + return { + runCli: async () => ({ stdout: "", stderr: "", exitCode: 0, ok: true }), + httpGet: async (url) => { + over.urls?.push(url); + return over.http ?? { ok: true, status: 200, body: issues }; + }, + }; +} + +const ctx: HealthCtx = { cwd: "/tmp/repo", config: {}, services: ["posthog"] }; + +describe("parsePostHogHealthIssues / activePostHogHealthIssues", () => { + it("parses paginated health issues and ignores resolved/dismissed/snoozed rows", () => { + const parsed = parsePostHogHealthIssues( + JSON.stringify({ + results: [ + { status: "active", dismissed: false, snoozed_until: null }, + { status: "resolved", dismissed: false, snoozed_until: null }, + { status: "active", dismissed: true, snoozed_until: null }, + { status: "active", dismissed: false, snoozed_until: "2026-08-29T00:00:00Z" }, + ], + }), + ); + assert.equal(activePostHogHealthIssues(parsed).length, 1); + assert.deepEqual(parsePostHogHealthIssues("nope"), []); + }); +}); + +describe("posthogApiBase", () => { + afterEach(() => { + delete process.env.POSTHOG_API_HOST; + delete process.env.POSTHOG_HOST; + delete process.env.NEXT_PUBLIC_POSTHOG_HOST; + }); + + it("maps the public ingestion host to the REST API host", () => { + process.env.NEXT_PUBLIC_POSTHOG_HOST = "https://eu.i.posthog.com"; + assert.equal(posthogApiBase(), "https://eu.posthog.com"); + }); +}); + +describe("posthogSensor.probe", () => { + afterEach(() => { + delete process.env.POSTHOG_PERSONAL_API_KEY; + delete process.env.POSTHOG_PROJECT_ID; + delete process.env.POSTHOG_API_HOST; + delete process.env.POSTHOG_HOST; + delete process.env.NEXT_PUBLIC_POSTHOG_HOST; + }); + + function setEnv() { + process.env.POSTHOG_PERSONAL_API_KEY = "phx_personal"; + process.env.POSTHOG_PROJECT_ID = "123"; + } + + it("emits red when active unsnoozed PostHog health issues exist", async () => { + setEnv(); + const out = await posthogSensor.probe(ctx, deps()); + assert.equal(out[0].status, "red"); + assert.equal(out[0].severity, "critical"); + assert.match(out[0].title, /active health issue/); + }); + + it("emits green when no active unsnoozed health issues exist", async () => { + setEnv(); + const out = await posthogSensor.probe( + ctx, + deps({ http: { ok: true, status: 200, body: JSON.stringify({ results: [] }) } }), + ); + assert.equal(out[0].status, "green"); + }); + + it("is unknown when PostHog API credentials are missing", async () => { + const out = await posthogSensor.probe(ctx, deps()); + assert.equal(out[0].status, "unknown"); + }); + + it("is unknown on a non-OK API response", async () => { + setEnv(); + const out = await posthogSensor.probe( + ctx, + deps({ http: { ok: false, status: 401, body: "" } }), + ); + assert.equal(out[0].status, "unknown"); + assert.match(out[0].title, /401/); + }); + + it("queries the active non-dismissed health-issues endpoint", async () => { + setEnv(); + const urls: string[] = []; + await posthogSensor.probe(ctx, deps({ urls })); + assert.match(urls[0], /\/api\/projects\/123\/health_issues\/\?status=active&dismissed=false$/); + }); +}); diff --git a/src/health-sensors/posthog.ts b/src/health-sensors/posthog.ts new file mode 100644 index 00000000..9544f882 --- /dev/null +++ b/src/health-sensors/posthog.ts @@ -0,0 +1,116 @@ +import type { HealthFinding, HealthSensor } from "../health.js"; + +export interface PostHogHealthIssue { + id?: string; + kind?: string; + severity?: string; + status?: string; + dismissed?: boolean; + snoozed_until?: string | null; + title?: string; + summary?: string; + link?: string; +} + +export function parsePostHogHealthIssues(body: string): PostHogHealthIssue[] { + try { + const parsed = JSON.parse(body) as { results?: PostHogHealthIssue[] } | PostHogHealthIssue[]; + if (Array.isArray(parsed)) return parsed; + return Array.isArray(parsed.results) ? parsed.results : []; + } catch { + return []; + } +} + +export function activePostHogHealthIssues(issues: PostHogHealthIssue[]): PostHogHealthIssue[] { + return issues.filter( + (i) => (i.status ?? "").toLowerCase() === "active" && i.dismissed !== true && !i.snoozed_until, + ); +} + +export function posthogApiBase(): string { + const raw = + process.env.POSTHOG_API_HOST ?? + process.env.POSTHOG_HOST ?? + process.env.NEXT_PUBLIC_POSTHOG_HOST ?? + "https://us.posthog.com"; + const base = raw.replace(/\/+$/, "").replace(/\/api$/, ""); + if (base === "https://us.i.posthog.com") return "https://us.posthog.com"; + if (base === "https://eu.i.posthog.com") return "https://eu.posthog.com"; + return base; +} + +function severity(issue: PostHogHealthIssue): HealthFinding["severity"] { + switch ((issue.severity ?? "").toLowerCase()) { + case "critical": + return "critical"; + case "warning": + return "medium"; + case "info": + return "low"; + default: + return "medium"; + } +} + +export const posthogSensor: HealthSensor = { + id: "posthog", + async probe(_ctx, deps): Promise { + const token = process.env.POSTHOG_PERSONAL_API_KEY; + const project = process.env.POSTHOG_PROJECT_ID; + const source = project ? `posthog/${project}` : "(posthog project unset)"; + if (!token || !project) { + return [ + { + sensor: "posthog", + source, + status: "unknown", + title: "PostHog probe skipped: POSTHOG_PERSONAL_API_KEY / POSTHOG_PROJECT_ID not set", + detail: "set both to enable PostHog health issue checks", + }, + ]; + } + + const url = `${posthogApiBase()}/api/projects/${encodeURIComponent( + project, + )}/health_issues/?status=active&dismissed=false`; + const res = await deps.httpGet(url, { Authorization: `Bearer ${token}` }); + if (!res.ok) { + return [ + { + sensor: "posthog", + source, + status: "unknown", + title: `PostHog API returned HTTP ${res.status}`, + detail: "check POSTHOG_PERSONAL_API_KEY scope / POSTHOG_PROJECT_ID / POSTHOG_API_HOST", + }, + ]; + } + + const active = activePostHogHealthIssues(parsePostHogHealthIssues(res.body)); + if (active.length === 0) { + return [ + { + sensor: "posthog", + source, + status: "green", + title: "PostHog: no active unsnoozed health issues", + }, + ]; + } + + const first = active[0]; + const label = first.title ?? first.kind ?? first.id ?? "unknown issue"; + return [ + { + sensor: "posthog", + source, + status: "red", + severity: severity(first), + title: `PostHog: ${active.length} active health issue(s)`, + detail: label, + suggestedClass: "code", + }, + ]; + }, +}; diff --git a/src/health-sensors/tinybird.test.ts b/src/health-sensors/tinybird.test.ts new file mode 100644 index 00000000..743d1533 --- /dev/null +++ b/src/health-sensors/tinybird.test.ts @@ -0,0 +1,99 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { parseTinybirdJobs, tinybirdJobsByStatus, tinybirdSensor } from "./tinybird.js"; +import type { HealthCtx, HealthDeps, HttpResponse } from "../health.js"; + +const failedJobs = JSON.stringify({ + jobs: [ + { + id: "j1", + kind: "import", + status: "error", + datasource: { name: "events" }, + }, + ], +}); + +function deps(over: { http?: HttpResponse; urls?: string[] } = {}): HealthDeps { + return { + runCli: async () => ({ stdout: "", stderr: "", exitCode: 0, ok: true }), + httpGet: async (url) => { + over.urls?.push(url); + return over.http ?? { ok: true, status: 200, body: failedJobs }; + }, + }; +} + +const ctx: HealthCtx = { cwd: "/tmp/repo", config: {}, services: ["tinybird"] }; + +describe("parseTinybirdJobs / tinybirdJobsByStatus", () => { + it("separates failed and pending Tinybird jobs", () => { + const parsed = parseTinybirdJobs( + JSON.stringify({ + jobs: [ + { id: "j1", status: "error" }, + { id: "j2", status: "working" }, + { id: "j3", status: "waiting" }, + { id: "j4", status: "done" }, + ], + }), + ); + const grouped = tinybirdJobsByStatus(parsed); + assert.equal(grouped.failed.length, 1); + assert.equal(grouped.pending.length, 2); + assert.deepEqual(parseTinybirdJobs("nope"), []); + }); +}); + +describe("tinybirdSensor.probe", () => { + afterEach(() => { + delete process.env.TINYBIRD_TOKEN; + delete process.env.TINYBIRD_API_URL; + }); + + it("emits red when recent Tinybird jobs failed", async () => { + process.env.TINYBIRD_TOKEN = "p.token"; + const out = await tinybirdSensor.probe(ctx, deps()); + assert.equal(out[0].status, "red"); + assert.match(out[0].title, /failed job/); + assert.match(out[0].detail ?? "", /events/); + }); + + it("emits unknown while Tinybird jobs are still running", async () => { + process.env.TINYBIRD_TOKEN = "p.token"; + const body = JSON.stringify({ jobs: [{ id: "j2", kind: "copy", status: "working" }] }); + const out = await tinybirdSensor.probe(ctx, deps({ http: { ok: true, status: 200, body } })); + assert.equal(out[0].status, "unknown"); + assert.match(out[0].title, /still running/); + }); + + it("emits green when recent Tinybird jobs are done", async () => { + process.env.TINYBIRD_TOKEN = "p.token"; + const body = JSON.stringify({ jobs: [{ id: "j3", kind: "import", status: "done" }] }); + const out = await tinybirdSensor.probe(ctx, deps({ http: { ok: true, status: 200, body } })); + assert.equal(out[0].status, "green"); + }); + + it("is unknown when TINYBIRD_TOKEN is missing", async () => { + const out = await tinybirdSensor.probe(ctx, deps()); + assert.equal(out[0].status, "unknown"); + }); + + it("is unknown on a non-OK API response", async () => { + process.env.TINYBIRD_TOKEN = "p.token"; + const out = await tinybirdSensor.probe( + ctx, + deps({ http: { ok: false, status: 401, body: "" } }), + ); + assert.equal(out[0].status, "unknown"); + assert.match(out[0].title, /401/); + }); + + it("uses the configured Tinybird API host", async () => { + process.env.TINYBIRD_TOKEN = "p.token"; + process.env.TINYBIRD_API_URL = "https://api.eu-central-1.aws.tinybird.co/"; + const urls: string[] = []; + await tinybirdSensor.probe(ctx, deps({ urls })); + assert.equal(urls[0], "https://api.eu-central-1.aws.tinybird.co/v0/jobs"); + }); +}); diff --git a/src/health-sensors/tinybird.ts b/src/health-sensors/tinybird.ts new file mode 100644 index 00000000..ec33381f --- /dev/null +++ b/src/health-sensors/tinybird.ts @@ -0,0 +1,110 @@ +import type { HealthFinding, HealthSensor } from "../health.js"; + +export interface TinybirdJob { + id?: string; + job_id?: string; + kind?: string; + status?: string; + created_at?: string; + updated_at?: string; + job_url?: string; + datasource?: { name?: string }; +} + +export function parseTinybirdJobs(body: string): TinybirdJob[] { + try { + const parsed = JSON.parse(body) as { jobs?: TinybirdJob[] } | TinybirdJob[]; + if (Array.isArray(parsed)) return parsed; + return Array.isArray(parsed.jobs) ? parsed.jobs : []; + } catch { + return []; + } +} + +export function tinybirdJobsByStatus(jobs: TinybirdJob[]): { + failed: TinybirdJob[]; + pending: TinybirdJob[]; +} { + const failed = jobs.filter((j) => (j.status ?? "").toLowerCase() === "error"); + const pending = jobs.filter((j) => { + const status = (j.status ?? "").toLowerCase(); + return status === "waiting" || status === "working"; + }); + return { failed, pending }; +} + +function jobLabel(job: TinybirdJob): string { + const id = job.job_id ?? job.id ?? "?"; + const kind = job.kind ?? "job"; + const ds = job.datasource?.name ? ` datasource=${job.datasource.name}` : ""; + return `${kind} ${id}${ds}`; +} + +export const tinybirdSensor: HealthSensor = { + id: "tinybird", + async probe(_ctx, deps): Promise { + const token = process.env.TINYBIRD_TOKEN; + const base = (process.env.TINYBIRD_API_URL ?? "https://api.tinybird.co").replace(/\/+$/, ""); + if (!token) { + return [ + { + sensor: "tinybird", + source: "tinybird", + status: "unknown", + title: "Tinybird probe skipped: TINYBIRD_TOKEN not set", + detail: "set TINYBIRD_TOKEN to enable Tinybird job checks", + }, + ]; + } + + const res = await deps.httpGet(`${base}/v0/jobs`, { Authorization: `Bearer ${token}` }); + if (!res.ok) { + return [ + { + sensor: "tinybird", + source: base, + status: "unknown", + title: `Tinybird API returned HTTP ${res.status}`, + detail: "check TINYBIRD_TOKEN / TINYBIRD_API_URL", + }, + ]; + } + + const { failed, pending } = tinybirdJobsByStatus(parseTinybirdJobs(res.body)); + if (failed.length > 0) { + return [ + { + sensor: "tinybird", + source: base, + status: "red", + severity: "high", + title: `Tinybird: ${failed.length} failed job(s) in recent job history`, + detail: jobLabel(failed[0]), + suggestedClass: "code", + }, + ]; + } + if (pending.length > 0) { + return [ + { + sensor: "tinybird", + source: base, + status: "unknown", + severity: "medium", + title: `Tinybird: ${pending.length} job(s) still running or waiting`, + detail: `${jobLabel(pending[0])}; wait before declaring green`, + suggestedClass: "human", + }, + ]; + } + + return [ + { + sensor: "tinybird", + source: base, + status: "green", + title: "Tinybird: no failed or pending jobs in recent job history", + }, + ]; + }, +}; diff --git a/src/health-track.test.ts b/src/health-track.test.ts index bb9b5f08..f1a84384 100644 --- a/src/health-track.test.ts +++ b/src/health-track.test.ts @@ -17,10 +17,11 @@ const findings: HealthFinding[] = [ ]; describe("actionableHealth", () => { - it("keeps only red findings (green + unknown are not action items)", () => { + it("keeps every non-green finding (green is the only closed state)", () => { const out = actionableHealth(findings); - assert.equal(out.length, 1); + assert.equal(out.length, 2); assert.equal(out[0].title, "workflow failing: CI"); + assert.equal(out[1].title, "probe errored"); }); }); diff --git a/src/health-track.ts b/src/health-track.ts index 16eb84ee..8d53f9da 100644 --- a/src/health-track.ts +++ b/src/health-track.ts @@ -2,7 +2,7 @@ import type { HealthFinding } from "./health.js"; import type { SyncFinding } from "./memory/pal.js"; export function actionableHealth(findings: HealthFinding[]): HealthFinding[] { - return findings.filter((f) => f.status === "red"); + return findings.filter((f) => f.status !== "green"); } export function healthFindingToSync(f: HealthFinding): SyncFinding { diff --git a/src/health.test.ts b/src/health.test.ts index ed0cdcf2..6c6c93a3 100644 --- a/src/health.test.ts +++ b/src/health.test.ts @@ -1,6 +1,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { runHealth, type HealthSensor, type HealthCtx, type HealthDeps } from "./health.js"; +import { + runHealth, + healthOk, + type HealthSensor, + type HealthCtx, + type HealthDeps, +} from "./health.js"; import { selectSensors, defaultHealthDeps, HEALTH_SENSORS } from "./health.js"; const ctx: HealthCtx = { cwd: "/tmp/repo", config: {} }; @@ -41,6 +47,17 @@ describe("runHealth", () => { }); }); +describe("healthOk", () => { + it("requires every connected sensor finding to be green", () => { + assert.equal(healthOk([{ sensor: "a", source: "s", status: "green", title: "ok" }]), true); + assert.equal(healthOk([{ sensor: "a", source: "s", status: "red", title: "bad" }]), false); + assert.equal( + healthOk([{ sensor: "a", source: "s", status: "unknown", title: "still running" }]), + false, + ); + }); +}); + describe("selectSensors", () => { it("includes github-actions when a git remote is present", () => { const sel = selectSensors({ cwd: "/tmp/repo", config: {}, gitRemote: true }); @@ -77,6 +94,26 @@ describe("selectSensors", () => { ); }); + it("includes github-dependabot when Dependabot config is present", () => { + assert.ok( + selectSensors({ cwd: "/r", config: {}, gitRemote: true, githubDependabot: true }).some( + (s) => s.id === "github-dependabot", + ), + ); + assert.equal( + selectSensors({ cwd: "/r", config: {}, gitRemote: true }).some( + (s) => s.id === "github-dependabot", + ), + false, + ); + }); + + it("includes analytics sensors when analytics services are detected or declared", () => { + const sel = selectSensors({ cwd: "/r", config: {}, services: ["posthog", "tinybird"] }); + assert.ok(sel.some((s) => s.id === "posthog")); + assert.ok(sel.some((s) => s.id === "tinybird")); + }); + it("registry has all three CI sensors and defaultHealthDeps exposes runCli + httpGet", () => { assert.ok(HEALTH_SENSORS.length >= 3); assert.equal(typeof defaultHealthDeps.runCli, "function"); diff --git a/src/health.ts b/src/health.ts index 98666cc7..106cab4a 100644 --- a/src/health.ts +++ b/src/health.ts @@ -1,11 +1,14 @@ import type { kitConfig } from "./config.js"; import { execFileNoThrow, type ExecResult } from "./utils/execFileNoThrow.js"; import { githubActionsSensor } from "./health-sensors/github-actions.js"; +import { githubDependabotSensor } from "./health-sensors/github-dependabot.js"; import { gitlabSensor } from "./health-sensors/gitlab-ci.js"; import { bitbucketSensor } from "./health-sensors/bitbucket-pipelines.js"; import { vercelSensor } from "./health-sensors/vercel.js"; import { sentrySensor } from "./health-sensors/sentry.js"; import { resendSensor } from "./health-sensors/resend.js"; +import { posthogSensor } from "./health-sensors/posthog.js"; +import { tinybirdSensor } from "./health-sensors/tinybird.js"; import { supabaseAdvisorSensor } from "./health-sensors/supabase-advisor.js"; import { tlsCertSensor } from "./health-sensors/tls-cert.js"; @@ -32,6 +35,8 @@ export interface HealthCtx { gitlabCi?: boolean; /** True when a bitbucket-pipelines.yml is present (Bitbucket Pipelines in use). */ bitbucketPipelines?: boolean; + /** True when .github/dependabot.yml is present. */ + githubDependabot?: boolean; /** Vercel link from .vercel/project.json (orgId = teamId, projectId), if present. */ vercel?: { orgId?: string; projectId?: string }; /** Service ids the project is connected to (from the registry), for dep-detected sensors. */ @@ -83,31 +88,37 @@ export async function runHealth( export const HEALTH_SENSORS: HealthSensor[] = [ githubActionsSensor, + githubDependabotSensor, gitlabSensor, bitbucketSensor, vercelSensor, sentrySensor, resendSensor, + posthogSensor, + tinybirdSensor, supabaseAdvisorSensor, tlsCertSensor, ]; +const SERVICE_HEALTH_SENSORS = new Set(["sentry", "resend", "posthog", "tinybird"]); + /** Returns the sensors whose underlying CI platform the project actually uses. */ export function selectSensors(ctx: HealthCtx): HealthSensor[] { return HEALTH_SENSORS.filter((s) => { + if (SERVICE_HEALTH_SENSORS.has(s.id)) { + return ctx.services?.includes(s.id) === true; + } switch (s.id) { case "github-actions": return ctx.gitRemote === true || ctx.config.context?.github !== undefined; + case "github-dependabot": + return ctx.gitRemote === true && ctx.githubDependabot === true; case "gitlab-ci": return ctx.gitlabCi === true; case "bitbucket-pipelines": return ctx.bitbucketPipelines === true; case "vercel": return Boolean(ctx.vercel?.projectId); - case "sentry": - return ctx.services?.includes("sentry") === true; - case "resend": - return ctx.services?.includes("resend") === true; case "supabase-advisor": return ctx.services?.includes("supabase") === true; case "tls-cert": @@ -134,9 +145,18 @@ export const defaultHealthDeps: HealthDeps = { const MARK: Record = { green: "✓", red: "✗", unknown: "?" }; +export function healthOk(findings: HealthFinding[]): boolean { + return findings.every((f) => f.status === "green"); +} + /** Pure human formatter — returns lines + red count (CLI adds color). */ -export function formatHealth(findings: HealthFinding[]): { lines: string[]; redCount: number } { +export function formatHealth(findings: HealthFinding[]): { + lines: string[]; + redCount: number; + nonGreenCount: number; +} { const lines = findings.map((f) => `${MARK[f.status]} [${f.sensor}] ${f.title} (${f.source})`); const redCount = findings.filter((f) => f.status === "red").length; - return { lines, redCount }; + const nonGreenCount = findings.filter((f) => f.status !== "green").length; + return { lines, redCount, nonGreenCount }; } diff --git a/src/service-registry.test.ts b/src/service-registry.test.ts index f2d800f2..e8ea2a5a 100644 --- a/src/service-registry.test.ts +++ b/src/service-registry.test.ts @@ -68,6 +68,29 @@ describe("service-registry", () => { assert.ok(def, "posthog missing from registry"); assert.ok(def.login?.startsWith("#"), "no CLI login — must be an informational comment"); assert.ok(def.secrets?.includes("NEXT_PUBLIC_POSTHOG_KEY")); + assert.ok(def.secrets?.includes("POSTHOG_PERSONAL_API_KEY")); + assert.ok(def.secrets?.includes("POSTHOG_PROJECT_ID")); + }); + }); + + describe("tinybird — informational analytics service", () => { + it("detects tinybird by config file and SDK dependency", async () => { + const byFile = await detectServices({ + deps: [], + fileExists: async (p) => p === "tinybird.config.json", + }); + assert.deepEqual(byFile, ["tinybird"]); + + const byDep = await detectServices({ deps: ["tinybird"], fileExists: noFiles }); + assert.deepEqual(byDep, ["tinybird"]); + }); + + it("declares token/env keys and no CLI login", () => { + const def = SERVICE_BY_ID["tinybird"]; + assert.ok(def, "tinybird missing from registry"); + assert.ok(def.login?.startsWith("#"), "no CLI login — must be an informational comment"); + assert.ok(def.secrets?.includes("TINYBIRD_TOKEN")); + assert.ok(def.secrets?.includes("TINYBIRD_API_URL")); }); }); diff --git a/src/service-registry.ts b/src/service-registry.ts index 0ff3dbde..497a0795 100644 --- a/src/service-registry.ts +++ b/src/service-registry.ts @@ -154,7 +154,21 @@ export const SERVICE_REGISTRY: ServiceDef[] = [ pyDeps: ["posthog"], login: "# posthog — no CLI login; get keys from https://app.posthog.com/settings/project", check: "# posthog — check NEXT_PUBLIC_POSTHOG_KEY is set", - secrets: ["NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST"], + secrets: [ + "NEXT_PUBLIC_POSTHOG_KEY", + "NEXT_PUBLIC_POSTHOG_HOST", + "POSTHOG_PROJECT_ID", + "POSTHOG_PERSONAL_API_KEY", + ], + }, + { + id: "tinybird", + deps: ["tinybird"], + pyDeps: ["tinybird"], + files: ["tinybird.config.json"], + login: "# tinybird — no CLI login; set TINYBIRD_TOKEN in env", + check: "# tinybird — check TINYBIRD_TOKEN is set", + secrets: ["TINYBIRD_TOKEN", "TINYBIRD_API_URL"], }, { id: "typeorm",