Skip to content
Merged
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
34 changes: 33 additions & 1 deletion apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -464,14 +464,46 @@
"accuracyPct"
]
}
},
"accuracyTrend": {
"type": "array",
"items": {
"type": "object",
"properties": {
"weekStart": {
"type": "string"
},
"merged": {
"type": "number"
},
"closed": {
"type": "number"
},
"reversed": {
"type": "number"
},
"accuracyPct": {
"type": "number",
"nullable": true
}
},
"required": [
"weekStart",
"merged",
"closed",
"reversed",
"accuracyPct"
]
}
}
},
"required": [
"generatedAt",
"updatedAt",
"totals",
"weekly",
"byProject"
"byProject",
"accuracyTrend"
]
},
"PublicQualityMetrics": {
Expand Down
5 changes: 3 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-w
import { computePredictedGateAgreement } from "../review/predicted-gate-agreement";
import { isRagEnabled } from "../review/rag-wire";
import { getPublicStats, isPublicStatsEnabled } from "../review/public-stats";
import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend";
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, normalizeReadinessGateMode } from "../signals/focus-manifest";
Expand Down Expand Up @@ -951,9 +952,9 @@ export function createApp() {
app.get("/v1/public/stats", async (c) => {
if (!isPublicStatsEnabled(c.env)) return c.json({ error: "not_found" }, 404);
try {
const stats = await getPublicStats(c.env);
const [stats, accuracyTrend] = await Promise.all([getPublicStats(c.env), loadPublicAccuracyTrend(c.env)]);
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
return c.json(stats);
return c.json({ ...stats, accuracyTrend });
} catch {
return c.json({ error: "public_stats_unavailable" }, 503);
}
Expand Down
11 changes: 11 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ export const PublicStatsSchema = z
accuracyPct: z.number().nullable(),
}),
),
/** Trailing weekly history of totals.accuracyPct's SAME formula (#4447) -- null accuracyPct on a week means
* too few decided (merged+closed) PRs that week to publish a meaningful percentage, not zero accuracy. */
accuracyTrend: z.array(
z.object({
weekStart: z.string(),
merged: z.number(),
closed: z.number(),
reversed: z.number(),
accuracyPct: z.number().nullable(),
}),
),
})
.openapi("PublicStats");

Expand Down
6 changes: 3 additions & 3 deletions src/review/public-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function storage(env: Env): D1Database {
}

/** Read-only helper that degrades a missing/empty table (or absent column in some envs) to []. */
async function safeAll<T>(
export async function safeAll<T>(
env: Env,
sql: string,
...binds: unknown[]
Expand Down Expand Up @@ -108,7 +108,7 @@ function accuracyPct(
* allowlist correctly went empty, while the historical rows this worker already wrote for them remain real and
* safe to publish. Empty allowlist => the own-ledger side reports zero (still fails safe), but does NOT
* suppress the separately-gated Orb cross-fleet aggregate (see getPublicStats below). */
function publicStatsProjects(env: {
export function publicStatsProjects(env: {
GITTENSORY_PUBLIC_STATS_REPOS?: string | undefined;
}): string[] {
const seen = new Set<string>();
Expand Down Expand Up @@ -165,7 +165,7 @@ export interface PublicStatsPayload {
// legacy review_targets ledger, which the convergence cutover orphaned (nothing writes it anymore). `reversed`
// (the accuracy numerator) is computed LIVE from the same ledger: a terminal engine auto-action (close/merge)
// that a human later overturned (see the reversal query below). All reads are public-safe COUNTs, degrade to 0.
const PUBLISHED_PR_KEYS = `
export const PUBLISHED_PR_KEYS = `
SELECT
substr(target_key, 1, instr(target_key, '#') - 1) AS repo,
CAST(substr(target_key, instr(target_key, '#') + 1) AS INTEGER) AS number,
Expand Down
181 changes: 181 additions & 0 deletions src/services/public-accuracy-trend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Public "Decision accuracy %" weekly trend (#4447, part of epic #4445). The homepage already shows a LIVE,
// lifetime accuracyPct (public-stats.ts's own reversal-grounded formula: 1 - reversed/(merged+closed), over the
// SAME own-ledger allowlist + registered Orb fleet the rest of that payload uses) but no history, so there's no
// way to see whether accuracy is improving, stable, or degrading.
//
// DELIBERATELY NOT a persisted/cron rollup: `audit_events`, `pull_requests`, and `orb_pr_outcomes` are already
// durable, so a live weekly re-bucketing of those SAME rows (mirroring buildPublicQualityTrend's already-shipped
// #2568 pattern for the sibling per-repo quality trend) can recompute any historical week correctly on every
// request -- no cron-miss gap risk, no second copy of the number to keep in sync, and the SAME formula as the
// live figure by construction, so the two can never silently diverge or read as inconsistent to a public viewer.
import { PUBLISHED_PR_KEYS, publicStatsProjects, safeAll } from "../review/public-stats";
import { isoWeekStart } from "./public-quality-metrics";

export const PUBLIC_ACCURACY_TREND_WEEKS = 8;
/** Below this many decided (merged+closed) PRs in a week, that week's accuracy is too noisy to publish. */
export const MIN_ACCURACY_TREND_SAMPLE = 3;

export type PublicAccuracyTrendWeek = {
/** UTC Monday (YYYY-MM-DD) that starts the bucket. */
weekStart: string;
merged: number;
closed: number;
reversed: number;
accuracyPct: number | null;
};

type DayRow = { day: string; merged: number; closed: number; reversed: number };

const MS_PER_WEEK = 7 * 86_400_000;

function roundPct(value: number): number {
return Math.round(value * 1000) / 10;
}

/** Same formula as public-stats.ts's accuracyPct, reused so the trend and the live number can never drift
* apart into two competing definitions of "accuracy". */
function accuracyPctOf(merged: number, closed: number, reversed: number): number | null {
const decided = merged + closed;
if (decided < MIN_ACCURACY_TREND_SAMPLE) return null;
const reversalRate = Math.min(1, reversed / decided);
return roundPct(1 - reversalRate);
}

/** Fold day-granularity rows into `weeks` trailing UTC-Monday buckets ending in the week containing `nowMs`.
* Pure -- mirrors buildPublicQualityTrend's own bucketing shape (public-quality-metrics.ts). */
export function buildPublicAccuracyTrend(dayRows: DayRow[], nowMs: number, weeks: number = PUBLIC_ACCURACY_TREND_WEEKS): PublicAccuracyTrendWeek[] {
const currentStartMs = Date.parse(isoWeekStart(nowMs));
const oldestStartMs = currentStartMs - (weeks - 1) * MS_PER_WEEK;
const buckets = Array.from({ length: weeks }, () => ({ merged: 0, closed: 0, reversed: 0 }));

for (const row of dayRows) {
const dayMs = Date.parse(`${row.day}T00:00:00.000Z`);
if (!Number.isFinite(dayMs)) continue;
const weekOffset = Math.floor((dayMs - oldestStartMs) / MS_PER_WEEK);
if (weekOffset < 0 || weekOffset >= weeks) continue;
const bucket = buckets[weekOffset]!;
bucket.merged += row.merged;
bucket.closed += row.closed;
bucket.reversed += row.reversed;
}

return buckets.map((bucket, offset) => ({
weekStart: isoWeekStart(oldestStartMs + offset * MS_PER_WEEK),
merged: bucket.merged,
closed: bucket.closed,
reversed: bucket.reversed,
accuracyPct: accuracyPctOf(bucket.merged, bucket.closed, bucket.reversed),
}));
}

/** Day-bucketed own-ledger merged/closed, matching public-stats.ts's `dispositions` query exactly except for the
* added `GROUP BY day` -- `closed` uses `pr.updated_at` as the close-date proxy (no dedicated closed_at column
* exists), the same convention buildPublicQualityTrend already established for the sibling quality trend. */
async function loadOwnLedgerDayRows(env: Env, projects: string[], sinceIso: string): Promise<Map<string, { merged: number; closed: number }>> {
const map = new Map<string, { merged: number; closed: number }>();
if (projects.length === 0) return map;
const inList = projects.map(() => "?").join(", ");
const [mergedRows, closedRows] = await Promise.all([
safeAll<{ day: string; n: number }>(
env,
`SELECT date(pr.merged_at) AS day, COUNT(*) AS n
FROM (SELECT DISTINCT repo, number FROM (${PUBLISHED_PR_KEYS})) ev
JOIN pull_requests pr ON pr.repo_full_name = ev.repo AND pr.number = ev.number
WHERE LOWER(ev.repo) IN (${inList}) AND pr.merged_at IS NOT NULL AND pr.merged_at >= ?
GROUP BY day`,
...projects,
sinceIso,
),
safeAll<{ day: string; n: number }>(
env,
`SELECT date(pr.updated_at) AS day, COUNT(*) AS n
FROM (SELECT DISTINCT repo, number FROM (${PUBLISHED_PR_KEYS})) ev
JOIN pull_requests pr ON pr.repo_full_name = ev.repo AND pr.number = ev.number
WHERE LOWER(ev.repo) IN (${inList}) AND pr.state = 'closed' AND pr.merged_at IS NULL AND pr.updated_at >= ?
GROUP BY day`,
...projects,
sinceIso,
),
]);
for (const row of mergedRows) map.set(row.day, { merged: row.n, closed: (map.get(row.day)?.closed ?? 0) });
for (const row of closedRows) map.set(row.day, { merged: (map.get(row.day)?.merged ?? 0), closed: row.n });
return map;
}

/** Day-bucketed reversal count, matching public-stats.ts's `reversalRows` query exactly except bucketed by the
* ORIGINAL auto-action's own created_at (not the later reversal's timestamp) so a reversal always credits the
* week the decision was actually made, and never retroactively shifts a past week's published trend. */
async function loadReversalDayRows(env: Env, projects: string[], sinceIso: string): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (projects.length === 0) return map;
const inList = projects.map(() => "?").join(", ");
const rows = await safeAll<{ day: string; n: number }>(
env,
`SELECT date(ev.created_at) AS day, COUNT(DISTINCT ev.pr_number) AS n FROM (
SELECT substr(target_key, 1, instr(target_key, '#') - 1) AS project,
CAST(substr(target_key, instr(target_key, '#') + 1) AS INTEGER) AS pr_number,
event_type, created_at
FROM audit_events
WHERE event_type IN ('agent.action.close', 'agent.action.merge')
AND outcome = 'completed' AND instr(target_key, '#') > 0
AND COALESCE(json_extract(metadata_json, '$.mode'), 'live') <> 'dry_run'
AND created_at >= ?
) ev
JOIN pull_requests pr ON pr.repo_full_name = ev.project AND pr.number = ev.pr_number
WHERE LOWER(ev.project) IN (${inList})
AND ( (ev.event_type = 'agent.action.close' AND (pr.state = 'open' OR pr.merged_at IS NOT NULL))
OR (ev.event_type = 'agent.action.merge' AND pr.state = 'open') )
GROUP BY day`,
sinceIso,
...projects,
);
for (const row of rows) map.set(row.day, row.n);
return map;
}

/** Day-bucketed Orb-fleet merged/closed, matching getOrbGlobalStats (orb/outcomes.ts) exactly except for the
* added `GROUP BY day`. No excludeAccount here, mirroring getPublicStats's own choice not to exclude any
* account from the homepage total (see public-stats.ts's file header). */
async function loadOrbDayRows(env: Env, sinceIso: string): Promise<Map<string, { merged: number; closed: number }>> {
const map = new Map<string, { merged: number; closed: number }>();
const rows = await safeAll<{ day: string; merged: number; closed: number }>(
env,
`SELECT date(o.occurred_at) AS day,
SUM(CASE WHEN o.outcome = 'merged' THEN 1 ELSE 0 END) AS merged,
SUM(CASE WHEN o.outcome = 'closed' THEN 1 ELSE 0 END) AS closed
FROM orb_pr_outcomes o
JOIN orb_github_installations i ON i.installation_id = o.installation_id AND i.registered = 1
WHERE o.occurred_at >= ?
GROUP BY day`,
sinceIso,
);
/* v8 ignore next -- SUM(CASE WHEN ... THEN 1 ELSE 0 END) over an existing GROUP BY day always yields a defined
* integer (0 or more), never SQL NULL, so the ?? 0 fallback can't currently be exercised; kept for defense
* against a future query-shape change. */
for (const row of rows) map.set(row.day, { merged: row.merged ?? 0, closed: row.closed ?? 0 });
return map;
}

/** Assemble the public accuracy trend from the SAME live tables getPublicStats already reads. Fail-safe: each
* underlying query degrades to [] on error (safeAll), so a single bad query yields under-counted weeks rather
* than throwing the whole public stats payload. */
export async function loadPublicAccuracyTrend(env: Env, nowMs: number = Date.now()): Promise<PublicAccuracyTrendWeek[]> {
const projects = publicStatsProjects(env);
const sinceIso = new Date(Date.parse(isoWeekStart(nowMs)) - (PUBLIC_ACCURACY_TREND_WEEKS - 1) * MS_PER_WEEK).toISOString();

const [ownLedger, reversals, orb] = await Promise.all([
loadOwnLedgerDayRows(env, projects, sinceIso),
loadReversalDayRows(env, projects, sinceIso),
loadOrbDayRows(env, sinceIso),
]);

const days = new Set([...ownLedger.keys(), ...reversals.keys(), ...orb.keys()]);
const dayRows: DayRow[] = [...days].map((day) => ({
day,
merged: (ownLedger.get(day)?.merged ?? 0) + (orb.get(day)?.merged ?? 0),
closed: (ownLedger.get(day)?.closed ?? 0) + (orb.get(day)?.closed ?? 0),
reversed: reversals.get(day) ?? 0,
}));

return buildPublicAccuracyTrend(dayRows, nowMs);
}
5 changes: 5 additions & 0 deletions test/integration/public-stats-route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../../src/api/routes";
import { createTestEnv } from "../helpers/d1";
import { PUBLIC_ACCURACY_TREND_WEEKS } from "../../src/services/public-accuracy-trend";

/** Seed the LIVE ledger: a published-review surface per reviewed PR (audit_events) + each PR's terminal
* disposition (pull_requests state/merged_at), plus one live reversal (an engine close on a now-reopened PR). */
Expand Down Expand Up @@ -60,6 +61,7 @@ describe("GET /v1/public/stats (#1059)", () => {
totals: Record<string, number | null>;
weekly: { reviewed: number; merged: number };
byProject: Array<{ project: string; reviewed: number }>;
accuracyTrend: Array<{ weekStart: string; merged: number; closed: number; reversed: number; accuracyPct: number | null }>;
};
expect(body.totals.handled).toBe(5); // distinct reviewed PRs
expect(body.totals.merged).toBe(3);
Expand All @@ -76,5 +78,8 @@ describe("GET /v1/public/stats (#1059)", () => {
expect(body.byProject.map((p) => p.project)).toContain(
"JSONbored/awesome-claude",
);
// #4447: the weekly accuracy trend rides along on the SAME response, one entry per trailing week.
expect(body.accuracyTrend).toHaveLength(PUBLIC_ACCURACY_TREND_WEEKS);
for (const week of body.accuracyTrend) expect(typeof week.weekStart).toBe("string");
});
});
Loading
Loading