From 4e0113d77dfeb5a52fa93df09c699a2e9107ca9b Mon Sep 17 00:00:00 2001 From: jony376 Date: Wed, 15 Jul 2026 05:33:25 -0700 Subject: [PATCH] feat(grafana): add additive maintainer review event panels --- grafana/dashboards/maintainer-reviews.json | 56 ++++++-- scripts/export-grafana-reporting-db.sh | 127 ++++++++++++++++++- test/unit/selfhost-grafana-dashboard.test.ts | 69 ++++++++++ test/unit/selfhost-grafana-reporting.test.ts | 69 +++++++++- 4 files changed, 304 insertions(+), 17 deletions(-) diff --git a/grafana/dashboards/maintainer-reviews.json b/grafana/dashboards/maintainer-reviews.json index 97c3fe8a60..9608fe40e9 100644 --- a/grafana/dashboards/maintainer-reviews.json +++ b/grafana/dashboards/maintainer-reviews.json @@ -4,7 +4,7 @@ "tags": ["loopover", "maintainer"], "timezone": "browser", "schemaVersion": 39, - "version": 7, + "version": 8, "editable": false, "graphTooltip": 1, "refresh": "1m", @@ -100,12 +100,52 @@ "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-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:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) 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:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] }, + { + "type": "row", + "id": 15, + "title": "Additive review activity", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 } + }, + { + "type": "stat", + "id": 16, + "title": "Held for manual review events", + "description": "Additive count of `agent.action.hold` audit rows in this window -- each hold event is counted when it happened, even if that PR later merged or closed. This is the event-flow counterpart to the current-state 'Manual review' snapshot above, not a replacement for it.", + "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, + "gridPos": { "h": 4, "w": 8, "x": 0, "y": 6 }, + "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": "loopover-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS held_events FROM audit_events WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND event_type='agent.action.hold' AND outcome IN ('success','completed') AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS held_events FROM audit_events WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND event_type='agent.action.hold' AND outcome IN ('success','completed') AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}" }] + }, + { + "type": "stat", + "id": 17, + "title": "Approval events", + "description": "Additive count of `agent.action.approve` audit rows in this window -- every bot approval event is counted when it was recorded, even if the PR later merged, closed, or received a newer commit. This complements the current-state 'Approved (pending merge)' tile above, which only shows PRs still waiting at the end of their latest in-window state transition.", + "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 6 }, + "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": "loopover-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS approval_events FROM audit_events WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND event_type='agent.action.approve' AND outcome IN ('success','completed') AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS approval_events FROM audit_events WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND event_type='agent.action.approve' AND outcome IN ('success','completed') AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}" }] + }, + { + "type": "stat", + "id": 18, + "title": "Visibility-skipped events", + "description": "Additive count of `github_app.pr_visibility_skipped` audit rows in this window -- PRs the pipeline explicitly skipped from public review flow, counted at the moment the skip was recorded. This is the real event-ledger measure of 'skipped/ignored by the pipeline', distinct from the snapshot tile above that now means 'no gate decision yet'.", + "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 6 }, + "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": "loopover-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS skipped_events FROM audit_events WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND event_type='github_app.pr_visibility_skipped' AND outcome='completed' AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS skipped_events FROM audit_events WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND event_type='github_app.pr_visibility_skipped' AND outcome='completed' AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}" }] + }, { "type": "row", "id": 11, "title": "Issue activity", "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 } + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 } }, { "type": "stat", @@ -113,7 +153,7 @@ "title": "Issues opened", "description": "Local, webhook-observed count via the loopover-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": "loopover-db" }, - "gridPos": { "h": 4, "w": 8, "x": 0, "y": 6 }, + "gridPos": { "h": 4, "w": 8, "x": 0, "y": 11 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" } } }, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS opened FROM issues WHERE (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}", "rawQueryText": "SELECT count(*) AS opened FROM issues WHERE (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(created_at) >= ${__from:date:seconds} AND unixepoch(created_at) < ${__to:date:seconds}" }] @@ -124,7 +164,7 @@ "title": "Issues closed", "description": "Local, webhook-observed count via the loopover-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": "loopover-db" }, - "gridPos": { "h": 4, "w": 8, "x": 8, "y": 6 }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 11 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "red" } } }, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS closed FROM issues WHERE state='closed' AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) 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:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds}" }] @@ -135,7 +175,7 @@ "title": "Issues open", "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": "loopover-db" }, - "gridPos": { "h": 4, "w": 8, "x": 16, "y": 6 }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 11 }, "fieldConfig": { "defaults": { "color": { "mode": "fixed", "fixedColor": "orange" } } }, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }, "targets": [{ "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, "refId": "A", "queryType": "table", "queryText": "SELECT count(*) AS open FROM issues WHERE state='open' AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring})", "rawQueryText": "SELECT count(*) AS open FROM issues WHERE state='open' AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring})" }] @@ -145,7 +185,7 @@ "id": 8, "title": "Pull requests (latest 1000)", "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, - "gridPos": { "h": 16, "w": 24, "x": 0, "y": 10 }, + "gridPos": { "h": 16, "w": 24, "x": 0, "y": 15 }, "fieldConfig": { "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "filterable": true, "inspect": false } }, "overrides": [ @@ -179,7 +219,7 @@ "id": 9, "title": "Reviews per day", "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 }, "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": "loopover-db" }, "refId": "A", "queryType": "time series", "timeColumns": ["time"], "queryText": "SELECT unixepoch(date(updated_at)) * 1000 AS time, count(*) AS reviews FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY date(updated_at) ORDER BY time", "rawQueryText": "SELECT unixepoch(date(updated_at)) * 1000 AS time, count(*) AS reviews FROM review_targets WHERE (submitter NOT LIKE '%[bot]%' OR submitter IS NULL) AND (${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) AND unixepoch(updated_at) >= ${__from:date:seconds} AND unixepoch(updated_at) < ${__to:date:seconds} GROUP BY date(updated_at) ORDER BY time" }] @@ -189,7 +229,7 @@ "id": 10, "title": "By verdict", "datasource": { "type": "frser-sqlite-datasource", "uid": "loopover-db" }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 }, "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": "loopover-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:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) 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:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring}) 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 e2712cb7d8..2d9791227c 100644 --- a/scripts/export-grafana-reporting-db.sh +++ b/scripts/export-grafana-reporting-db.sh @@ -147,7 +147,8 @@ SQL # 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 # 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 +# (review_audit, ai_usage_events, audit_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 # the rebuild only when that fingerprint matches the last run's AND the last-good $OUT_DB still passes # SQLite's quick_check. Fails OPEN: any error or missing piece while computing the fingerprint or validating @@ -169,7 +170,7 @@ sqlite_table_fingerprint() { sqlite3 "$APP_DB" ".dump $tbl" | hash_stdin } -# review_audit/ai_usage_events are insert-only event/audit logs (nothing ever UPDATEs a row in +# review_audit/ai_usage_events/audit_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 @@ -183,10 +184,10 @@ sqlite_append_only_fingerprint() { sqlite_source_fingerprint() { [ -s "$APP_DB" ] || return 1 fp="script=$SCRIPT_VERSION" - for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events" "issues"; do + for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events" "audit_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 ;; + review_audit | ai_usage_events | audit_events) val="$(sqlite_append_only_fingerprint "$tbl")" || return 1 ;; *) val="$(sqlite_table_fingerprint "$tbl")" || return 1 ;; esac else @@ -197,7 +198,7 @@ sqlite_source_fingerprint() { printf '%s' "$fp" } -# Mirrors sqlite_append_only_fingerprint's reasoning: review_audit/ai_usage_events are insert-only, +# Mirrors sqlite_append_only_fingerprint's reasoning: review_audit/ai_usage_events/audit_events are insert-only, # so count+max is sufficient and avoids scanning+serializing every row on every fast-path check. pg_append_only_fingerprint() { tbl="$1" @@ -206,10 +207,10 @@ pg_append_only_fingerprint() { pg_source_fingerprint() { fp="script=$SCRIPT_VERSION" - for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events" "issues"; do + for tbl in "pull_requests" "review_audit" "review_targets" "ai_usage_events" "audit_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 ;; + review_audit | ai_usage_events | audit_events) val="$(pg_append_only_fingerprint "$tbl")" || return 1 ;; *) val="$(pg_scalar "SELECT md5(COALESCE(string_agg(row_to_json(t)::text, E'\n' ORDER BY row_to_json(t)::text), '')) FROM $tbl t")" || return 1 ;; esac else @@ -304,6 +305,19 @@ 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); + +CREATE TABLE audit_events ( + repo TEXT NOT NULL, + pull_number INTEGER NOT NULL, + submitter TEXT, + event_type TEXT NOT NULL, + outcome TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX audit_events_repo_created_idx ON audit_events(repo, created_at); +CREATE INDEX audit_events_type_created_idx ON audit_events(event_type, created_at); +CREATE INDEX audit_events_repo_type_created_idx ON audit_events(repo, event_type, created_at); SQL if pg_enabled; then @@ -318,6 +332,7 @@ if pg_enabled; then ! pg_table_exists "advisories" && ! pg_table_exists "review_targets" && ! pg_table_exists "ai_usage_events" && + ! pg_table_exists "audit_events" && ! pg_table_exists "review_audit" && ! pg_table_exists "issues"; then if [ -s "$OUT_DB" ]; then @@ -472,6 +487,34 @@ FROM ai_usage_events sqlite_import_csv "$AI_CSV" "ai_usage_events" fi + if pg_table_exists "audit_events"; then + AUDIT_EVENTS_CSV="$(csv_temp_file "audit-events")" + pg_copy_csv " +SELECT + split_part(a.target_key, '#', 1) AS repo, + CAST(split_part(a.target_key, '#', 2) AS INTEGER) AS pull_number, + p.author_login AS submitter, + a.event_type, + a.outcome, + a.detail, + a.created_at +FROM audit_events a +LEFT JOIN pull_requests p + ON p.repo_full_name = split_part(a.target_key, '#', 1) + AND p.number = CAST(split_part(a.target_key, '#', 2) AS INTEGER) +WHERE a.event_type IN ( + 'agent.action.approve', + 'agent.action.close', + 'agent.action.hold', + 'agent.action.merge', + 'github_app.pr_public_surface_published', + 'github_app.pr_visibility_skipped' +) + AND position('#' in a.target_key) > 0 +" "$AUDIT_EVENTS_CSV" + sqlite_import_csv "$AUDIT_EVENTS_CSV" "audit_events" + fi + sqlite3 "$TMP_DB" "PRAGMA quick_check;" | grep -qx "ok" mv "$TMP_DB" "$OUT_DB" rm -f "$TMP_DB-wal" "$TMP_DB-shm" @@ -498,6 +541,7 @@ if ! source_table_exists "pull_requests" && ! source_table_exists "advisories" && ! source_table_exists "review_targets" && ! source_table_exists "ai_usage_events" && + ! source_table_exists "audit_events" && ! source_table_exists "review_audit" && ! source_table_exists "issues"; then if [ -s "$OUT_DB" ]; then @@ -640,6 +684,75 @@ DETACH report; " fi +if source_table_exists "audit_events" && source_table_exists "pull_requests"; then + sqlite3 -cmd ".timeout 5000" "$APP_DB" " +ATTACH '$TMP_DB_SQL' AS report; +INSERT INTO report.audit_events ( + repo, + pull_number, + submitter, + event_type, + outcome, + detail, + created_at +) +SELECT + substr(a.target_key, 1, instr(a.target_key, '#') - 1) AS repo, + CAST(substr(a.target_key, instr(a.target_key, '#') + 1) AS INTEGER) AS pull_number, + p.author_login AS submitter, + a.event_type, + a.outcome, + a.detail, + a.created_at +FROM main.audit_events a +LEFT JOIN main.pull_requests p + ON p.repo_full_name = substr(a.target_key, 1, instr(a.target_key, '#') - 1) + AND p.number = CAST(substr(a.target_key, instr(a.target_key, '#') + 1) AS INTEGER) +WHERE a.event_type IN ( + 'agent.action.approve', + 'agent.action.close', + 'agent.action.hold', + 'agent.action.merge', + 'github_app.pr_public_surface_published', + 'github_app.pr_visibility_skipped' +) + AND instr(a.target_key, '#') > 0; +DETACH report; +" +elif source_table_exists "audit_events"; then + sqlite3 -cmd ".timeout 5000" "$APP_DB" " +ATTACH '$TMP_DB_SQL' AS report; +INSERT INTO report.audit_events ( + repo, + pull_number, + submitter, + event_type, + outcome, + detail, + created_at +) +SELECT + substr(a.target_key, 1, instr(a.target_key, '#') - 1) AS repo, + CAST(substr(a.target_key, instr(a.target_key, '#') + 1) AS INTEGER) AS pull_number, + NULL AS submitter, + a.event_type, + a.outcome, + a.detail, + a.created_at +FROM main.audit_events a +WHERE a.event_type IN ( + 'agent.action.approve', + 'agent.action.close', + 'agent.action.hold', + 'agent.action.merge', + 'github_app.pr_public_surface_published', + 'github_app.pr_visibility_skipped' +) + AND instr(a.target_key, '#') > 0; +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 50337a3730..497b07891c 100644 --- a/test/unit/selfhost-grafana-dashboard.test.ts +++ b/test/unit/selfhost-grafana-dashboard.test.ts @@ -54,6 +54,12 @@ function reviewTargets(dashboard = readDashboard()): DashboardTarget[] { .filter((target) => target.queryText?.includes("review_targets")); } +function auditEventTargets(dashboard = readDashboard()): DashboardTarget[] { + return dashboard.panels + .flatMap((panel) => panel.targets ?? []) + .filter((target) => target.queryText?.includes("audit_events")); +} + function targetForPanel(panelId: number): DashboardTarget { const panel = readDashboard().panels.find((candidate) => candidate.id === panelId); const target = panel?.targets?.[0]; @@ -324,6 +330,38 @@ describe("maintainer Reviews & PRs Grafana dashboard", () => { expect(target.queryText).not.toContain("verdict='ignore'"); }); + it("adds additive audit-event stat panels beside the snapshot-only Manual/Approved/Ignored tiles (#3717 part 2)", () => { + const dashboard = readDashboard(); + const panelsById = new Map(dashboard.panels.map((panel) => [panel.id, panel])); + + for (const [id, title] of [ + [16, "Held for manual review events"], + [17, "Approval events"], + [18, "Visibility-skipped events"], + ] as const) { + const panel = panelsById.get(id); + expect(panel?.title).toBe(title); + expect(panel?.datasource?.type).toBe("frser-sqlite-datasource"); + expect(panel?.description?.length ?? 0).toBeGreaterThan(0); + expect(panel?.description).toContain("Additive"); + } + }); + + it("binds every additive audit_events panel query to the selected repo and time range", () => { + const targets = auditEventTargets(); + + expect(targets).toHaveLength(3); + for (const target of targets) { + expect(target.rawQueryText).toBe(target.queryText); + expect(target.queryText).toContain("FROM audit_events"); + expect(target.queryText).toContain("(${repo:sqlstring} = '__ALL__' OR repo = ${repo:sqlstring})"); + expect(target.queryText).toContain("unixepoch(created_at)"); + expect(target.queryText).toContain(timeFrom); + expect(target.queryText).toContain(timeTo); + expect(target.queryText).toContain("submitter NOT LIKE '%[bot]%'"); + } + }); + 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])); @@ -480,6 +518,37 @@ describe("maintainer Reviews & PRs Grafana dashboard", () => { expect(repoBOnly).toBe("2"); }); + (sqliteCliAvailable ? it : it.skip)("counts additive audit-event rows by repo and excludes bot-authored PR events", () => { + const root = tmpRoot(); + const db = join(root, "reporting.sqlite"); + sqlite(db, ` + CREATE TABLE audit_events ( + repo TEXT NOT NULL, + pull_number INTEGER NOT NULL, + submitter TEXT, + event_type TEXT NOT NULL, + outcome TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO audit_events (repo, pull_number, submitter, event_type, outcome, detail, created_at) + VALUES + ('owner/repo-a', 1, 'alice', 'agent.action.hold', 'completed', NULL, '2026-06-29T20:15:00Z'), + ('owner/repo-a', 2, 'bob', 'agent.action.approve', 'success', NULL, '2026-06-29T20:20:00Z'), + ('owner/repo-a', 3, 'github-actions[bot]', 'agent.action.hold', 'completed', NULL, '2026-06-29T20:25:00Z'), + ('owner/repo-b', 4, 'carol', 'github_app.pr_visibility_skipped', 'completed', 'draft', '2026-06-29T20:30:00Z'), + ('owner/repo-b', 5, 'dave', 'agent.action.approve', 'queued', NULL, '2026-06-29T20:35:00Z'), + ('owner/repo-b', 6, 'erin', 'agent.action.hold', 'completed', NULL, '2026-06-29T19:30:00Z'); + `); + + expect(sqlite(db, expandGrafanaRange(targetForPanel(16).queryText!))).toBe("1"); + expect(sqlite(db, expandGrafanaRange(targetForPanel(17).queryText!))).toBe("1"); + expect(sqlite(db, expandGrafanaRange(targetForPanel(18).queryText!))).toBe("1"); + expect(sqlite(db, expandGrafanaRange(targetForPanel(16).queryText!, "owner/repo-a"))).toBe("1"); + expect(sqlite(db, expandGrafanaRange(targetForPanel(18).queryText!, "owner/repo-a"))).toBe("0"); + expect(sqlite(db, expandGrafanaRange(targetForPanel(18).queryText!, "owner/repo-b"))).toBe("1"); + }); + (sqliteCliAvailable ? it : it.skip)("SQL-escapes repo variable values instead of interpolating raw dashboard input", () => { const root = tmpRoot(); diff --git a/test/unit/selfhost-grafana-reporting.test.ts b/test/unit/selfhost-grafana-reporting.test.ts index 9bd17f2952..0eed99db90 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"*"issues"*) + *"information_schema.tables"*"pull_requests"*|*"information_schema.tables"*"advisories"*|*"information_schema.tables"*"review_targets"*|*"information_schema.tables"*"ai_usage_events"*|*"information_schema.tables"*"audit_events"*|*"information_schema.tables"*"review_audit"*|*"information_schema.tables"*"issues"*) printf '1\\n' ;; *"information_schema.columns"*"ai_usage_events"*"estimated_neurons"*|\ @@ -98,6 +98,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 audit_events a"*) + printf '"JSONbored/gittensory",1690,JSONbored,agent.action.approve,completed,,2026-06-28T21:38:00Z\\n' + printf '"JSONbored/gittensory",1691,tmimmanuel,github_app.pr_visibility_skipped,completed,draft,2026-06-28T21:39:00Z\\n' + ;; esac `, ); @@ -185,7 +189,7 @@ function failingCopyPsql(root: string): string { `#!/bin/sh args="$*" case "$args" in - *"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"*"audit_events"*|*"information_schema.tables"*"review_audit"*) printf '1\\n' ;; *"information_schema.columns"*"ai_usage_events"*"estimated_neurons"*) @@ -501,6 +505,62 @@ esac ); }); + it("exports public-safe PR audit events for additive maintainer-review dashboard panels", () => { + 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', 7001, 'human pr', 'open', 'alice', NULL, '2026-06-28T21:00:00Z', '2026-06-28T21:00:00Z'), + ('JSONbored/gittensory', 7002, 'bot pr', 'open', 'github-actions[bot]', NULL, '2026-06-28T21:00:00Z', '2026-06-28T21:00:00Z'); + + CREATE TABLE audit_events ( + id TEXT NOT NULL, + actor TEXT, + target_key TEXT NOT NULL, + event_type TEXT NOT NULL, + outcome TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO audit_events (id, actor, target_key, event_type, outcome, detail, created_at) + VALUES + ('1', 'loopover', 'JSONbored/gittensory#7001', 'agent.action.hold', 'completed', NULL, '2026-06-28T21:10:00Z'), + ('2', 'loopover', 'JSONbored/gittensory#7001', 'agent.action.approve', 'success', NULL, '2026-06-28T21:11:00Z'), + ('3', 'loopover', 'JSONbored/gittensory#7001', 'github_app.pr_visibility_skipped', 'completed', 'draft', '2026-06-28T21:12:00Z'), + ('4', 'loopover', 'JSONbored/gittensory#7002', 'agent.action.merge', 'completed', NULL, '2026-06-28T21:13:00Z'), + ('5', 'loopover', 'JSONbored/gittensory#7001', 'agent.action.label', 'completed', NULL, '2026-06-28T21:14:00Z'); + `); + + runExporter(root, appDb, outDb); + + expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); + expect( + sqlite( + outDb, + "SELECT repo || '|' || pull_number || '|' || COALESCE(submitter,'') || '|' || event_type || '|' || outcome || '|' || COALESCE(detail,'') FROM audit_events ORDER BY created_at", + ), + ).toBe( + [ + "JSONbored/gittensory|7001|alice|agent.action.hold|completed|", + "JSONbored/gittensory|7001|alice|agent.action.approve|success|", + "JSONbored/gittensory|7001|alice|github_app.pr_visibility_skipped|completed|draft", + "JSONbored/gittensory|7002|github-actions[bot]|agent.action.merge|completed|", + ].join("\n"), + ); + }); + it("copies durable AI usage estimate rows into the redacted reporting database", () => { const root = tmpRoot(); const appDb = join(root, "app.sqlite"); @@ -588,6 +648,10 @@ esac expect(sqlite(outDb, "SELECT repo || '|' || number || '|' || author || '|' || state || '|' || title FROM issues;")).toBe( "JSONbored/gittensory|42|alice|open|a real issue", ); + expect(sqlite(outDb, "SELECT count(*) FROM audit_events;")).toBe("2"); + expect(sqlite(outDb, "SELECT repo || '|' || pull_number || '|' || submitter || '|' || event_type || '|' || outcome || '|' || COALESCE(detail,'') FROM audit_events ORDER BY created_at;")).toBe( + "JSONbored/gittensory|1690|JSONbored|agent.action.approve|completed|\nJSONbored/gittensory|1691|tmimmanuel|github_app.pr_visibility_skipped|completed|draft", + ); expect(readdirSync(csvTmp)).toEqual([]); }); @@ -866,5 +930,6 @@ esac expect(second).toContain("reporting export skipped: source unchanged since last export"); expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); expect(sqlite(outDb, "SELECT count(*) FROM review_targets;")).toBe("3"); + expect(sqlite(outDb, "SELECT count(*) FROM audit_events;")).toBe("2"); }); });