mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 05:56:41 +02:00
721abce5a5
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>
176 lines
5.3 KiB
TypeScript
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 });
|
|
}
|
|
});
|