import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent, type OrchestrationReadModel, type RepositoryIdentity, type ThreadPullRequestLink, type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const LATER = "2026-01-02T00:00:00.000Z"; const THREAD_ID = ThreadId.make("thread-1"); const PROJECT_ID = ProjectId.make("project-1"); function makeEvent(input: { readonly sequence: number; readonly type: OrchestrationEvent["type"]; readonly payload: unknown; }): OrchestrationEvent { return { sequence: input.sequence, eventId: EventId.make(`event-${input.sequence}`), type: input.type, aggregateKind: "thread", aggregateId: THREAD_ID, occurredAt: NOW, commandId: CommandId.make(`command-${input.sequence}`), causationEventId: null, correlationId: null, metadata: {}, payload: input.payload as never, } as OrchestrationEvent; } function makeLink(overrides: Partial = {}): ThreadPullRequestLink { return { host: "github.com", repository: "t3tools/t3code", number: 42, url: "https://github.com/t3tools/t3code/pull/42", source: "manual", linkedAt: NOW, snapshot: null, stack: null, ...overrides, }; } const snapshot: ThreadPullRequestSnapshot = { state: "merged", title: "Add links", headBranch: "feat/links", baseBranch: "main", isDraft: false, updatedAt: LATER, syncedAt: LATER, }; const createThread = (model: OrchestrationReadModel) => projectEvent( model, makeEvent({ sequence: model.snapshotSequence + 1, type: "thread.created", payload: { threadId: THREAD_ID, projectId: PROJECT_ID, title: "Thread", modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, createdAt: NOW, updatedAt: NOW, }, }), ); const createProject = (model: OrchestrationReadModel, repositoryIdentity: RepositoryIdentity) => projectEvent(model, { ...makeEvent({ sequence: model.snapshotSequence + 1, type: "project.created", payload: { projectId: PROJECT_ID, title: "Project", workspaceRoot: "/workspace/project", defaultModelSelection: null, scripts: [], createdAt: NOW, updatedAt: NOW, }, }), aggregateKind: "project", aggregateId: PROJECT_ID, }).pipe( Effect.map((next) => ({ ...next, projects: next.projects.map((project) => project.id === PROJECT_ID ? { ...project, repositoryIdentity } : project, ), })), ); it.effect("seeds threads with no pull requests", () => Effect.gen(function* () { const created = yield* createThread(createEmptyReadModel(NOW)); expect(created.threads[0]?.pullRequests).toEqual([]); expect(created.threads[0]?.linkedPullRequest ?? null).toBeNull(); }), ); it.effect("projects link, sync, and unlink onto the thread", () => Effect.gen(function* () { const created = yield* createThread( yield* createProject(createEmptyReadModel(NOW), { canonicalKey: "github.com/t3tools/t3code", provider: "github", displayName: "t3tools/t3code", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "https://github.com/t3tools/t3code.git", }, }), ); const link = makeLink(); const linked = yield* projectEvent( created, makeEvent({ sequence: 2, type: "thread.pull-request-linked", payload: { threadId: THREAD_ID, link, updatedAt: LATER }, }), ); expect(linked.threads[0]?.pullRequests).toEqual([link]); expect(linked.threads[0]?.updatedAt).toBe(LATER); // The legacy field is derived from the array so old clients keep working. expect(linked.threads[0]?.linkedPullRequest).toEqual({ projectId: PROJECT_ID, repository: "t3tools/t3code", number: 42, url: "https://github.com/t3tools/t3code/pull/42", }); // A second link for the same key replaces in place (used for un-dismiss // and stack tombstones), never duplicates. const relinked = yield* projectEvent( linked, makeEvent({ sequence: 3, type: "thread.pull-request-linked", payload: { threadId: THREAD_ID, link: { ...link, host: "GITHUB.COM", source: "agent" }, updatedAt: LATER, }, }), ); expect(relinked.threads[0]?.pullRequests).toHaveLength(1); expect(relinked.threads[0]?.pullRequests[0]?.source).toBe("agent"); const synced = yield* projectEvent( relinked, makeEvent({ sequence: 4, type: "thread.pull-request-synced", payload: { threadId: THREAD_ID, host: "github.com", repository: "t3tools/t3code", number: 42, snapshot, stack: null, updatedAt: LATER, }, }), ); expect(synced.threads[0]?.pullRequests[0]?.snapshot).toEqual(snapshot); const unlinked = yield* projectEvent( synced, makeEvent({ sequence: 5, type: "thread.pull-request-unlinked", payload: { threadId: THREAD_ID, host: "github.com", repository: "t3tools/t3code", number: 42, updatedAt: LATER, }, }), ); expect(unlinked.threads[0]?.pullRequests).toEqual([]); expect(unlinked.threads[0]?.linkedPullRequest).toBeNull(); }), ); it.effect("ignores a sync for a pull request that is no longer linked", () => Effect.gen(function* () { const created = yield* createThread(createEmptyReadModel(NOW)); const other = makeLink({ number: 7, url: "https://github.com/t3tools/t3code/pull/7" }); const linked = yield* projectEvent( created, makeEvent({ sequence: 2, type: "thread.pull-request-linked", payload: { threadId: THREAD_ID, link: other, updatedAt: NOW }, }), ); const synced = yield* projectEvent( linked, makeEvent({ sequence: 3, type: "thread.pull-request-synced", payload: { threadId: THREAD_ID, host: "github.com", repository: "t3tools/t3code", number: 42, snapshot, stack: null, updatedAt: LATER, }, }), ); expect(synced.threads[0]?.pullRequests).toEqual([other]); expect(synced.threads[0]?.updatedAt).toBe(NOW); }), ); it.effect("mirrors legacy meta-updated links into pullRequests using the project host", () => Effect.gen(function* () { const withProject = yield* createProject(createEmptyReadModel(NOW), { canonicalKey: "GitHub.com/t3tools/t3code", provider: "github", displayName: "t3tools/t3code", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:t3tools/t3code.git", }, }); const created = yield* createThread(withProject); const agentLink = makeLink({ number: 7, url: "https://github.com/t3tools/t3code/pull/7", source: "agent", }); const withAgentLink = yield* projectEvent( created, makeEvent({ sequence: 3, type: "thread.pull-request-linked", payload: { threadId: THREAD_ID, link: agentLink, updatedAt: NOW }, }), ); const legacyLinked = yield* projectEvent( withAgentLink, makeEvent({ sequence: 4, type: "thread.meta-updated", payload: { threadId: THREAD_ID, linkedPullRequest: { projectId: PROJECT_ID, repository: "T3Tools/T3Code", number: 42, url: "https://github.com/t3tools/t3code/pull/42", }, updatedAt: LATER, }, }), ); expect(legacyLinked.threads[0]?.pullRequests).toEqual([ agentLink, { host: "github.com", repository: "t3tools/t3code", number: 42, url: "https://github.com/t3tools/t3code/pull/42", source: "manual", linkedAt: LATER, snapshot: null, stack: null, }, ]); // Two open links read as a stack; the derived field points at the top. expect(legacyLinked.threads[0]?.linkedPullRequest).toEqual({ projectId: PROJECT_ID, repository: "t3tools/t3code", number: 42, url: "https://github.com/t3tools/t3code/pull/42", }); // Null clears only the manual link; the agent's stays. const legacyCleared = yield* projectEvent( legacyLinked, makeEvent({ sequence: 5, type: "thread.meta-updated", payload: { threadId: THREAD_ID, linkedPullRequest: null, updatedAt: LATER }, }), ); expect(legacyCleared.threads[0]?.pullRequests).toEqual([agentLink]); expect(legacyCleared.threads[0]?.linkedPullRequest).toEqual({ projectId: PROJECT_ID, repository: "t3tools/t3code", number: 7, url: "https://github.com/t3tools/t3code/pull/7", }); }), ); it.effect("falls back to the link URL host when the project has no repository identity", () => Effect.gen(function* () { const created = yield* createThread(createEmptyReadModel(NOW)); const legacyLinked = yield* projectEvent( created, makeEvent({ sequence: 2, type: "thread.meta-updated", payload: { threadId: THREAD_ID, linkedPullRequest: { projectId: PROJECT_ID, repository: "t3tools/t3code", number: 42, url: "https://GitLab.example.com/t3tools/t3code/-/merge_requests/42", }, updatedAt: LATER, }, }), ); expect(legacyLinked.threads[0]?.pullRequests[0]?.host).toBe("gitlab.example.com"); }), ); it.effect("leaves pullRequests alone when meta-updated carries no legacy link", () => Effect.gen(function* () { const created = yield* createThread(createEmptyReadModel(NOW)); const link = makeLink(); const linked = yield* projectEvent( created, makeEvent({ sequence: 2, type: "thread.pull-request-linked", payload: { threadId: THREAD_ID, link, updatedAt: NOW }, }), ); const retitled = yield* projectEvent( linked, makeEvent({ sequence: 3, type: "thread.meta-updated", payload: { threadId: THREAD_ID, title: "Renamed", updatedAt: LATER }, }), ); expect(retitled.threads[0]?.title).toBe("Renamed"); expect(retitled.threads[0]?.pullRequests).toEqual([link]); }), ); it.effect("replays Azure legacy selectors as full repository keys", () => Effect.gen(function* () { const withProject = yield* createProject(createEmptyReadModel(NOW), { canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web", provider: "azure-devops", displayName: "v3/org-a/project/web", name: "web", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@ssh.dev.azure.com:v3/org-a/project/web", }, }); const created = yield* createThread(withProject); const legacy = { projectId: PROJECT_ID, repository: "web", number: 7, url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", }; const model = yield* projectEvent( created, makeEvent({ sequence: 3, type: "thread.meta-updated", payload: { threadId: THREAD_ID, linkedPullRequest: legacy, updatedAt: LATER }, }), ); expect(model.threads[0]?.pullRequests).toEqual([ { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7, url: legacy.url, source: "manual", linkedAt: LATER, snapshot: null, stack: null, }, ]); expect(model.threads[0]?.linkedPullRequest).toEqual(legacy); }), );