| 1 | import { |
| 2 | CommandId, |
| 3 | ProjectId, |
| 4 | ProviderInstanceId, |
| 5 | ThreadId, |
| 6 | type GitRunStackedActionResult, |
| 7 | type OrchestrationV2ServerCommand as OrchestrationCommand, |
| 8 | type OrchestrationProjectShell, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import { describe, expect, it } from "@effect/vitest"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Ref from "effect/Ref"; |
| 14 | |
| 15 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 16 | import { |
| 17 | type PullRequestTestThread, |
| 18 | v2PullRequestThread, |
| 19 | } from "../orchestration-v2/testkit/pullRequestFixtures.ts"; |
| 20 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 21 | import * as ProjectService from "../project/ProjectService.ts"; |
| 22 | import { refreshPushedPullRequests } from "./refreshPushedPullRequests.ts"; |
| 23 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 24 | import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts"; |
| 25 | |
| 26 | const PROJECT_ID = ProjectId.make("project-1"); |
| 27 | const THREAD_ID = ThreadId.make("thread-1"); |
| 28 | const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test")); |
| 29 | |
| 30 | const project: OrchestrationProjectShell = { |
| 31 | id: PROJECT_ID, |
| 32 | title: "Project", |
| 33 | workspaceRoot: "/workspace/project", |
| 34 | defaultModelSelection: null, |
| 35 | scripts: [], |
| 36 | repositoryIdentity: { |
| 37 | canonicalKey: "github.acme.test/platform/api", |
| 38 | locator: { |
| 39 | source: "git-remote", |
| 40 | remoteName: "origin", |
| 41 | remoteUrl: "git@github.acme.test:Platform/API.git", |
| 42 | }, |
| 43 | provider: "github", |
| 44 | displayName: "Platform/API", |
| 45 | owner: "Platform", |
| 46 | name: "API", |
| 47 | }, |
| 48 | createdAt: "2026-08-01T00:00:00.000Z", |
| 49 | updatedAt: "2026-08-01T00:00:00.000Z", |
| 50 | }; |
| 51 | |
| 52 | const thread: PullRequestTestThread = { |
| 53 | id: THREAD_ID, |
| 54 | projectId: PROJECT_ID, |
| 55 | title: "Thread", |
| 56 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 57 | runtimeMode: "full-access", |
| 58 | interactionMode: "default", |
| 59 | branch: null, |
| 60 | worktreePath: null, |
| 61 | pullRequests: [], |
| 62 | createdAt: "2026-08-01T00:00:00.000Z", |
| 63 | updatedAt: "2026-08-20T00:00:00.000Z", |
| 64 | archivedAt: null, |
| 65 | settledOverride: null, |
| 66 | settledAt: null, |
| 67 | latestUserMessageAt: "2026-08-20T00:00:00.000Z", |
| 68 | }; |
| 69 | |
| 70 | function prResult(pr: GitRunStackedActionResult["pr"]): Pick<GitRunStackedActionResult, "pr"> { |
| 71 | return { pr }; |
| 72 | } |
| 73 | |
| 74 | const layerDependenciesFor = ( |
| 75 | dispatch: Orchestrator.OrchestratorV2Shape["dispatch"], |
| 76 | threadShell: PullRequestTestThread | null = thread, |
| 77 | ) => |
| 78 | Layer.mergeAll( |
| 79 | Layer.mock(ProjectService.ProjectService)({ |
| 80 | getShell: () => Effect.succeedSome(project), |
| 81 | }), |
| 82 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 83 | getThreadShell: () => Effect.succeed(threadShell ? v2PullRequestThread(threadShell) : null), |
| 84 | dispatch, |
| 85 | }), |
| 86 | ); |
| 87 | |
| 88 | const recordingDispatch = Effect.fn("recordingDispatch")(function* () { |
| 89 | const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]); |
| 90 | const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => |
| 91 | Ref.update(commands, (recorded) => [...recorded, command]).pipe( |
| 92 | Effect.as({ sequence: 1, storedEvents: [] }), |
| 93 | ); |
| 94 | return { commands, dispatch }; |
| 95 | }); |
| 96 | |
| 97 | describe("createdPullRequestKey", () => { |
| 98 | it("reads host and repository from the URL when it is recognisable", () => { |
| 99 | expect( |
| 100 | createdPullRequestKey( |
| 101 | prResult({ |
| 102 | status: "created", |
| 103 | number: 12, |
| 104 | url: "https://github.com/Other/Fork/pull/12", |
| 105 | }), |
| 106 | project, |
| 107 | ), |
| 108 | ).toEqual({ |
| 109 | host: "github.com", |
| 110 | repository: "other/fork", |
| 111 | number: 12, |
| 112 | url: "https://github.com/Other/Fork/pull/12", |
| 113 | }); |
| 114 | }); |
| 115 | |
| 116 | it("falls back to the project's host and repository for an unreadable URL", () => { |
| 117 | expect( |
| 118 | createdPullRequestKey( |
| 119 | prResult({ status: "opened_existing", number: 3, url: "https://ghe.internal/x/3" }), |
| 120 | project, |
| 121 | ), |
| 122 | ).toEqual({ |
| 123 | host: "github.acme.test", |
| 124 | repository: "platform/api", |
| 125 | number: 3, |
| 126 | url: "https://ghe.internal/x/3", |
| 127 | }); |
| 128 | expect( |
| 129 | createdPullRequestKey( |
| 130 | prResult({ status: "created", number: 3, url: "https://ghe.internal/x/3" }), |
| 131 | undefined, |
| 132 | ), |
| 133 | ).toBeNull(); |
| 134 | }); |
| 135 | |
| 136 | it("yields nothing when no pull request came out of the action", () => { |
| 137 | expect( |
| 138 | createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project), |
| 139 | ).toBeNull(); |
| 140 | expect( |
| 141 | createdPullRequestKey( |
| 142 | prResult({ status: "created", url: "https://github.com/a/b/pull/1" }), |
| 143 | project, |
| 144 | ), |
| 145 | ).toBeNull(); |
| 146 | expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull(); |
| 147 | }); |
| 148 | }); |
| 149 | |
| 150 | describe("linkCreatedPullRequest", () => { |
| 151 | it.effect("links a created pull request to the thread with source created", () => |
| 152 | Effect.gen(function* () { |
| 153 | const { commands, dispatch } = yield* recordingDispatch(); |
| 154 | yield* linkCreatedPullRequest({ |
| 155 | threadId: THREAD_ID, |
| 156 | result: prResult({ |
| 157 | status: "created", |
| 158 | number: 42, |
| 159 | url: "https://github.com/t3tools/t3code/pull/42", |
| 160 | }), |
| 161 | commandId, |
| 162 | }).pipe(Effect.provide(layerDependenciesFor(dispatch))); |
| 163 | |
| 164 | expect(yield* Ref.get(commands)).toEqual([ |
| 165 | { |
| 166 | type: "thread.pull-request.link", |
| 167 | commandId: "server:pr-created-link:test", |
| 168 | threadId: THREAD_ID, |
| 169 | host: "github.com", |
| 170 | repository: "t3tools/t3code", |
| 171 | number: 42, |
| 172 | url: "https://github.com/t3tools/t3code/pull/42", |
| 173 | source: "created", |
| 174 | }, |
| 175 | ]); |
| 176 | }), |
| 177 | ); |
| 178 | |
| 179 | it.effect("dispatches nothing when the action produced no pull request", () => |
| 180 | Effect.gen(function* () { |
| 181 | const { commands, dispatch } = yield* recordingDispatch(); |
| 182 | const layerDependencies = layerDependenciesFor(dispatch); |
| 183 | yield* linkCreatedPullRequest({ |
| 184 | threadId: THREAD_ID, |
| 185 | result: prResult({ status: "skipped_not_requested" }), |
| 186 | commandId, |
| 187 | }).pipe(Effect.provide(layerDependencies)); |
| 188 | yield* linkCreatedPullRequest({ |
| 189 | threadId: THREAD_ID, |
| 190 | result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }), |
| 191 | commandId, |
| 192 | }).pipe(Effect.provide(layerDependencies)); |
| 193 | |
| 194 | expect(yield* Ref.get(commands)).toEqual([]); |
| 195 | }), |
| 196 | ); |
| 197 | |
| 198 | it.effect("swallows an already-linked rejection and other dispatch failures", () => |
| 199 | Effect.gen(function* () { |
| 200 | const rejecting: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => |
| 201 | Effect.fail( |
| 202 | new Orchestrator.OrchestratorDispatchError({ |
| 203 | commandId: command.commandId, |
| 204 | commandType: command.type, |
| 205 | cause: "already linked", |
| 206 | }), |
| 207 | ); |
| 208 | const result = prResult({ |
| 209 | status: "opened_existing", |
| 210 | number: 7, |
| 211 | url: "https://github.com/t3tools/t3code/pull/7", |
| 212 | }); |
| 213 | yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( |
| 214 | Effect.provide(layerDependenciesFor(rejecting)), |
| 215 | ); |
| 216 | yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( |
| 217 | Effect.provide(layerDependenciesFor(() => Effect.die(new Error("engine down")))), |
| 218 | ); |
| 219 | // A thread that vanished between the action and the link is not an error either. |
| 220 | yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( |
| 221 | Effect.provide(layerDependenciesFor(() => Effect.die(new Error("unreachable")), null)), |
| 222 | ); |
| 223 | }), |
| 224 | ); |
| 225 | }); |
| 226 | |
| 227 | it.effect( |
| 228 | "refreshes PR readers after a push from a thread or project, but not a local commit", |
| 229 | () => |
| 230 | Effect.gen(function* () { |
| 231 | const refreshed: string[] = []; |
| 232 | const layerDependencies = Layer.mergeAll( |
| 233 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 234 | getThreadShell: () => Effect.succeed(v2PullRequestThread(thread)), |
| 235 | }), |
| 236 | Layer.mock(ProjectStore.ProjectStoreV2)({ |
| 237 | listShells: () => Effect.succeed([project]), |
| 238 | }), |
| 239 | Layer.mock(PullRequestService.PullRequestService)({ |
| 240 | refreshAfterTurn: (id) => |
| 241 | Effect.sync(() => { |
| 242 | refreshed.push(id); |
| 243 | }), |
| 244 | }), |
| 245 | ); |
| 246 | yield* refreshPushedPullRequests( |
| 247 | { cwd: "/worktree", threadId: THREAD_ID }, |
| 248 | { push: { status: "pushed" } }, |
| 249 | ).pipe(Effect.provide(layerDependencies)); |
| 250 | yield* refreshPushedPullRequests( |
| 251 | { cwd: project.workspaceRoot }, |
| 252 | { push: { status: "pushed" } }, |
| 253 | ).pipe(Effect.provide(layerDependencies)); |
| 254 | yield* refreshPushedPullRequests({ cwd: "/unrelated" }, { push: { status: "pushed" } }).pipe( |
| 255 | Effect.provide(layerDependencies), |
| 256 | ); |
| 257 | yield* refreshPushedPullRequests( |
| 258 | { cwd: project.workspaceRoot, threadId: THREAD_ID }, |
| 259 | { push: { status: "skipped_not_requested" } }, |
| 260 | ).pipe(Effect.provide(layerDependencies)); |
| 261 | yield* refreshPushedPullRequests( |
| 262 | { cwd: "/draft-worktree", projectId: PROJECT_ID }, |
| 263 | { push: { status: "pushed" } }, |
| 264 | ).pipe(Effect.provide(layerDependencies)); |
| 265 | expect(refreshed).toEqual([PROJECT_ID, PROJECT_ID, PROJECT_ID]); |
| 266 | }), |
| 267 | ); |