1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type GitRunStackedActionResult,
7 type OrchestrationV2ServerCommand as OrchestrationCommand,
8 type OrchestrationProjectShell,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "@effect/vitest";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14
15import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
16import {
17 type PullRequestTestThread,
18 v2PullRequestThread,
19} from "../orchestration-v2/testkit/pullRequestFixtures.ts";
20import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
21import * as ProjectService from "../project/ProjectService.ts";
22import { refreshPushedPullRequests } from "./refreshPushedPullRequests.ts";
23import * as PullRequestService from "../pullRequest/PullRequestService.ts";
24import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts";
25
26const PROJECT_ID = ProjectId.make("project-1");
27const THREAD_ID = ThreadId.make("thread-1");
28const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test"));
29
30const 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
52const 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
70function prResult(pr: GitRunStackedActionResult["pr"]): Pick<GitRunStackedActionResult, "pr"> {
71 return { pr };
72}
73
74const 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
88const 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
97describe("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
150describe("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
227it.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);