feat(phase6d): GitHub action loop — issue creation, webhook, validation cron, patterns view
Schema: - Insight.+githubIssueNumber (user-visible #N, separate from id BigInt) GitHub integration (apps/web/src/lib/github.ts): - repoFor(projectKey): env-based GITHUB_REPO_<KEY>=owner/repo mapping - createIssue / getIssue REST wrappers - verifyWebhookSignature (HMAC-SHA256 timing-safe) - buildIssueBody: renders structured markdown from insight + LLM body (hypothesis, reproduce steps, affected route/provider, quick/long fixes, suggested investigation, evidence links to panel, DoD checklist) Server action createGithubIssueForInsight: - Auth-gated, audited; idempotent (refuses if issue already exists) - Labels: insight-driven, severity-<P>, type-<T>, <project>-pilot - Sets status=in_backlog, stores githubIssueUrl/Id/Number/State Webhook /api/webhooks/github: - Signature verify with GITHUB_WEBHOOK_SECRET - issues.closed → status=shipped + shippedAt + validationStartedAt - issues.reopened → status=in_progress + clear validation state - issues.opened → status=in_backlog - PR linking placeholder (passthrough only for now) Validation cron (worker, daily 5:00 UTC): - For each insight in 'shipped' state: - Count sessions with same fingerprint after shippedAt - >= INSIGHT_REGRESSION_THRESHOLD (default 3) → status=regressed + regressionDetected=true - validationPeriodDays elapsed with no regression → status=validated + validatedAt UI: - Insight detail: GithubActions card — Create button (when no issue), external link + issue # + state (when present) - New /insights/patterns page: clusters insights by type + affected_route/provider, shows ≥2-insight or ≥5-occurrence groups sorted by max severity - Inbox header link to Patterns - Cmd+K palette: Patterns entry Env needed: GITHUB_TOKEN, GITHUB_REPO_SASE, GITHUB_WEBHOOK_SECRET. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
95
apps/web/src/app/api/webhooks/github/route.ts
Normal file
95
apps/web/src/app/api/webhooks/github/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyWebhookSignature } from "@/lib/github";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const payload = await req.text();
|
||||
const signature = req.headers.get("x-hub-signature-256");
|
||||
|
||||
if (!verifyWebhookSignature(payload, signature)) {
|
||||
return NextResponse.json({ ok: false, error: "bad_signature" }, { status: 401 });
|
||||
}
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(payload);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ghEvent = req.headers.get("x-github-event");
|
||||
if (ghEvent !== "issues" && ghEvent !== "pull_request" && ghEvent !== "ping") {
|
||||
return NextResponse.json({ ok: true, ignored: ghEvent });
|
||||
}
|
||||
|
||||
if (ghEvent === "ping") return NextResponse.json({ ok: true, pong: true });
|
||||
|
||||
if (ghEvent === "issues") {
|
||||
const action = event.action;
|
||||
const issue = event.issue;
|
||||
if (!issue?.id) return NextResponse.json({ ok: true, no_issue: true });
|
||||
|
||||
const insight = await prisma.insight.findFirst({
|
||||
where: { githubIssueId: BigInt(issue.id) },
|
||||
});
|
||||
if (!insight) {
|
||||
return NextResponse.json({ ok: true, not_tracked: true, issue_id: issue.id });
|
||||
}
|
||||
|
||||
let nextStatus: string | null = null;
|
||||
let extra: Record<string, unknown> = {};
|
||||
switch (action) {
|
||||
case "closed":
|
||||
nextStatus = "shipped";
|
||||
extra = { shippedAt: new Date(), validationStartedAt: new Date() };
|
||||
break;
|
||||
case "reopened":
|
||||
nextStatus = "in_progress";
|
||||
extra = { shippedAt: null, validationStartedAt: null, regressionDetected: false };
|
||||
break;
|
||||
case "opened":
|
||||
nextStatus = "in_backlog";
|
||||
break;
|
||||
case "edited":
|
||||
case "labeled":
|
||||
case "unlabeled":
|
||||
// ignore body/label changes
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextStatus) {
|
||||
await prisma.insight.update({
|
||||
where: { id: insight.id },
|
||||
data: { status: nextStatus, githubIssueState: issue.state, ...extra },
|
||||
});
|
||||
} else {
|
||||
await prisma.insight.update({
|
||||
where: { id: insight.id },
|
||||
data: { githubIssueState: issue.state },
|
||||
});
|
||||
}
|
||||
revalidatePath("/insights");
|
||||
revalidatePath(`/insights/i/${insight.id}`);
|
||||
return NextResponse.json({ ok: true, insight_id: insight.id, status: nextStatus });
|
||||
}
|
||||
|
||||
if (ghEvent === "pull_request") {
|
||||
// Map linked PR opening to in_progress. GitHub auto-links issues via #N in PR body.
|
||||
const pr = event.pull_request;
|
||||
const action = event.action;
|
||||
if (!pr?.body) return NextResponse.json({ ok: true, no_pr: true });
|
||||
|
||||
// Find linked issues by #N pattern
|
||||
const refs = Array.from(String(pr.body).matchAll(/#(\d+)/g)).map((m) => Number(m[1]));
|
||||
if (!refs.length) return NextResponse.json({ ok: true, no_refs: true });
|
||||
|
||||
// Look up by issue number (we stored id not number — need to match via API or store number)
|
||||
// For now match against githubIssueId — but PR refs use issue number. Skip safely.
|
||||
return NextResponse.json({ ok: true, pr_refs: refs, action });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user