import type { Doc, Id } from "@code/backend/convex/_generated/dataModel"; import { describe, expect, test } from "vitest"; import { buildActivityTimeline, buildWorkUnitCard, buildWorkUnitDetail, eventsForIssue, extractArtifactPaths, extractLatestSummary, extractPullRequestFromEvents, } from "./work-unit-projection"; import type { LinkedSignal } from "./work-unit-projection"; type Issue = Doc<"projectIssues">; type Event = Doc<"projectEvents">; const ISSUE_A = "issue-a" as Id<"projectIssues">; const ISSUE_B = "issue-b" as Id<"projectIssues">; const makeLinkedSignal = ( overrides: Partial = {} ): LinkedSignal => ({ createdAt: 5000, signalId: "sig-1" as Id<"signals">, sourceCount: 2, summary: "Three users hit the same error.", title: "OAuth crash on Safari", ...overrides, }); const makeIssue = (overrides: Partial = {}): Issue => ({ _creationTime: 1, _id: ISSUE_A, body: "Fix the Safari OAuth callback so users can complete sign-in.", createdAt: 1000, number: 1, projectId: "project-1" as Id<"projects">, status: "open", title: "Fix Safari OAuth callback", updatedAt: 2000, ...overrides, }) as Issue; const makeEvent = ( kind: string, data: Record, overrides: Partial = {} ): Event => ({ _creationTime: 1, _id: `evt-${kind}-${overrides.createdAt ?? 1000}` as Id<"projectEvents">, createdAt: overrides.createdAt ?? 1000, data, issueId: ISSUE_A, kind, projectId: "project-1" as Id<"projects">, ...overrides, }) as Event; describe("eventsForIssue", () => { test("filters to a specific issue and ignores undefined", () => { const events: Event[] = [ makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }), makeEvent( "issue.created", { number: 2 }, { createdAt: 2000, issueId: ISSUE_B } ), ]; expect(eventsForIssue(events, ISSUE_A)).toHaveLength(1); expect(eventsForIssue(undefined, ISSUE_A)).toEqual([]); }); }); describe("extractArtifactPaths", () => { test("collects distinct paths from artifact.updated events", () => { const events: Event[] = [ makeEvent( "artifact.updated", { path: "work.md", revision: 1 }, { createdAt: 1000 } ), makeEvent( "artifact.updated", { path: "work.md", revision: 2 }, { createdAt: 2000 } ), makeEvent( "artifact.updated", { path: "artifacts.md", revision: 1 }, { createdAt: 3000 } ), makeEvent("issue.created", { number: 1 }, { createdAt: 4000 }), ]; expect(extractArtifactPaths(events)).toEqual(["artifacts.md", "work.md"]); }); test("returns empty for no artifact events", () => { expect(extractArtifactPaths([])).toEqual([]); }); }); describe("buildWorkUnitCard", () => { test("projects a fresh issue with no events and no linked signals", () => { const card = buildWorkUnitCard({ issue: makeIssue(), issueEvents: [], linkedSignals: [], }); expect(card.title).toBe("Fix Safari OAuth callback"); expect(card.status).toBe("open"); expect(card.stepCount).toBe(0); expect(card.currentActivity).toBeNull(); expect(card.needsInput).toBe(false); expect(card.hasPullRequest).toBe(false); expect(card.artifactCount).toBe(0); expect(card.signalCount).toBe(0); expect(card.summary).toContain("Fix the Safari OAuth"); }); test("counts per-issue artifacts from events, not project-wide", () => { const events: Event[] = [ makeEvent( "artifact.updated", { path: "work.md", revision: 1 }, { createdAt: 1000 } ), makeEvent( "artifact.updated", { path: "artifacts.md", revision: 1 }, { createdAt: 2000 } ), ]; const card = buildWorkUnitCard({ issue: makeIssue({ status: "working" }), issueEvents: events, linkedSignals: [], }); expect(card.artifactCount).toBe(2); expect(card.stepCount).toBe(2); }); test("reflects needs-input status and activity from events", () => { const events = [ makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }), makeEvent("issue.queued", { number: 1 }, { createdAt: 2000 }), makeEvent("agent.needs-input", {}, { createdAt: 3000 }), ]; const card = buildWorkUnitCard({ issue: makeIssue({ status: "needs-input" }), issueEvents: events, linkedSignals: [], }); expect(card.needsInput).toBe(true); expect(card.stepCount).toBe(3); expect(card.currentActivity).toBe("Needs your input"); }); test("detects PR from gitea event", () => { const events = [ makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }), makeEvent( "gitea.pull_request.created", { pullRequest: { number: 42, url: "https://git.example.com/pulls/42" }, }, { createdAt: 5000 } ), ]; const card = buildWorkUnitCard({ issue: makeIssue({ status: "completed" }), issueEvents: events, linkedSignals: [], }); expect(card.hasPullRequest).toBe(true); }); }); describe("linked signal projection", () => { test("card signalCount reflects real linked signals", () => { const card = buildWorkUnitCard({ issue: makeIssue(), issueEvents: [], linkedSignals: [ makeLinkedSignal(), makeLinkedSignal({ signalId: "sig-2" as Id<"signals">, title: "Build error", }), ], }); expect(card.signalCount).toBe(2); }); test("card signalCount is 0 when no signals are linked", () => { const card = buildWorkUnitCard({ issue: makeIssue(), issueEvents: [], linkedSignals: [], }); expect(card.signalCount).toBe(0); }); test("detail exposes linked signals with title, summary, and source count", () => { const detail = buildWorkUnitDetail({ issue: makeIssue(), issueEvents: [], linkedSignals: [ makeLinkedSignal(), makeLinkedSignal({ signalId: "sig-2" as Id<"signals">, sourceCount: 5, summary: "CI broke", }), ], }); expect(detail.signalCount).toBe(2); expect(detail.linkedSignals).toHaveLength(2); expect(detail.linkedSignals[0].title).toBe("OAuth crash on Safari"); expect(detail.linkedSignals[0].sourceCount).toBe(2); expect(detail.linkedSignals[1].sourceCount).toBe(5); }); test("detail linked signals empty when no attachment exists", () => { const detail = buildWorkUnitDetail({ issue: makeIssue(), issueEvents: [], linkedSignals: [], }); expect(detail.signalCount).toBe(0); expect(detail.linkedSignals).toEqual([]); }); }); describe("extractLatestSummary", () => { test("returns the most recent agent summary", () => { const events = [ makeEvent("agent.working", {}, { createdAt: 1000 }), makeEvent( "agent.completed", { summary: "All tests passed." }, { createdAt: 5000 } ), ]; expect(extractLatestSummary(events)).toBe("All tests passed."); }); test("returns null when no terminal agent event exists", () => { const events = [ makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }), ]; expect(extractLatestSummary(events)).toBeNull(); }); test("prefers failure error over earlier completion", () => { const events = [ makeEvent("agent.completed", { summary: "Done." }, { createdAt: 1000 }), makeEvent( "agent.failed", { error: "Tests failed." }, { createdAt: 5000 } ), ]; expect(extractLatestSummary(events)).toBe("Tests failed."); }); }); describe("extractPullRequestFromEvents", () => { test("extracts URL and number from gitea event", () => { const events = [ makeEvent( "gitea.pull_request.created", { pullRequest: { number: 7, url: "https://git.example.com/pulls/7" } }, { createdAt: 1000 } ), ]; const info = extractPullRequestFromEvents(events); expect(info.hasPR).toBe(true); expect(info.url).toBe("https://git.example.com/pulls/7"); expect(info.number).toBe(7); }); test("returns false when no PR event exists", () => { expect(extractPullRequestFromEvents([]).hasPR).toBe(false); }); }); describe("buildActivityTimeline", () => { test("sorts newest first and maps labels", () => { const events = [ makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }), makeEvent("agent.completed", { summary: "Done." }, { createdAt: 5000 }), ]; const timeline = buildActivityTimeline(events); expect(timeline).toHaveLength(2); expect(timeline[0].kind).toBe("agent.completed"); expect(timeline[0].label).toBe("Work completed"); expect(timeline[1].kind).toBe("issue.created"); expect(timeline[1].label).toBe("Work created"); }); test("maps signal.attached event to activity", () => { const events = [ makeEvent( "signal.attached", { signalId: "sig-1", signalProblemTitle: "OAuth crash" }, { createdAt: 3000 } ), ]; const timeline = buildActivityTimeline(events); expect(timeline[0].label).toBe("Signal linked"); expect(timeline[0].detail).toContain("OAuth crash"); }); }); describe("buildWorkUnitDetail", () => { test("aggregates all detail fields from per-issue events", () => { const events: Event[] = [ makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }), makeEvent( "artifact.updated", { path: "work.md", revision: 2 }, { createdAt: 2000 } ), makeEvent( "artifact.updated", { path: "artifacts.md", revision: 1 }, { createdAt: 2500 } ), makeEvent( "gitea.pull_request.created", { pullRequest: { number: 3, url: "https://git.example.com/pulls/3" } }, { createdAt: 3000 } ), ]; const detail = buildWorkUnitDetail({ issue: makeIssue({ status: "completed" }), issueEvents: events, linkedSignals: [makeLinkedSignal()], }); expect(detail.stepCount).toBe(4); expect(detail.artifactCount).toBe(2); expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]); expect(detail.signalCount).toBe(1); expect(detail.hasPullRequest).toBe(true); expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3"); expect(detail.pullRequestNumber).toBe(3); expect(detail.activity).toHaveLength(4); expect(detail.activity[0].kind).toBe("gitea.pull_request.created"); }); }); // --------------------------------------------------------------------------- // Cross-issue isolation: one issue must never inherit another issue's // artifacts, PR, signals, or activity. // --------------------------------------------------------------------------- describe("cross-issue isolation", () => { const SIGNAL_A = makeLinkedSignal({ signalId: "sig-a" as Id<"signals"> }); const SIGNAL_B = makeLinkedSignal({ signalId: "sig-b" as Id<"signals">, title: "Different signal", }); const sharedEvents: Event[] = [ makeEvent( "issue.created", { number: 1 }, { createdAt: 1000, issueId: ISSUE_A } ), makeEvent( "artifact.updated", { path: "work.md", revision: 1 }, { createdAt: 2000, issueId: ISSUE_A } ), makeEvent( "gitea.pull_request.created", { pullRequest: { number: 5, url: "https://git.example.com/pulls/5" } }, { createdAt: 3000, issueId: ISSUE_A } ), makeEvent( "agent.completed", { summary: "Done for A." }, { createdAt: 4000, issueId: ISSUE_A } ), // Issue B events — completely separate makeEvent( "issue.created", { number: 2 }, { createdAt: 1500, issueId: ISSUE_B } ), makeEvent( "artifact.updated", { path: "artifacts.md", revision: 1 }, { createdAt: 2500, issueId: ISSUE_B } ), makeEvent( "agent.failed", { error: "Failed for B." }, { createdAt: 3500, issueId: ISSUE_B } ), ]; test("issue A does not inherit issue B's artifacts", () => { const eventsA = eventsForIssue(sharedEvents, ISSUE_A); const cardA = buildWorkUnitCard({ issue: makeIssue({ _id: ISSUE_A, number: 1 }), issueEvents: eventsA, linkedSignals: [SIGNAL_A], }); expect(cardA.artifactCount).toBe(1); expect(cardA.stepCount).toBe(4); const eventsB = eventsForIssue(sharedEvents, ISSUE_B); const cardB = buildWorkUnitCard({ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issueEvents: eventsB, linkedSignals: [SIGNAL_B], }); expect(cardB.artifactCount).toBe(1); expect(cardB.stepCount).toBe(3); }); test("issue A does not inherit issue B's PR", () => { const eventsB = eventsForIssue(sharedEvents, ISSUE_B); const cardB = buildWorkUnitCard({ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issueEvents: eventsB, linkedSignals: [], }); expect(cardB.hasPullRequest).toBe(false); }); test("issue A does not inherit issue B's summary", () => { const eventsA = eventsForIssue(sharedEvents, ISSUE_A); expect(extractLatestSummary(eventsA)).toBe("Done for A."); const eventsB = eventsForIssue(sharedEvents, ISSUE_B); expect(extractLatestSummary(eventsB)).toBe("Failed for B."); }); test("detail for issue A lists only issue A's artifact paths", () => { const eventsA = eventsForIssue(sharedEvents, ISSUE_A); const detailA = buildWorkUnitDetail({ issue: makeIssue({ _id: ISSUE_A, number: 1 }), issueEvents: eventsA, linkedSignals: [SIGNAL_A], }); expect(detailA.artifactPaths).toEqual(["work.md"]); expect(detailA.pullRequestNumber).toBe(5); }); test("detail for issue B lists only issue B's artifact paths and no PR", () => { const eventsB = eventsForIssue(sharedEvents, ISSUE_B); const detailB = buildWorkUnitDetail({ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issueEvents: eventsB, linkedSignals: [], }); expect(detailB.artifactPaths).toEqual(["artifacts.md"]); expect(detailB.hasPullRequest).toBe(false); expect(detailB.pullRequestUrl).toBeNull(); }); test("issue A does not inherit issue B's linked signals", () => { const detailA = buildWorkUnitDetail({ issue: makeIssue({ _id: ISSUE_A, number: 1 }), issueEvents: eventsForIssue(sharedEvents, ISSUE_A), linkedSignals: [SIGNAL_A], }); expect(detailA.signalCount).toBe(1); expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari"); const detailB = buildWorkUnitDetail({ issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }), issueEvents: eventsForIssue(sharedEvents, ISSUE_B), linkedSignals: [SIGNAL_B], }); expect(detailB.signalCount).toBe(1); expect(detailB.linkedSignals[0].title).toBe("Different signal"); }); });