From 124553d4e71487e1bea0ea6c2950faa455630a1a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:06:33 -0700 Subject: [PATCH] fix(observability): repo-scope + fix stale data on the Reviews & PRs dashboard Three bugs in grafana/dashboards/maintainer-reviews.json: issue panels hit GitHub Search's 1000-result cap via a hardcoded org query (switched to the local, uncapped issues table already used by the PR panels), the Ignored panel queried dead status/verdict values the export mapping hasn't produced since a prior migration (redefined around a value the current model produces, plus a SCRIPT_VERSION fingerprint so future export-logic-only edits invalidate the cache), and no per-repo filter existed anywhere on the dashboard (added a dynamic $repo template variable backed by a live query, not a hardcoded list). Fixes noUncheckedIndexedAccess TS2532 errors in the new $repo template variable test -- vars[0] needs the non-null assertion already used elsewhere in this codebase after the preceding toHaveLength(1) proves the array is non-empty. --- grafana/dashboards/maintainer-reviews.json | 70 +++++---- scripts/export-grafana-reporting-db.sh | 77 +++++++++- test/unit/selfhost-grafana-dashboard.test.ts | 153 ++++++++++++++++--- test/unit/selfhost-grafana-reporting.test.ts | 70 ++++++++- 4 files changed, 317 insertions(+), 53 deletions(-) diff --git a/grafana/dashboards/maintainer-reviews.json b/grafana/dashboards/maintainer-reviews.json index 5cf6fc3272..e698d83e70 100644 --- a/grafana/dashboards/maintainer-reviews.json +++ b/grafana/dashboards/maintainer-reviews.json @@ -4,12 +4,28 @@ "tags": ["gittensory", "maintainer"], "timezone": "browser", "schemaVersion": 39, - "version": 6, + "version": 7, "editable": false, "graphTooltip": 1, "refresh": "1m", "time": { "from": "now-90d", "to": "now" }, - "templating": { "list": [] }, + "templating": { + "list": [ + { + "name": "repo", + "label": "Repo", + "type": "query", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, + "query": { "queryType": "table", "rawQueryText": "SELECT DISTINCT repo FROM review_targets ORDER BY repo" }, + "includeAll": true, + "allValue": "$__all", + "multi": false, + "current": { "text": "All", "value": "$__all" }, + "refresh": 2, + "sort": 1 + } + ] + }, "annotations": { "list": [] }, "links": [], "panels": [ @@ -29,7 +45,7 @@ "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, "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 prs FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS prs FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS prs FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS prs FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, { "type": "stat", @@ -39,7 +55,7 @@ "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "green" }, "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 merged FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='merged' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS merged FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='merged' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS merged FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='merged' AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS merged FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='merged' AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, { "type": "stat", @@ -49,7 +65,7 @@ "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, "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 review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='closed' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS closed FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='closed' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS closed FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='closed' AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS closed FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='closed' AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, { "type": "stat", @@ -60,29 +76,29 @@ "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, "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 manual FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (status='manual' OR verdict='manual') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS manual FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (status='manual' OR verdict='manual') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS manual FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (status='manual' OR verdict='manual') AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS manual FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (status='manual' OR verdict='manual') AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, { "type": "stat", "id": 6, - "title": "Commented (advisory)", - "description": "Counts PRs whose most recent status update falls in this window AND is currently 'commented' -- the same in-window-latest-state semantics as 'Manual review'. A PR that was commented on and later merged or closed within the same window shows only under Merged/Closed, so this under-reports total advisory-comment activity for anyone who resolves their queue promptly.", + "title": "Approved (pending merge)", + "description": "Counts PRs whose most recent status update falls in this window AND is currently 'commented' (the gate decided to merge but the PR hasn't merged yet) -- the same in-window-latest-state semantics as 'Manual review'. A PR that was approved and later actually merged or closed within the same window shows only under Merged/Closed, so this under-reports total approval activity for anyone who resolves their queue promptly.", "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, "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 commented FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='commented' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS commented FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='commented' AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS commented FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='commented' AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS commented FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='commented' AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, { "type": "stat", "id": 7, - "title": "Ignored", - "description": "Counts PRs whose most recent status update falls in this window AND is currently 'ignored' -- the same in-window-latest-state semantics as 'Manual review'. If a PR's status changes again later in the same window (e.g. re-triaged into manual/commented/merged/closed), it moves out of this count entirely rather than accumulating, so this does not track how many times a PR was ever ignored.", + "title": "Ignored (no gate decision yet)", + "description": "Counts PRs whose most recent status update falls in this window AND the gate has never recorded a decision for (status='manual' with no hold/close verdict behind it -- distinct from a PR the gate actively held or closed). Redefined 2026-07 (was 'status=ignored OR verdict=ignore', values a #3511/#3535 migration to review_audit-based gate decisions stopped producing years ago -- the export can no longer set either).", "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "purple" }, "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 ignored FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (status='ignored' OR verdict='ignore') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS ignored FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (status='ignored' OR verdict='ignore') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS ignored FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='manual' AND verdict IS NULL AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS ignored FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND status='manual' AND verdict IS NULL AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, { "type": "row", @@ -95,34 +111,34 @@ "type": "stat", "id": 12, "title": "Issues opened", - "description": "Live GitHub-API count via the grafana-github-datasource (org:JSONbored is:issue), not the local webhook-observed issues table -- upstream-accurate regardless of local sync gaps, at the cost of a different consistency model from the review_targets-backed PR panels above. See #3716 for the tradeoff.", - "datasource": { "type": "grafana-github-datasource", "uid": "github" }, + "description": "Local, webhook-observed count via the gittensory-db issues table, same consistency model as the PR panels above (#3716's option 1) -- not a live GitHub-API census, so it inherits the same webhook-completeness caveat the PR data already has. Switched 2026-07 from the grafana-github-datasource live-API path (#3716's option 2), which hardcoded org:JSONbored (broken for any other self-hoster) and silently hit GitHub Search's 1000-result cap with no disclosure on this dashboard.", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "gridPos": { "h": 4, "w": 8, "x": 0, "y": 6 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" } } }, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["count"], "fields": "/^number$/" } }, - "targets": [{ "refId": "A", "queryType": "Issues", "owner": "", "repository": "", "options": { "query": "org:JSONbored is:issue", "timeField": 1 } }] + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS opened FROM issues WHERE ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS opened FROM issues WHERE ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}" }] }, { "type": "stat", "id": 13, "title": "Issues closed", - "description": "Live GitHub-API count via the grafana-github-datasource (org:JSONbored is:issue is:closed), not the local webhook-observed issues table. See #3716 for the tradeoff.", - "datasource": { "type": "grafana-github-datasource", "uid": "github" }, + "description": "Local, webhook-observed count via the gittensory-db issues table -- see 'Issues opened' for the 2026-07 switch from the live GitHub-API path. Uses the issue's last-updated timestamp as the closing-transition signal (no separate closed_at column exists), the same approach the PR panels above already use for their own updated_at-based windowing.", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "gridPos": { "h": 4, "w": 8, "x": 8, "y": 6 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "red" } } }, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["count"], "fields": "/^number$/" } }, - "targets": [{ "refId": "A", "queryType": "Issues", "owner": "", "repository": "", "options": { "query": "org:JSONbored is:issue is:closed", "timeField": 0 } }] + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS closed FROM issues WHERE state='closed' AND ('$repo' = '$__all' OR repo = '$repo') 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 ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, { "type": "stat", "id": 14, "title": "Issues open", - "description": "Current open-issue backlog (org:JSONbored is:issue is:open) -- a state snapshot, not filtered by the dashboard time window, mirroring github-prs.json's own 'Open issues' panel.", - "datasource": { "type": "grafana-github-datasource", "uid": "github" }, + "description": "Current open-issue backlog from the local, webhook-observed issues table -- a state snapshot, not filtered by the dashboard time window, same semantics as the original live-API version of this panel.", + "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "gridPos": { "h": 4, "w": 8, "x": 16, "y": 6 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "orange" } } }, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["count"], "fields": "/^number$/" } }, - "targets": [{ "refId": "A", "queryType": "Issues", "owner": "", "repository": "", "options": { "query": "org:JSONbored is:issue is:open" } }] + "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS open FROM issues WHERE state='open' AND ('$repo' = '$__all' OR repo = '$repo')", "rawQueryText": "SELECT count(*) AS open FROM issues WHERE state='open' AND ('$repo' = '$__all' OR repo = '$repo')" }] }, { "type": "table", @@ -156,7 +172,7 @@ ] }, "options": { "showHeader": true, "cellHeight": "sm", "footer": { "show": false }, "sortBy": [{ "displayName": "updated_at", "desc": true }] }, - "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT repo, number, submitter AS author, status, verdict, title, updated_at FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} ORDER BY updated_at DESC LIMIT 1000", "rawQueryText": "SELECT repo, number, submitter AS author, status, verdict, title, updated_at FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} ORDER BY updated_at DESC LIMIT 1000" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT repo, number, submitter AS author, status, verdict, title, updated_at FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} ORDER BY updated_at DESC LIMIT 1000", "rawQueryText": "SELECT repo, number, submitter AS author, status, verdict, title, updated_at FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} ORDER BY updated_at DESC LIMIT 1000" }] }, { "type": "timeseries", @@ -166,7 +182,7 @@ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1, "showPoints": "never", "stacking": { "mode": "none" } }, "unit": "short" }, "overrides": [] }, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false }, "tooltip": { "mode": "single", "sort": "none" } }, - "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "time series", "timeColumns": ["time"], "queryText": "SELECT date(updated_at) AS time, count(*) AS reviews FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY date(updated_at) ORDER BY time", "rawQueryText": "SELECT date(updated_at) AS time, count(*) AS reviews FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY date(updated_at) ORDER BY time" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "time series", "timeColumns": ["time"], "queryText": "SELECT date(updated_at) AS time, count(*) AS reviews FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY date(updated_at) ORDER BY time", "rawQueryText": "SELECT date(updated_at) AS time, count(*) AS reviews FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY date(updated_at) ORDER BY time" }] }, { "type": "piechart", @@ -176,7 +192,7 @@ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 }, "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 (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND 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 (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND 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" }] + "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT verdict, count(*) AS c FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND verdict IS NOT NULL AND ('$repo' = '$__all' OR repo = '$repo') 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 (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND verdict IS NOT NULL AND ('$repo' = '$__all' OR repo = '$repo') AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY verdict ORDER BY c DESC" }] } ] } diff --git a/scripts/export-grafana-reporting-db.sh b/scripts/export-grafana-reporting-db.sh index ec8d96f8c0..e48f0e5fbd 100644 --- a/scripts/export-grafana-reporting-db.sh +++ b/scripts/export-grafana-reporting-db.sh @@ -1,6 +1,14 @@ #!/bin/sh set -eu +# Bump whenever this script's own mapping/export LOGIC changes (not just when a new table is added -- +# any change to how existing columns are derived, e.g. the status/verdict CASE statements below). The +# incremental fast-path below only fingerprints SOURCE DATA, so a logic-only edit with no new source rows +# would otherwise serve the previous run's output forever -- this constant, folded into the fingerprint, +# forces a full rebuild the next time this script runs after such an edit ships. Overridable via env var +# purely so a test can simulate "the script logic changed" without editing this file. +SCRIPT_VERSION="${GITTENSORY_REPORTING_SCRIPT_VERSION:-2}" + APP_DB="${GITTENSORY_REPORTING_SOURCE_DB:-/appdb/gittensory.sqlite}" PG_DB="${GITTENSORY_REPORTING_SOURCE_DATABASE_URL:-${DATABASE_URL:-}}" OUT_DIR="${GITTENSORY_REPORTING_DIR:-/reporting}" @@ -174,8 +182,8 @@ 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 + fp="script=$SCRIPT_VERSION" + 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 ;; @@ -197,8 +205,8 @@ pg_append_only_fingerprint() { } pg_source_fingerprint() { - fp="" - for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events"; do + fp="script=$SCRIPT_VERSION" + 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 ;; @@ -264,6 +272,20 @@ CREATE INDEX review_targets_updated_idx ON review_targets(updated_at); CREATE INDEX review_targets_status_idx ON review_targets(status); CREATE INDEX review_targets_verdict_idx ON review_targets(verdict); +CREATE TABLE issues ( + repo TEXT NOT NULL, + number INTEGER NOT NULL, + author TEXT, + state TEXT NOT NULL, + title TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX issues_repo_idx ON issues(repo); +CREATE INDEX issues_state_idx ON issues(state); +CREATE INDEX issues_created_idx ON issues(created_at); +CREATE INDEX issues_updated_idx ON issues(updated_at); + CREATE TABLE ai_usage_events ( feature TEXT NOT NULL, model TEXT NOT NULL, @@ -296,7 +318,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 @@ -397,6 +420,22 @@ WHERE t.kind = 'pull_request' sqlite_import_csv "$LEGACY_CSV" "review_targets" fi + if pg_table_exists "issues"; then + ISSUES_CSV="$(csv_temp_file "issues")" + pg_copy_csv " +SELECT + repo_full_name AS repo, + number, + author_login AS author, + state, + title, + created_at, + updated_at +FROM issues +" "$ISSUES_CSV" + sqlite_import_csv "$ISSUES_CSV" "issues" + fi + if pg_table_exists "ai_usage_events"; then AI_CSV="$(csv_temp_file "ai-usage-events")" if pg_column_exists "ai_usage_events" "estimated_neurons"; then @@ -459,7 +498,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 @@ -575,6 +615,31 @@ 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, + author, + state, + title, + created_at, + updated_at +) +SELECT + repo_full_name, + number, + author_login, + state, + title, + created_at, + updated_at +FROM main.issues; +DETACH report; +" +fi + if source_table_exists "ai_usage_events"; then ESTIMATED_NEURONS_EXPR=0 if source_column_exists "ai_usage_events" "estimated_neurons"; then diff --git a/test/unit/selfhost-grafana-dashboard.test.ts b/test/unit/selfhost-grafana-dashboard.test.ts index c66183df39..0aa2802b07 100644 --- a/test/unit/selfhost-grafana-dashboard.test.ts +++ b/test/unit/selfhost-grafana-dashboard.test.ts @@ -63,7 +63,11 @@ function targetForPanel(panelId: number): DashboardTarget { function expandGrafanaRange(query: string): string { const from = Math.floor(Date.parse("2026-06-29T20:00:00Z") / 1000); const to = Math.floor(Date.parse("2026-06-29T22:00:00Z") / 1000); - return query.replaceAll(timeFrom, String(from)).replaceAll(timeTo, String(to)); + // Every panel's $repo variable also needs expanding for a real sqlite3 CLI run, same as the time + // placeholders above -- Grafana's own templating engine does this substitution normally, so a raw + // file-read + direct sqlite3 execution (what these tests do) has to simulate it. Default to "All + // repos" ('$__all' both sides of the OR) unless a caller substitutes a specific repo value first. + return query.replaceAll(timeFrom, String(from)).replaceAll(timeTo, String(to)).replaceAll("'$repo'", "'$__all'"); } function tmpRoot(): string { @@ -258,14 +262,14 @@ describe("maintainer Reviews & PRs Grafana dashboard", () => { } }); - it("explains the latest-update-in-window (not lifetime) semantics on Manual/Commented/Ignored (#3717)", () => { + it("explains the latest-update-in-window (not lifetime) semantics on Manual/Approved/Ignored (#3717)", () => { const dashboard = readDashboard(); const panelsById = new Map(dashboard.panels.map((panel) => [panel.id, panel])); for (const [id, title] of [ [5, "Manual review"], - [6, "Commented (advisory)"], - [7, "Ignored"], + [6, "Approved (pending merge)"], + [7, "Ignored (no gate decision yet)"], ] as const) { const panel = panelsById.get(id); expect(panel?.title).toBe(title); @@ -274,31 +278,108 @@ describe("maintainer Reviews & PRs Grafana dashboard", () => { } }); - it("adds GitHub-API-backed issue-activity stat panels alongside the review_targets PR panels (#3716)", () => { + it("redefines 'Ignored' around verdict IS NULL, not the dead status='ignored'/verdict='ignore' values (2026-07 fix)", () => { + const target = targetForPanel(7); + + expect(target.queryText).toContain("status='manual'"); + expect(target.queryText).toContain("verdict IS NULL"); + expect(target.queryText).not.toContain("status='ignored'"); + expect(target.queryText).not.toContain("verdict='ignore'"); + }); + + it("adds local, webhook-observed issue-activity stat panels alongside the review_targets PR panels (#3716, switched off the GitHub API 2026-07)", () => { const dashboard = readDashboard(); const panelsById = new Map(dashboard.panels.map((panel) => [panel.id, panel])); - for (const [id, title, expectedQuery] of [ - [12, "Issues opened", "org:JSONbored is:issue"], - [13, "Issues closed", "org:JSONbored is:issue is:closed"], - [14, "Issues open", "org:JSONbored is:issue is:open"], + for (const [id, title] of [ + [12, "Issues opened"], + [13, "Issues closed"], + [14, "Issues open"], ] as const) { const panel = panelsById.get(id); expect(panel?.title).toBe(title); - expect(panel?.datasource?.type).toBe("grafana-github-datasource"); + expect(panel?.datasource?.type).toBe("frser-sqlite-datasource"); const target = panel?.targets?.[0]; - expect(target?.queryType).toBe("Issues"); - expect(target?.options?.query).toBe(expectedQuery); - // A live GitHub-API source (not review_targets) — never asserted to be time-window bound the way - // the SQL panels above are; "Issues open" is deliberately a state snapshot with no timeField at all. + expect(target?.queryType).toBe("table"); + expect(target?.rawQueryText).toBe(target?.queryText); + expect(target?.queryText).toContain("FROM issues"); + expect(target?.queryText).toContain("('$repo' = '$__all' OR repo = '$repo')"); expect(panel?.description?.length ?? 0).toBeGreaterThan(0); } - // "Issues open" is a current-state snapshot (mirrors github-prs.json's own "Open issues" panel) — - // no timeField, unlike the opened/closed flow panels. - expect(panelsById.get(14)?.targets?.[0]?.options?.timeField).toBeUndefined(); - expect(panelsById.get(12)?.targets?.[0]?.options?.timeField).toBe(1); - expect(panelsById.get(13)?.targets?.[0]?.options?.timeField).toBe(0); + // "Issues opened"/"Issues closed" are flow counts bound to the dashboard's selected time window, same as + // every review_targets panel above; "Issues open" is deliberately a current-state snapshot with no time + // filter at all (mirrors github-prs.json's own "Open issues" panel semantics). + expect(targetForPanel(12).queryText).toContain("unixepoch(created_at)"); + expect(targetForPanel(12).queryText).toContain(timeFrom); + expect(targetForPanel(13).queryText).toContain("state='closed'"); + expect(targetForPanel(13).queryText).toContain("unixepoch(updated_at)"); + expect(targetForPanel(13).queryText).toContain(timeTo); + expect(targetForPanel(14).queryText).toContain("state='open'"); + expect(targetForPanel(14).queryText).not.toContain("unixepoch"); + }); + + it("scopes the issue-activity panels to the selected $repo, same as the PR panels", () => { + for (const id of [12, 13, 14] as const) { + expect(targetForPanel(id).queryText).toContain("('$repo' = '$__all' OR repo = '$repo')"); + } + }); + + it("declares a dynamic, query-backed $repo template variable (not a hardcoded repo list)", () => { + const dashboard = readDashboard() as unknown as { + templating: { list: Array<{ name: string; type: string; datasource?: { type?: string }; query?: { rawQueryText?: string }; includeAll?: boolean }> }; + }; + const vars = dashboard.templating.list; + + expect(vars).toHaveLength(1); + expect(vars[0]!.name).toBe("repo"); + expect(vars[0]!.type).toBe("query"); + expect(vars[0]!.includeAll).toBe(true); + expect(vars[0]!.datasource?.type).toBe("frser-sqlite-datasource"); + expect(vars[0]!.query?.rawQueryText).toBe("SELECT DISTINCT repo FROM review_targets ORDER BY repo"); + }); + + it("scopes every review_targets panel query to the selected $repo", () => { + const targets = reviewTargets(); + + expect(targets.length).toBeGreaterThan(0); + for (const target of targets) { + expect(target.queryText).toContain("('$repo' = '$__all' OR repo = '$repo')"); + } + }); + + (sqliteCliAvailable ? it : it.skip)("issue-activity panels count real rows correctly by state and window", () => { + const root = tmpRoot(); + const db = join(root, "reporting.sqlite"); + sqlite(db, ` + CREATE TABLE issues ( + repo TEXT NOT NULL, + number INTEGER NOT NULL, + author TEXT, + state TEXT NOT NULL, + title TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO issues (repo, number, author, state, title, created_at, updated_at) + VALUES + ('owner/repo', 1, 'alice', 'open', 'opened in window', '2026-06-29T20:30:00Z', '2026-06-29T20:30:00Z'), + ('owner/repo', 2, 'bob', 'closed', 'closed in window', '2026-06-01T00:00:00Z', '2026-06-29T21:00:00Z'), + ('owner/repo', 3, 'carol', 'open', 'opened before window', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z'), + ('other/repo', 4, 'dave', 'open', 'different repo', '2026-06-29T20:30:00Z', '2026-06-29T20:30:00Z'); + `); + + const opened = sqlite(db, expandGrafanaRange(targetForPanel(12).queryText!)); + const closed = sqlite(db, expandGrafanaRange(targetForPanel(13).queryText!)); + const open = sqlite(db, expandGrafanaRange(targetForPanel(14).queryText!)); + + // Issues #1 (owner/repo) and #4 (other/repo) were both created inside the window -- "All repos" is + // selected here (expandGrafanaRange's default), so the different-repo row still counts. + expect(opened).toBe("2"); + // Only issue #2 is closed with an updated_at inside the window. + expect(closed).toBe("1"); + // Open is a state snapshot across all repos/time: issues #1, #3, #4 are open. + expect(open).toBe("3"); }); (sqliteCliAvailable ? it : it.skip)("filters the pull request table to the selected time window", () => { @@ -328,6 +409,40 @@ describe("maintainer Reviews & PRs Grafana dashboard", () => { expect(rows).not.toContain("old row"); }); + (sqliteCliAvailable ? it : it.skip)("actually narrows the PRs-tracked count to a selected $repo, and 'All' still includes every repo", () => { + const root = tmpRoot(); + const db = join(root, "reporting.sqlite"); + sqlite(db, ` + CREATE TABLE review_targets ( + repo TEXT NOT NULL, + number INTEGER NOT NULL, + submitter TEXT, + status TEXT NOT NULL, + verdict TEXT, + title TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO review_targets (repo, number, submitter, status, verdict, title, created_at, updated_at) + VALUES + ('owner/repo-a', 1, 'alice', 'merged', 'merge', 'in repo a', '2026-06-29T20:30:00Z', '2026-06-29T20:30:00Z'), + ('owner/repo-b', 2, 'bob', 'merged', 'merge', 'in repo b', '2026-06-29T20:30:00Z', '2026-06-29T20:30:00Z'), + ('owner/repo-b', 3, 'carol', 'merged', 'merge', 'also in repo b', '2026-06-29T20:30:00Z', '2026-06-29T20:30:00Z'); + `); + + const trackedQuery = targetForPanel(2).queryText!; + const allRepos = sqlite(db, expandGrafanaRange(trackedQuery)); + // A specific repo selection substitutes BOTH $repo occurrences with the same real repo value (never + // the literal "$__all" sentinel, which only appears when "All" is selected) -- simulate that directly + // on the raw query rather than going through expandGrafanaRange's own "All" default. + const repoAOnly = sqlite(db, expandGrafanaRange(trackedQuery.replaceAll("'$repo'", "'owner/repo-a'"))); + const repoBOnly = sqlite(db, expandGrafanaRange(trackedQuery.replaceAll("'$repo'", "'owner/repo-b'"))); + + expect(allRepos).toBe("3"); + expect(repoAOnly).toBe("1"); + expect(repoBOnly).toBe("2"); + }); + it("excludes bot-authored PRs from every review_targets panel query, not just the table", () => { const targets = reviewTargets(); diff --git a/test/unit/selfhost-grafana-reporting.test.ts b/test/unit/selfhost-grafana-reporting.test.ts index 04a4ba5d9a..0f68bcfee7 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"*|\ @@ -91,6 +91,9 @@ case "$args" in *"FROM review_targets t"*) printf '"JSONbored/gittensory",1049,bohdansolovie,closed,close,"historical PR",2026-06-22T17:28:56Z,2026-06-22T17:28:56Z\\n' ;; + *"repo_full_name AS repo"*"FROM issues"*) + printf '"JSONbored/gittensory",42,alice,open,"a real issue",2026-06-28T12:00:00Z,2026-06-28T12:00:00Z\\n' + ;; *"FROM ai_usage_events"*) 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' @@ -469,6 +472,35 @@ esac ); }); + it("exports the local, webhook-observed issues table into the redacted reporting database (#3716)", () => { + const root = tmpRoot(); + const appDb = join(root, "app.sqlite"); + const outDb = join(root, "reporting.sqlite"); + sqlite(appDb, ` + CREATE TABLE issues ( + repo_full_name TEXT NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + author_login TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO issues (repo_full_name, number, title, state, author_login, created_at, updated_at) + VALUES + ('JSONbored/gittensory', 42, 'a real issue', 'open', 'alice', '2026-06-28T12:00:00Z', '2026-06-28T12:00:00Z'), + ('JSONbored/gittensory', 43, 'a closed issue', 'closed', 'bob', '2026-06-01T00:00:00Z', '2026-06-29T00:00:00Z'); + `); + + runExporter(root, appDb, outDb); + + expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); + expect(sqlite(outDb, "SELECT count(*) FROM issues;")).toBe("2"); + expect(sqlite(outDb, "SELECT repo || '|' || number || '|' || author || '|' || state || '|' || title FROM issues WHERE number = 42;")).toBe( + "JSONbored/gittensory|42|alice|open|a real issue", + ); + }); + it("copies durable AI usage estimate rows into the redacted reporting database", () => { const root = tmpRoot(); const appDb = join(root, "app.sqlite"); @@ -552,6 +584,10 @@ 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"); + expect(sqlite(outDb, "SELECT count(*) FROM issues;")).toBe("1"); + expect(sqlite(outDb, "SELECT repo || '|' || number || '|' || author || '|' || state || '|' || title FROM issues;")).toBe( + "JSONbored/gittensory|42|alice|open|a real issue", + ); expect(readdirSync(csvTmp)).toEqual([]); }); @@ -668,6 +704,38 @@ esac expect(sqlite(outDb, "SELECT count(*) FROM review_targets;")).toBe("1"); }); + it("forces a fresh rebuild when the script's own logic version changes, even with the source data completely unchanged (2026-07 staleness fix)", () => { + 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, 'unchanged PR', '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 + ); + `); + + const first = runExporter(root, appDb, outDb, { GITTENSORY_REPORTING_SCRIPT_VERSION: "test-v1" }); + expect(first).toContain("reporting export complete"); + + // Same source data, same script version -- the normal incremental fast-path applies. + const second = runExporter(root, appDb, outDb, { GITTENSORY_REPORTING_SCRIPT_VERSION: "test-v1" }); + expect(second).toContain("reporting export skipped: source unchanged since last export"); + + // Same source data, but the script's own logic "changed" (simulated by a different version string) -- + // this must NOT skip, exactly the gap that let the dead 'ignored'/'ignore' values survive a real mapping + // migration for years without the reporting DB ever refreshing. + const third = runExporter(root, appDb, outDb, { GITTENSORY_REPORTING_SCRIPT_VERSION: "test-v2" }); + expect(third).toContain("reporting export complete"); + }); + it("redoes the rebuild once the SQLite source actually changes, reflecting the new row", () => { const root = tmpRoot(); const appDb = join(root, "app.sqlite");