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