mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-24 05:40:52 +02:00
feat: add shared team dashboard, regression alerts, weekly digest edge functions
Dashboard: Supabase edge function serving self-contained HTML with PKCE OAuth, 6 parallel client-side REST queries, SVG charts, dark theme, auto-refresh, who's-online from heartbeats. Public URL. Regression alert: webhook on eval_runs INSERT, 5-min cooldown dedup via alert_cooldowns, Slack notification on >5% pass rate drop. Weekly digest: pg_cron Monday 9am UTC, aggregates 7-day team data, Slack message with evals/ships/sessions/costs. 15 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
46c82ce8ec
commit
78840c64a8
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Regression alert edge function.
|
||||
*
|
||||
* Trigger: Database webhook on eval_runs INSERT.
|
||||
* Logic: Compare new run's pass rate against recent baseline.
|
||||
* If >5% drop, POST to team's Slack webhook.
|
||||
* Dedup via alert_cooldowns table (5-min window).
|
||||
*
|
||||
* Uses service_role key (bypasses RLS) — standard for webhooks.
|
||||
*/
|
||||
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
interface WebhookPayload {
|
||||
type: 'INSERT';
|
||||
table: string;
|
||||
record: {
|
||||
id: string;
|
||||
team_id: string;
|
||||
repo_slug: string;
|
||||
branch: string;
|
||||
passed: number;
|
||||
total_tests: number;
|
||||
timestamp: string;
|
||||
};
|
||||
schema: string;
|
||||
}
|
||||
|
||||
// --- Pure functions (testable) ---
|
||||
|
||||
export function computePassRate(passed: number, total: number): number | null {
|
||||
return total > 0 ? (passed / total) * 100 : null;
|
||||
}
|
||||
|
||||
export function shouldAlert(
|
||||
currentRate: number | null,
|
||||
baselineRate: number | null,
|
||||
thresholdPct: number = 5,
|
||||
): boolean {
|
||||
if (currentRate === null || baselineRate === null) return false;
|
||||
return baselineRate - currentRate > thresholdPct;
|
||||
}
|
||||
|
||||
export function formatSlackMessage(opts: {
|
||||
repoSlug: string;
|
||||
branch: string;
|
||||
previousRate: number;
|
||||
currentRate: number;
|
||||
}): string {
|
||||
const delta = opts.currentRate - opts.previousRate;
|
||||
const arrow = delta < 0 ? 'regressed' : 'improved';
|
||||
return [
|
||||
`:warning: *Eval ${arrow}* on \`${opts.branch}\` (${opts.repoSlug})`,
|
||||
`Pass rate: ${opts.previousRate.toFixed(0)}% → ${opts.currentRate.toFixed(0)}% (${delta > 0 ? '+' : ''}${delta.toFixed(0)}%)`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// --- Main handler ---
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
try {
|
||||
const payload: WebhookPayload = await req.json();
|
||||
const { record } = payload;
|
||||
|
||||
if (!record || !record.team_id || !record.total_tests) {
|
||||
return new Response('OK (skipped: missing fields)', { status: 200 });
|
||||
}
|
||||
|
||||
const currentRate = computePassRate(record.passed, record.total_tests);
|
||||
if (currentRate === null) {
|
||||
return new Response('OK (skipped: total_tests=0)', { status: 200 });
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
// Check cooldown (5-min dedup)
|
||||
const { data: cooldown } = await supabase
|
||||
.from('alert_cooldowns')
|
||||
.select('last_sent_at')
|
||||
.eq('team_id', record.team_id)
|
||||
.eq('repo_slug', record.repo_slug)
|
||||
.eq('alert_type', 'regression')
|
||||
.single();
|
||||
|
||||
if (cooldown?.last_sent_at) {
|
||||
const cooldownMs = Date.now() - new Date(cooldown.last_sent_at).getTime();
|
||||
if (cooldownMs < 5 * 60 * 1000) {
|
||||
return new Response('OK (cooldown active)', { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Get previous runs for baseline
|
||||
const { data: previousRuns } = await supabase
|
||||
.from('eval_runs')
|
||||
.select('passed, total_tests')
|
||||
.eq('team_id', record.team_id)
|
||||
.eq('repo_slug', record.repo_slug)
|
||||
.neq('id', record.id)
|
||||
.order('timestamp', { ascending: false })
|
||||
.limit(19);
|
||||
|
||||
if (!previousRuns || previousRuns.length < 2) {
|
||||
return new Response('OK (not enough history)', { status: 200 });
|
||||
}
|
||||
|
||||
// Compute baseline pass rate
|
||||
const rates = previousRuns
|
||||
.map(r => computePassRate(r.passed, r.total_tests))
|
||||
.filter((r): r is number => r !== null);
|
||||
|
||||
if (rates.length === 0) {
|
||||
return new Response('OK (no valid baseline)', { status: 200 });
|
||||
}
|
||||
|
||||
const baselineRate = rates.reduce((a, b) => a + b, 0) / rates.length;
|
||||
|
||||
if (!shouldAlert(currentRate, baselineRate)) {
|
||||
return new Response('OK (no regression)', { status: 200 });
|
||||
}
|
||||
|
||||
// Get Slack webhook URL
|
||||
const { data: setting } = await supabase
|
||||
.from('team_settings')
|
||||
.select('value')
|
||||
.eq('team_id', record.team_id)
|
||||
.eq('key', 'slack-webhook')
|
||||
.single();
|
||||
|
||||
if (!setting?.value) {
|
||||
console.log(`Regression detected but no Slack webhook configured for team ${record.team_id}`);
|
||||
return new Response('OK (no webhook configured)', { status: 200 });
|
||||
}
|
||||
|
||||
// Send Slack alert
|
||||
const message = formatSlackMessage({
|
||||
repoSlug: record.repo_slug,
|
||||
branch: record.branch,
|
||||
previousRate: baselineRate,
|
||||
currentRate,
|
||||
});
|
||||
|
||||
const slackRes = await fetch(setting.value, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: message }),
|
||||
});
|
||||
|
||||
if (!slackRes.ok) {
|
||||
console.error(`Slack webhook failed: ${slackRes.status} ${await slackRes.text()}`);
|
||||
}
|
||||
|
||||
// Update cooldown
|
||||
await supabase
|
||||
.from('alert_cooldowns')
|
||||
.upsert({
|
||||
team_id: record.team_id,
|
||||
repo_slug: record.repo_slug,
|
||||
alert_type: 'regression',
|
||||
last_sent_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log(`Regression alert sent: ${record.repo_slug} ${baselineRate.toFixed(0)}% → ${currentRate.toFixed(0)}%`);
|
||||
|
||||
return new Response('OK (alert sent)', { status: 200 });
|
||||
} catch (err) {
|
||||
console.error(`Regression alert error: ${err}`);
|
||||
return new Response('OK (error logged)', { status: 200 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user