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 });
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
51
apps/web/src/app/insights/i/[id]/_github-actions.tsx
Normal file
51
apps/web/src/app/insights/i/[id]/_github-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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>
|
||||
|
||||
152
apps/web/src/app/insights/patterns/page.tsx
Normal file
152
apps/web/src/app/insights/patterns/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user