From dda8de5d028a4709b76a3c18abea1fae5ce0bb3e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:00:33 -0700 Subject: [PATCH] feat(observability): add GitHub issue stats to the maintainer dashboard The Reviews & PRs (maintainer) dashboard tracked pull requests only -- there was no issue-activity visibility without switching to the separate upstream GitHub dashboard. Add an "Issues opened/closed/open" stat row using the same frser-sqlite-datasource plus windowed-updated_at convention every existing panel on this dashboard already uses, rather than the live-API grafana-github-datasource the sibling dashboard uses for its own "Open issues" panel -- matching this dashboard's own established, webhook-observed philosophy instead of introducing a second datasource type into one dashboard. The tradeoff and its caveat are documented on the dashboard itself (root description plus per-panel descriptions) and cross-link to the sibling dashboard for an upstream-accurate, time-range-independent count. That local issues table was never mirrored into the redacted reporting export the dashboard actually reads (only review_targets and ai_usage_events were), so also extend export-grafana-reporting-db.sh with a minimal issues mirror (repo, number, state, created_at, updated_at only -- no title/labels/payload, since these are count-only stat panels) across both the Postgres and SQLite source paths, including the incremental fingerprint that detects an in-place state change. Closes #3716 --- grafana/dashboards/maintainer-reviews.json | 42 ++++++- scripts/export-grafana-reporting-db.sh | 66 +++++++++-- test/unit/selfhost-grafana-dashboard.test.ts | 69 +++++++++++ test/unit/selfhost-grafana-reporting.test.ts | 113 ++++++++++++++++++- 4 files changed, 281 insertions(+), 9 deletions(-) diff --git a/grafana/dashboards/maintainer-reviews.json b/grafana/dashboards/maintainer-reviews.json index 68bd54002b..3bdacaef7b 100644 --- a/grafana/dashboards/maintainer-reviews.json +++ b/grafana/dashboards/maintainer-reviews.json @@ -4,7 +4,8 @@ "tags": ["gittensory", "maintainer"], "timezone": "browser", "schemaVersion": 39, - "version": 5, + "version": 6, + "description": "All panels, including the 'GitHub issues' row, read the local frser-sqlite-datasource reporting export (webhook-observed, consistent with this dashboard's own established datasource) rather than the grafana-github-datasource live-API plugin used on the sibling 'Gittensory — Upstream PRs & issues (GitHub)' dashboard -- so this only reflects repos/events this app has actually received webhooks for, not a full upstream census. For an upstream-accurate, time-range-independent open-issue count instead, see that sibling dashboard's 'Open issues' panel (#3716).", "editable": false, "graphTooltip": 1, "refresh": "1m", @@ -136,6 +137,45 @@ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "unit": "short" }, "overrides": [] }, "options": { "legend": { "displayMode": "list", "placement": "right", "showLegend": true, "values": ["value", "percent"] }, "pieType": "donut", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": true }, "tooltip": { "mode": "single", "sort": "none" } }, "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT verdict, count(*) AS c FROM review_targets WHERE verdict IS NOT NULL AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY verdict ORDER BY c DESC", "rawQueryText": "SELECT verdict, count(*) AS c FROM review_targets WHERE verdict IS NOT NULL AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY verdict ORDER BY c DESC" }] + }, + { + "type": "row", + "id": 11, + "title": "GitHub issues", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 29 } + }, + { + "type": "stat", + "id": 12, + "title": "Issues opened", + "description": "Counts issues whose created_at falls in this window -- unlike every other panel on this dashboard (which key off updated_at), an issue's created_at never changes after the fact, so this is the one panel that answers 'newly opened' rather than 'most recently observed active' for the range (#3716).", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, + "gridPos": { "h": 4, "w": 8, "x": 0, "y": 30 }, + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" }, "unit": "short" }, "overrides": [] }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS opened FROM issues WHERE unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS opened FROM issues WHERE unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}" }] + }, + { + "type": "stat", + "id": 13, + "title": "Issues closed", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 30 }, + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "red" }, "unit": "short" }, "overrides": [] }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS closed FROM issues WHERE state='closed' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS closed FROM issues WHERE state='closed' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] + }, + { + "type": "stat", + "id": 14, + "title": "Issues open", + "description": "Counts issues whose most recent observed update falls in this window AND are currently 'open' -- the same in-window-latest-state snapshot semantics as 'Manual review' above, NOT a lifetime open-issue backlog: an issue opened long before this window and untouched since will not appear here. For the upstream-accurate, time-range-independent open-issue count, see the 'Open issues' panel on the 'Gittensory — Upstream PRs & issues (GitHub)' dashboard, which queries the live GitHub API instead of this webhook-observed local table (#3716).", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 30 }, + "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "orange" }, "unit": "short" }, "overrides": [] }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS open FROM issues WHERE state='open' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS open FROM issues WHERE state='open' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] } ] } diff --git a/scripts/export-grafana-reporting-db.sh b/scripts/export-grafana-reporting-db.sh index ec8d96f8c0..e40a489a37 100644 --- a/scripts/export-grafana-reporting-db.sh +++ b/scripts/export-grafana-reporting-db.sh @@ -137,7 +137,7 @@ SQL # Incremental fast-path (#3895): a live review pipeline is bursty -- most 30s cycles change nothing since the # last export, yet the full rebuild below re-exports and re-imports every row of every table every time. Hash -# the complete source rows for mutable tables (pull_requests, review_targets) since an in-place UPDATE can +# the complete source rows for mutable tables (pull_requests, review_targets, issues) since an in-place UPDATE can # leave row count and max(updated_at) unchanged; use a cheap count+max aggregate for insert-only tables # (review_audit, ai_usage_events) since nothing ever edits a row in place there, and ai_usage_events grows # without bound so a full dump/hash on every cycle would reproduce the unbounded I/O #3895 was fixing. Skip @@ -164,8 +164,8 @@ sqlite_table_fingerprint() { # review_audit/ai_usage_events are insert-only event/audit logs (nothing ever UPDATEs a row in # place), so a row count + max(created_at) aggregate can never miss a real change -- and unlike # the full-dump hash above, it stays O(1)-ish instead of O(row-count) as ai_usage_events grows -# without bound. pull_requests/review_targets DO receive in-place UPDATEs (e.g. a title or state -# change that doesn't necessarily bump updated_at in lockstep), so those still need the full +# without bound. pull_requests/review_targets/issues DO receive in-place UPDATEs (e.g. a title or +# state change that doesn't necessarily bump updated_at in lockstep), so those still need the full # content hash to catch an edit a count+max aggregate would silently miss. sqlite_append_only_fingerprint() { tbl="$1" @@ -175,7 +175,7 @@ sqlite_append_only_fingerprint() { sqlite_source_fingerprint() { [ -s "$APP_DB" ] || return 1 fp="" - for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events"; do + for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events" "issues"; do if source_table_exists "$tbl"; then case "$tbl" in review_audit | ai_usage_events) val="$(sqlite_append_only_fingerprint "$tbl")" || return 1 ;; @@ -198,7 +198,7 @@ pg_append_only_fingerprint() { pg_source_fingerprint() { fp="" - for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events"; do + for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events" "issues"; do if pg_table_exists "$tbl"; then case "$tbl" in review_audit | ai_usage_events) val="$(pg_append_only_fingerprint "$tbl")" || return 1 ;; @@ -282,6 +282,21 @@ CREATE TABLE ai_usage_events ( CREATE INDEX ai_usage_events_feature_created_idx ON ai_usage_events(feature, created_at); CREATE INDEX ai_usage_events_model_created_idx ON ai_usage_events(model, created_at); CREATE INDEX ai_usage_events_provider_created_idx ON ai_usage_events(provider, created_at); + +-- #3716: minimal redacted mirror of the app `issues` table (webhook-observed, same philosophy as +-- review_targets above) -- just enough for dashboard issue-activity stat panels. No title/labels/payload: +-- unlike review_targets (whose title already surfaces on the PR table panel), this table backs count-only +-- stat panels, so there is no dashboard need to carry issue titles into the reporting export at all. +CREATE TABLE issues ( + repo TEXT NOT NULL, + number INTEGER NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX issues_updated_idx ON issues(updated_at); +CREATE INDEX issues_state_idx ON issues(state); +CREATE INDEX issues_created_idx ON issues(created_at); SQL if pg_enabled; then @@ -296,7 +311,8 @@ if pg_enabled; then ! pg_table_exists "advisories" && ! pg_table_exists "review_targets" && ! pg_table_exists "ai_usage_events" && - ! pg_table_exists "review_audit"; then + ! pg_table_exists "review_audit" && + ! pg_table_exists "issues"; then if [ -s "$OUT_DB" ]; then rm -f "$TMP_DB" "$TMP_DB-wal" "$TMP_DB-shm" echo "reporting export skipped: no reporting source tables in Postgres; preserving last good $OUT_DB" >&2 @@ -433,6 +449,20 @@ FROM ai_usage_events sqlite_import_csv "$AI_CSV" "ai_usage_events" fi + if pg_table_exists "issues"; then + ISSUES_CSV="$(csv_temp_file "issues")" + pg_copy_csv " +SELECT + repo_full_name AS repo, + number, + state, + created_at, + updated_at +FROM issues +" "$ISSUES_CSV" + sqlite_import_csv "$ISSUES_CSV" "issues" + fi + sqlite3 "$TMP_DB" "PRAGMA quick_check;" | grep -qx "ok" mv "$TMP_DB" "$OUT_DB" rm -f "$TMP_DB-wal" "$TMP_DB-shm" @@ -459,7 +489,8 @@ if ! source_table_exists "pull_requests" && ! source_table_exists "advisories" && ! source_table_exists "review_targets" && ! source_table_exists "ai_usage_events" && - ! source_table_exists "review_audit"; then + ! source_table_exists "review_audit" && + ! source_table_exists "issues"; then if [ -s "$OUT_DB" ]; then rm -f "$TMP_DB" "$TMP_DB-wal" "$TMP_DB-shm" echo "reporting export skipped: no reporting source tables in $APP_DB; preserving last good $OUT_DB" >&2 @@ -632,6 +663,27 @@ DETACH report; " fi +if source_table_exists "issues"; then + sqlite3 -cmd ".timeout 5000" "$APP_DB" " +ATTACH '$TMP_DB_SQL' AS report; +INSERT INTO report.issues ( + repo, + number, + state, + created_at, + updated_at +) +SELECT + repo_full_name, + number, + state, + created_at, + updated_at +FROM main.issues; +DETACH report; +" +fi + sqlite3 "$TMP_DB" "PRAGMA quick_check;" | grep -qx "ok" mv "$TMP_DB" "$OUT_DB" rm -f "$TMP_DB-wal" "$TMP_DB-shm" diff --git a/test/unit/selfhost-grafana-dashboard.test.ts b/test/unit/selfhost-grafana-dashboard.test.ts index fe8f5099cf..49ab96bfbd 100644 --- a/test/unit/selfhost-grafana-dashboard.test.ts +++ b/test/unit/selfhost-grafana-dashboard.test.ts @@ -21,6 +21,7 @@ type DashboardPanel = { }; type Dashboard = { + description?: string; panels: DashboardPanel[]; }; @@ -50,6 +51,12 @@ function reviewTargets(dashboard = readDashboard()): DashboardTarget[] { .filter((target) => target.queryText?.includes("review_targets")); } +function issueTargets(dashboard = readDashboard()): DashboardTarget[] { + return dashboard.panels + .flatMap((panel) => panel.targets ?? []) + .filter((target) => target.queryText?.includes("FROM issues")); +} + function targetForPanel(panelId: number): DashboardTarget { const panel = readDashboard().panels.find((candidate) => candidate.id === panelId); const target = panel?.targets?.[0]; @@ -297,4 +304,66 @@ describe("maintainer Reviews & PRs Grafana dashboard", () => { expect(rows).toContain("owner/repo|2|new|commented|comment|new row|2026-06-29T21:00:00Z"); expect(rows).not.toContain("old row"); }); + + it("binds every issues panel query to Grafana's selected time range, keying opened off created_at and closed/open off updated_at (#3716)", () => { + const targets = issueTargets(); + + expect(targets.length).toBe(3); + for (const target of targets) { + expect(target.rawQueryText).toBe(target.queryText); + expect(target.queryText).toContain(timeFrom); + expect(target.queryText).toContain(timeTo); + } + // "Issues opened" is the deliberate exception to this dashboard's updated_at convention: an issue's + // created_at never changes after the fact, so it is the only one of the three keyed off created_at. + expect(targets.filter((target) => target.queryText?.includes("unixepoch(created_at)")).length).toBe(1); + expect(targets.filter((target) => target.queryText?.includes("unixepoch(updated_at)")).length).toBe(2); + }); + + it("documents the issues row's datasource choice and the in-window-snapshot caveat (#3716)", () => { + const dashboard = readDashboard(); + expect(dashboard.description).toContain("frser-sqlite-datasource"); + expect(dashboard.description).toContain("Upstream PRs & issues (GitHub)"); + + const panelsById = new Map(dashboard.panels.map((panel) => [panel.id, panel])); + const opened = panelsById.get(12); + expect(opened?.title).toBe("Issues opened"); + expect(opened?.description).toContain("created_at"); + + const open = panelsById.get(14); + expect(open?.title).toBe("Issues open"); + expect(open?.description?.length ?? 0).toBeGreaterThan(0); + expect(open?.description).toContain("window"); + expect(open?.description).toContain("Open issues"); + }); + + (sqliteCliAvailable ? it : it.skip)("counts issues opened/closed/open within the selected time window, each driven by a different row (#3716)", () => { + const root = tmpRoot(); + const db = join(root, "reporting.sqlite"); + sqlite(db, ` + CREATE TABLE issues ( + repo TEXT NOT NULL, + number INTEGER NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO issues (repo, number, state, created_at, updated_at) + VALUES + ('owner/repo', 1, 'open', '2026-06-29T20:15:00Z', '2026-06-29T20:15:00Z'), + ('owner/repo', 2, 'closed', '2026-06-20T00:00:00Z', '2026-06-29T20:45:00Z'), + ('owner/repo', 3, 'open', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'); + `); + + const openedQuery = expandGrafanaRange(targetForPanel(12).queryText!); + const closedQuery = expandGrafanaRange(targetForPanel(13).queryText!); + const openQuery = expandGrafanaRange(targetForPanel(14).queryText!); + + // Row 1: created AND updated in-window, open -> counts for "opened" and "open", not "closed". + // Row 2: created OUTSIDE the window but updated inside it, closed -> counts for "closed" only. + // Row 3: created and updated well outside the window, open -> excluded from every count. + expect(sqlite(db, openedQuery)).toBe("1"); + expect(sqlite(db, closedQuery)).toBe("1"); + expect(sqlite(db, openQuery)).toBe("1"); + }); }); diff --git a/test/unit/selfhost-grafana-reporting.test.ts b/test/unit/selfhost-grafana-reporting.test.ts index 04a4ba5d9a..d8c5ff7324 100644 --- a/test/unit/selfhost-grafana-reporting.test.ts +++ b/test/unit/selfhost-grafana-reporting.test.ts @@ -72,7 +72,7 @@ case "$args" in echo 'unexpected psql meta-command copy' >&2 exit 9 ;; - *"information_schema.tables"*"pull_requests"*|*"information_schema.tables"*"advisories"*|*"information_schema.tables"*"review_targets"*|*"information_schema.tables"*"ai_usage_events"*|*"information_schema.tables"*"review_audit"*) + *"information_schema.tables"*"pull_requests"*|*"information_schema.tables"*"advisories"*|*"information_schema.tables"*"review_targets"*|*"information_schema.tables"*"ai_usage_events"*|*"information_schema.tables"*"review_audit"*|*"information_schema.tables"*"issues"*) printf '1\\n' ;; *"information_schema.columns"*"ai_usage_events"*"estimated_neurons"*|\ @@ -95,6 +95,10 @@ case "$args" in printf 'ai_review_pr,codex:gpt-5.5,codex,medium,ok,42,120,15,135,0.25,done,"{""repoFullName"" : ""JSONbored/gittensory"", ""pullNumber"" : 1678}",2026-06-28T00:00:00Z\\n' printf 'issue_plan,codex:gpt-5.5,codex,medium,ok,8,50,10,60,0.05,done,"{""repoFullName"" : ""JSONbored/gittensory"", ""pullNumber"" : null}",2026-06-28T00:01:00Z\\n' ;; + *"FROM issues"*) + printf '"JSONbored/gittensory",1701,open,2026-06-28T20:00:00Z,2026-06-28T21:41:00Z\\n' + printf '"JSONbored/metagraphed",42,closed,2026-06-20T00:00:00Z,2026-06-28T21:42:00Z\\n' + ;; esac `, ); @@ -469,6 +473,107 @@ esac ); }); + // ── GitHub Issues stats mirror (#3716) ────────────────────────────────────────────────────────────── + it("exports a redacted issues mirror across every repo the app has observed, matching the dashboard's opened/closed/open stat queries", () => { + const root = tmpRoot(); + const appDb = join(root, "app.sqlite"); + const outDb = join(root, "reporting.sqlite"); + sqlite(appDb, ` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + author_login TEXT, + labels_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO issues (id, repo_full_name, number, title, state, created_at, updated_at) + VALUES + ('JSONbored/gittensory#100', 'JSONbored/gittensory', 100, 'an open gittensory issue', 'open', '2026-07-01T00:00:00Z', '2026-07-01T00:00:00Z'), + ('JSONbored/gittensory#101', 'JSONbored/gittensory', 101, 'a closed gittensory issue', 'closed', '2026-06-01T00:00:00Z', '2026-07-02T00:00:00Z'), + ('JSONbored/metagraphed#5', 'JSONbored/metagraphed', 5, 'an open metagraphed issue', 'open', '2026-07-03T00:00:00Z', '2026-07-03T00:00:00Z'); + + CREATE TABLE review_audit ( + id TEXT NOT NULL, target_id TEXT NOT NULL, event_type TEXT NOT NULL, decision TEXT, + source TEXT NOT NULL, created_at TEXT NOT NULL + ); + `); + + runExporter(root, appDb, outDb); + + expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); + // No title/labels/payload leak into the redacted mirror -- only the columns the stat panels need. + expect(sqlite(outDb, "SELECT sql FROM sqlite_master WHERE name = 'issues';")).not.toContain("title"); + expect(sqlite(outDb, "SELECT repo || '|' || number || '|' || state FROM issues ORDER BY repo, number;")).toBe( + "JSONbored/gittensory|100|open\nJSONbored/gittensory|101|closed\nJSONbored/metagraphed|5|open", + ); + // The exact query shapes the dashboard panels use (#3716): opened keys off created_at (an issue's + // created_at never moves), closed/open key off updated_at within the window, mirroring the existing + // PR panels' unixepoch(updated_at) convention. + const windowFrom = "unixepoch('2026-06-15T00:00:00Z')"; + const windowTo = "unixepoch('2026-07-08T00:00:00Z')"; + expect(sqlite(outDb, `SELECT count(*) FROM issues WHERE unixepoch(created_at) >= ${windowFrom} AND unixepoch(created_at) < ${windowTo};`)).toBe("2"); + expect(sqlite(outDb, `SELECT count(*) FROM issues WHERE state='closed' AND unixepoch(updated_at) >= ${windowFrom} AND unixepoch(updated_at) < ${windowTo};`)).toBe("1"); + expect(sqlite(outDb, `SELECT count(*) FROM issues WHERE state='open' AND unixepoch(updated_at) >= ${windowFrom} AND unixepoch(updated_at) < ${windowTo};`)).toBe("2"); + }); + + it("keeps the reporting schema valid and the issues mirror empty when the source predates the issues table", () => { + const root = tmpRoot(); + const appDb = join(root, "app.sqlite"); + const outDb = join(root, "reporting.sqlite"); + sqlite(appDb, ` + CREATE TABLE pull_requests ( + repo_full_name TEXT NOT NULL, number INTEGER NOT NULL, title TEXT NOT NULL, state TEXT NOT NULL, + author_login TEXT, merged_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + INSERT INTO pull_requests (repo_full_name, number, title, state, author_login, merged_at, created_at, updated_at) + VALUES ('JSONbored/gittensory', 6001, 'a PR from a pre-issues-table install', 'open', 'JSONbored', NULL, '2026-07-06T00:00:00Z', '2026-07-06T00:00:00Z'); + + CREATE TABLE review_audit ( + id TEXT NOT NULL, target_id TEXT NOT NULL, event_type TEXT NOT NULL, decision TEXT, + source TEXT NOT NULL, created_at TEXT NOT NULL + ); + `); + + runExporter(root, appDb, outDb); + + expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); + expect(sqlite(outDb, "SELECT count(*) FROM issues;")).toBe("0"); + expect(sqlite(outDb, "SELECT count(*) FROM review_targets;")).toBe("1"); + }); + + it("redoes the rebuild when an issue's state flips in place, even though row count and max(created_at) stay unchanged (full-hash fingerprint, not the append-only aggregate)", () => { + const root = tmpRoot(); + const appDb = join(root, "app.sqlite"); + const outDb = join(root, "reporting.sqlite"); + sqlite(appDb, ` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, repo_full_name TEXT NOT NULL, number INTEGER NOT NULL, title TEXT NOT NULL, + state TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + INSERT INTO issues (id, repo_full_name, number, title, state, created_at, updated_at) + VALUES ('JSONbored/gittensory#7001', 'JSONbored/gittensory', 7001, 'will be closed', 'open', '2026-07-06T00:00:00Z', '2026-07-06T00:00:00Z'); + + CREATE TABLE review_audit ( + id TEXT NOT NULL, target_id TEXT NOT NULL, event_type TEXT NOT NULL, decision TEXT, + source TEXT NOT NULL, created_at TEXT NOT NULL + ); + `); + runExporter(root, appDb, outDb); + expect(sqlite(outDb, "SELECT state FROM issues WHERE number = 7001;")).toBe("open"); + + // Row count and max(created_at) are IDENTICAL before and after -- only `state` (and updated_at, + // deliberately left untouched here to isolate the effect) changed. + sqlite(appDb, "UPDATE issues SET state = 'closed' WHERE number = 7001;"); + const second = runExporter(root, appDb, outDb); + expect(second).toContain("reporting export complete"); + expect(sqlite(outDb, "SELECT state FROM issues WHERE number = 7001;")).toBe("closed"); + }); + it("copies durable AI usage estimate rows into the redacted reporting database", () => { const root = tmpRoot(); const appDb = join(root, "app.sqlite"); @@ -552,6 +657,12 @@ esac expect(sqlite(outDb, "SELECT provider || '|' || effort || '|' || input_tokens || '|' || output_tokens || '|' || total_tokens || '|' || cost_usd FROM ai_usage_events ORDER BY created_at;")).toBe("codex|medium|120|15|135|0.25\ncodex|medium|50|10|60|0.05"); expect(sqlite(outDb, "SELECT DISTINCT json_extract(metadata_json, '$.repoFullName') FROM ai_usage_events;")).toBe("JSONbored/gittensory"); expect(sqlite(outDb, "SELECT group_concat(json_extract(metadata_json, '$.private') IS NULL, '|') FROM ai_usage_events;")).toBe("1|1"); + // #3716: the redacted `issues` mirror travels through the same Postgres COPY pipeline, across more than + // one repo -- proving the export is repo-agnostic, not a gittensory-only special case. + expect(sqlite(outDb, "SELECT count(*) FROM issues;")).toBe("2"); + expect(sqlite(outDb, "SELECT repo || '|' || number || '|' || state FROM issues ORDER BY repo;")).toBe( + "JSONbored/gittensory|1701|open\nJSONbored/metagraphed|42|closed", + ); expect(readdirSync(csvTmp)).toEqual([]); });