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:
Semih
2026-05-14 05:45:48 +00:00
parent 5beb89f771
commit c5acdfc8ec
12 changed files with 644 additions and 2 deletions

View 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 });
}