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

View File

@@ -5,6 +5,7 @@ import { headers } from "next/headers";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import { writeAudit } from "@/lib/audit";
import { createIssue, buildIssueBody } from "@/lib/github";
const ALLOWED_STATUS = new Set([
"new",
@@ -110,6 +111,63 @@ export async function bulkSetStatus(insightIds: string[], status: string) {
revalidatePath("/insights");
}
export async function createGithubIssueForInsight(
insightId: string,
opts?: { titleOverride?: string; labelsExtra?: string[] },
): Promise<{ url: string; number: number }> {
await requireSession();
const insight = await prisma.insight.findUnique({ where: { id: insightId } });
if (!insight) throw new Error("insight not found");
if (insight.githubIssueUrl) throw new Error("issue already exists");
const panelUrl = process.env.BETTER_AUTH_URL ?? "https://sp.semih.ai";
const title = (opts?.titleOverride ?? insight.title).slice(0, 256);
const body = buildIssueBody({
insightId: insight.id,
panelUrl,
type: insight.type,
severity: insight.severity,
occurrenceCount: insight.occurrenceCount,
uniqueUserCount: insight.uniqueUserCount,
firstSeenAt: insight.firstSeenAt,
lastSeenAt: insight.lastSeenAt,
confidence: insight.confidence,
model: insight.sourceModel,
body: insight.body as Record<string, unknown>,
relatedSessionIds: insight.relatedSessionIds,
});
const labels = [
"insight-driven",
`severity-${insight.severity}`,
`type-${insight.type}`,
`${insight.projectKey}-pilot`,
...(opts?.labelsExtra ?? []),
];
const issue = await createIssue(insight.projectKey, { title, body, labels });
await prisma.insight.update({
where: { id: insightId },
data: {
githubIssueUrl: issue.html_url,
githubIssueId: BigInt(issue.id),
githubIssueNumber: issue.number,
githubIssueState: issue.state,
status: "in_backlog",
},
});
await writeAudit({
endpoint: `/insights/${insightId}/github-issue`,
method: "POST",
requestPayload: { issueNumber: issue.number, repo: process.env[`GITHUB_REPO_${insight.projectKey.toUpperCase()}`] },
responseStatus: 200,
});
revalidatePath("/insights");
revalidatePath(`/insights/i/${insightId}`);
return { url: issue.html_url, number: issue.number };
}
export async function updateBudgetSetting(key: string, value: number | boolean) {
await requireSession();
const allowed = new Set([

View File

@@ -0,0 +1,51 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { createGithubIssueForInsight } from "../../_actions";
export function GithubActions({
insightId,
issueUrl,
issueNumber,
issueState,
}: {
insightId: string;
issueUrl: string | null;
issueNumber: number | null;
issueState: string | null;
}) {
const [pending, start] = useTransition();
const [flash, setFlash] = useState<string | null>(null);
const fire = () =>
start(async () => {
try {
const res = await createGithubIssueForInsight(insightId);
setFlash(`opened #${res.number}`);
setTimeout(() => setFlash(null), 3000);
} catch (e) {
setFlash(`err: ${(e as Error).message}`);
}
});
if (issueUrl) {
return (
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-3 text-sm">
<span className="text-muted-foreground">GitHub:</span>
<a href={issueUrl} target="_blank" rel="noreferrer" className="font-mono underline">
#{issueNumber} ({issueState})
</a>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Button size="sm" variant="default" disabled={pending} onClick={fire}>
Create GitHub Issue
</Button>
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { notFound } from "next/navigation";
import { FounderForm } from "./_founder-form";
import { GithubActions } from "./_github-actions";
export const dynamic = "force-dynamic";
@@ -49,6 +50,13 @@ export default async function InsightDetailPage({
<div className="col-span-2">fingerprint: <span className="font-mono">{insight.fingerprint}</span></div>
</div>
<GithubActions
insightId={insight.id}
issueUrl={insight.githubIssueUrl}
issueNumber={insight.githubIssueNumber}
issueState={insight.githubIssueState}
/>
<FounderForm
insightId={insight.id}
initialNotes={insight.founderNotes ?? ""}

View File

@@ -85,6 +85,7 @@ export default async function InsightInboxPage({
<p className="text-sm text-muted-foreground">
AI-generated insights from Sase.tr session pipeline. Press <kbd>?</kbd> for shortcuts.{" "}
<a href="/insights/pipeline" className="underline">Pipeline</a> ·{" "}
<a href="/insights/patterns" className="underline">Patterns</a> ·{" "}
<a href="/insights/costs" className="underline">Costs</a> ·{" "}
<a href="/insights/settings/budgets" className="underline">Budgets</a> ·{" "}
<a href="/insights/settings/prompts" className="underline">Prompts</a>

View File

@@ -0,0 +1,152 @@
import { PanelShell } from "@/components/panel-shell";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const dynamic = "force-dynamic";
type Cluster = {
key: string;
insights: Array<{
id: string;
title: string;
severity: string;
status: string;
occurrenceCount: number;
affectedRoute: string | null;
fingerprint: string;
}>;
totalOccurrences: number;
maxSeverity: string;
topRoute: string | null;
topProvider: string | null;
};
const SEV_RANK: Record<string, number> = { P0: 4, P1: 3, P2: 2, P3: 1, INFO: 0 };
function severityVariant(sev: string): "default" | "destructive" | "outline" {
if (sev === "P0" || sev === "P1") return "destructive";
if (sev === "P2") return "default";
return "outline";
}
export default async function PatternsPage() {
// Pull active insights (not dismissed/duplicate/validated)
const insights = await prisma.insight.findMany({
where: { status: { notIn: ["dismissed", "duplicate", "validated"] } },
orderBy: { lastSeenAt: "desc" },
take: 500,
});
// Build clusters: bucket by (type + affected_route|affected_provider)
const clusters = new Map<string, Cluster>();
for (const i of insights) {
const b = i.body as Record<string, unknown>;
const route =
typeof b.affected_route === "string" ? (b.affected_route as string) : null;
const provider =
typeof b.affected_provider === "string"
? (b.affected_provider as string)
: typeof b.implicated_provider === "string"
? (b.implicated_provider as string)
: null;
const dimension = route ?? (provider ? `provider:${provider}` : "(general)");
const key = `${i.type}::${dimension}`;
let c = clusters.get(key);
if (!c) {
c = {
key,
insights: [],
totalOccurrences: 0,
maxSeverity: "INFO",
topRoute: route,
topProvider: provider,
};
clusters.set(key, c);
}
c.insights.push({
id: i.id,
title: i.title,
severity: i.severity,
status: i.status,
occurrenceCount: i.occurrenceCount,
affectedRoute: route,
fingerprint: i.fingerprint,
});
c.totalOccurrences += i.occurrenceCount;
if (SEV_RANK[i.severity] > SEV_RANK[c.maxSeverity]) c.maxSeverity = i.severity;
}
// Keep clusters with 2+ insights or 5+ occurrences
const interesting = [...clusters.values()]
.filter((c) => c.insights.length >= 2 || c.totalOccurrences >= 5)
.sort((a, b) => {
const r = SEV_RANK[b.maxSeverity] - SEV_RANK[a.maxSeverity];
if (r !== 0) return r;
return b.totalOccurrences - a.totalOccurrences;
});
return (
<PanelShell title="Insights · patterns">
<p className="text-sm text-muted-foreground">
Cross-insight clusters grouped by type + affected route/provider. Helpful for spotting
root-cause patterns the LLM didn&apos;t merge automatically.{" "}
<a href="/insights" className="underline">Inbox</a>
</p>
{interesting.length === 0 ? (
<p className="text-sm text-muted-foreground">No multi-insight patterns yet.</p>
) : (
interesting.map((c) => {
const parts = c.key.split("::");
return (
<div key={c.key} className="rounded-md border">
<div className="flex items-center gap-2 border-b bg-muted/30 p-3">
<Badge variant={severityVariant(c.maxSeverity)}>{c.maxSeverity}</Badge>
<span className="font-mono text-xs">{parts[0]}</span>
<span className="text-xs text-muted-foreground"></span>
<span className="font-mono text-xs">{parts[1]}</span>
<span className="ml-auto text-xs text-muted-foreground">
{c.insights.length} insights · {c.totalOccurrences} occurrences
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">Sev</TableHead>
<TableHead>Title</TableHead>
<TableHead className="w-[80px]">Status</TableHead>
<TableHead className="w-[60px]">Occ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{c.insights.map((i) => (
<TableRow key={i.id}>
<TableCell>
<Badge variant={severityVariant(i.severity)}>{i.severity}</Badge>
</TableCell>
<TableCell>
<a href={`/insights/i/${i.id}`} className="underline">
{i.title}
</a>
</TableCell>
<TableCell className="text-xs">{i.status}</TableCell>
<TableCell className="font-mono text-xs">{i.occurrenceCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
})
)}
</PanelShell>
);
}