Files
gstack/supabase/functions/regression-alert/index.ts
T
Garry Tan 721abce5a5 fix: review-driven hardening — env guards, token expiry, slug validation, dashboard UX
From CEO plan review:
- Edge functions: early guard on missing env vars instead of non-null assert crash
- cli-team: wire isTokenExpired check (was imported but unused)
- Migration 007: CHECK constraint on team slug (a-z0-9 hyphens, 2-50 chars)
- Dashboard: streak badges on leaderboard, repo slug in who's-online,
  contextual empty states that teach, 60s refresh (was 30s)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:59:20 -05:00

176 lines
5.3 KiB
TypeScript

/**
* 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');
if (!supabaseUrl || !serviceKey) {
console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY env vars');
return new Response('OK (missing env vars)', { status: 200 });
}
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 });
}
});