Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion grafana/dashboards/maintainer-reviews.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}" }]
}
]
}
66 changes: 59 additions & 7 deletions scripts/export-grafana-reporting-db.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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 ;;
Expand All @@ -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 ;;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
69 changes: 69 additions & 0 deletions test/unit/selfhost-grafana-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type DashboardPanel = {
};

type Dashboard = {
description?: string;
panels: DashboardPanel[];
};

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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");
});
});
Loading
Loading