417 lines · 12.0 KB · TypeScript
RawDownload
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> = {}): 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);  }),);