import "dotenv/config"; import { strict as assert } from "node:assert"; import crypto from "node:crypto"; import { after, before, test } from "node:test"; import { closeDb, db, runMigrations, schema } from "@superlog/db"; import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; import { mountGithubPublic } from "./github.js"; const GH_WEBHOOK_SECRET = "github-test-secret"; process.env.GITHUB_APP_WEBHOOK_SECRET = GH_WEBHOOK_SECRET; const orgIds: string[] = []; before(async () => { await runMigrations(); }); after(async () => { try { for (const orgId of orgIds.reverse()) { await db.delete(schema.orgs).where(eq(schema.orgs.id, orgId)); } } finally { await closeDb(); } }); test("merged agent PR resolves incident, cascades linked issues, and writes timeline events", async () => { const fixture = await seedAgentPrFixture("merged"); const app = new Hono(); mountGithubPublic(app); const mergedAt = new Date().toISOString(); const res = await postGithub(app, "pull_request", `gh-${fixture.tag}-merged`, { action: "closed", repository: { full_name: fixture.repoFullName }, pull_request: { number: fixture.prNumber, merged: true, merged_at: mergedAt, closed_at: mergedAt, merged_by: { login: "alice", id: 100 }, user: { login: "superlog-bot", id: 999 }, head: { sha: "cafebabe", ref: fixture.branchName }, }, sender: { login: "alice", id: 100 }, installation: { id: fixture.installationId }, }); assert.equal(res.status, 200); const pr = await db.query.agentPullRequests.findFirst({ where: eq(schema.agentPullRequests.id, fixture.agentPrId), }); assert.equal(pr?.state, "merged"); assert.equal(pr?.mergedByLogin, "alice"); assert.ok(pr?.mergedAt); const incident = await db.query.incidents.findFirst({ where: eq(schema.incidents.id, fixture.incidentId), }); assert.equal(incident?.status, "resolved"); const issue = await db.query.issues.findFirst({ where: eq(schema.issues.id, fixture.issueId), }); assert.ok(issue); const resolvedEvents = await db.query.incidentEvents.findMany({ where: and( eq(schema.incidentEvents.agentRunId, fixture.agentRunId), eq(schema.incidentEvents.kind, "incident_resolved"), ), }); assert.equal(resolvedEvents.length, 1); assert.equal( resolvedEvents[0]?.summary, `Incident resolved because PR #${fixture.prNumber} was merged.`, ); assert.equal(resolvedEvents[0]?.detail?.reason, "agent_pr_merged"); const prMergedEvent = await db.query.agentPrEvents.findFirst({ where: and( eq(schema.agentPrEvents.agentPrId, fixture.agentPrId), eq(schema.agentPrEvents.kind, "pr_merged"), ), }); assert.ok(prMergedEvent); const duplicate = await postGithub(app, "pull_request", `gh-${fixture.tag}-merged`, { action: "closed", repository: { full_name: fixture.repoFullName }, pull_request: { number: fixture.prNumber, merged: true, merged_at: mergedAt, closed_at: mergedAt, merged_by: { login: "alice", id: 100 }, }, sender: { login: "alice", id: 100 }, installation: { id: fixture.installationId }, }); assert.equal(duplicate.status, 200); const resolvedEventsAfterDuplicate = await db.query.incidentEvents.findMany({ where: and( eq(schema.incidentEvents.agentRunId, fixture.agentRunId), eq(schema.incidentEvents.kind, "incident_resolved"), ), }); assert.equal(resolvedEventsAfterDuplicate.length, 1); }); test("closed unmerged agent PR does not resolve incident or linked issue", async () => { const fixture = await seedAgentPrFixture("closed"); const app = new Hono(); mountGithubPublic(app); const closedAt = new Date().toISOString(); const res = await postGithub(app, "pull_request", `gh-${fixture.tag}-closed`, { action: "closed", repository: { full_name: fixture.repoFullName }, pull_request: { number: fixture.prNumber, merged: false, closed_at: closedAt, user: { login: "superlog-bot", id: 999 }, }, sender: { login: "alice", id: 100 }, installation: { id: fixture.installationId }, }); assert.equal(res.status, 200); const pr = await db.query.agentPullRequests.findFirst({ where: eq(schema.agentPullRequests.id, fixture.agentPrId), }); assert.equal(pr?.state, "closed"); const incident = await db.query.incidents.findFirst({ where: eq(schema.incidents.id, fixture.incidentId), }); assert.equal(incident?.status, "open"); const issue = await db.query.issues.findFirst({ where: eq(schema.issues.id, fixture.issueId), }); assert.ok(issue); const resolvedEvent = await db.query.incidentEvents.findFirst({ where: and( eq(schema.incidentEvents.agentRunId, fixture.agentRunId), eq(schema.incidentEvents.kind, "incident_resolved"), ), }); assert.equal(resolvedEvent, undefined); }); async function seedAgentPrFixture(label: string): Promise<{ tag: string; repoFullName: string; branchName: string; installationId: number; incidentId: string; issueId: string; agentRunId: string; agentPrId: string; prNumber: number; }> { const tag = `test-${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; const repoFullName = `acme/${tag}`; const branchName = `superlog/${tag}`; const installationId = Math.floor(Date.now() / 1000) + Math.floor(Math.random() * 1_000_000); const prNumber = Math.floor(Math.random() * 10_000) + 1; const [org] = await db .insert(schema.orgs) .values({ name: tag, slug: tag }) .returning(); if (!org) throw new Error("failed to seed org"); orgIds.push(org.id); const [project] = await db .insert(schema.projects) .values({ orgId: org.id, name: "test", slug: tag }) .returning(); if (!project) throw new Error("failed to seed project"); const now = new Date(); const [incident] = await db .insert(schema.incidents) .values({ projectId: project.id, title: "Test incident", service: "api", firstSeen: now, lastSeen: now, }) .returning(); if (!incident) throw new Error("failed to seed incident"); const [issue] = await db .insert(schema.issues) .values({ projectId: project.id, fingerprint: `fp-${tag}`, kind: "span", service: "api", exceptionType: "Error", title: "Test issue", message: "boom", firstSeen: now, lastSeen: now, }) .returning(); if (!issue) throw new Error("failed to seed issue"); await db.insert(schema.incidentIssues).values({ incidentId: incident.id, issueId: issue.id, }); const [agentRun] = await db .insert(schema.agentRuns) .values({ incidentId: incident.id, runtime: "anthropic", state: "running" }) .returning(); if (!agentRun) throw new Error("failed to seed agent run"); const [installation] = await db .insert(schema.githubInstallations) .values({ orgId: org.id, projectId: project.id, installationId, accountLogin: "acme-bot", accountType: "Organization", repos: [{ id: 1, fullName: repoFullName, private: false }], }) .returning(); if (!installation) throw new Error("failed to seed GitHub installation"); const [agentPr] = await db .insert(schema.agentPullRequests) .values({ incidentId: incident.id, agentRunId: agentRun.id, installationId: installation.id, repoFullName, prNumber, prNodeId: `PR_${tag}`, url: `https://github.com/${repoFullName}/pull/${prNumber}`, branchName, baseBranch: "main", headSha: "deadbeef", title: "[superlog] Fix bug", state: "open", lastSyncedAt: now, }) .returning(); if (!agentPr) throw new Error("failed to seed agent PR"); return { tag, repoFullName, branchName, installationId, incidentId: incident.id, issueId: issue.id, agentRunId: agentRun.id, agentPrId: agentPr.id, prNumber, }; } async function postGithub( app: Hono, event: string, delivery: string, payload: unknown, ): Promise { const body = JSON.stringify(payload); return app.request("/github/webhook", { method: "POST", headers: { "content-type": "application/json", "x-github-event": event, "x-github-delivery": delivery, "x-hub-signature-256": ghSign(body), }, body, }); } function ghSign(body: string): string { return `sha256=${crypto.createHmac("sha256", GH_WEBHOOK_SECRET).update(body).digest("hex")}`; }