1import {
2 ProjectId,
3 ThreadId,
4 type OrchestrationV2AppThread,
5 type OrchestrationV2DomainEvent,
6 type ThreadPullRequestLink,
7} from "@t3tools/contracts";
8import { assert, expect, it } from "@effect/vitest";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12import * as SqlClient from "effect/sql/SqlClient";
13
14import * as SqlitePersistence from "../persistence/Sqlite.ts";
15import { listLinkedPullRequestThreads, makeLinkedThreadsChangeDetector } from "./linkedThreads.ts";
16
17const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
18
19it.effect(
20 "finds active, settled, and archived threads for exactly one pull request, excluding deleted, dismissed, and side chat links",
21 () =>
22 Effect.gen(function* () {
23 const sql = yield* SqlClient.SqlClient;
24 const createdAt = "2026-09-01T00:00:00.000Z";
25 const archivedAt = "2026-09-03T00:00:00.000Z";
26 const fixtures = [
27 {
28 id: "forgejo-old",
29 host: "forge.example",
30 repository: "acme/web",
31 number: 7,
32 source: "manual",
33 url: "http://forge.example:3000/acme/web/pulls/7",
34 },
35 {
36 id: "forgejo-other-port",
37 host: "forge.example:4000",
38 repository: "acme/web",
39 number: 7,
40 source: "manual",
41 url: "http://forge.example:4000/acme/web/pulls/7",
42 },
43 {
44 id: "azure",
45 host: "dev.azure.com",
46 repository: "org/project/_git/web",
47 number: 7,
48 source: "manual",
49 },
50 {
51 id: "other-org",
52 host: "dev.azure.com",
53 repository: "other/project/_git/web",
54 number: 7,
55 source: "manual",
56 },
57 { id: "settled", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
58 {
59 id: "auto-settled",
60 host: "github.com",
61 repository: "acme/web",
62 number: 7,
63 source: "manual",
64 },
65 { id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
66 {
67 id: "archived",
68 host: "github.com",
69 repository: "acme/web",
70 number: 7,
71 source: "created",
72 },
73 { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
74 {
75 id: "side-chat",
76 host: "github.com",
77 repository: "acme/web",
78 number: 7,
79 source: "manual",
80 },
81 {
82 id: "dismissed",
83 host: "github.com",
84 repository: "acme/web",
85 number: 7,
86 source: "stack-dismissed",
87 },
88 {
89 id: "other-host",
90 host: "github.example.com",
91 repository: "acme/web",
92 number: 7,
93 source: "manual",
94 },
95 {
96 id: "other-repository",
97 host: "github.com",
98 repository: "acme/api",
99 number: 7,
100 source: "manual",
101 },
102 {
103 id: "other-number",
104 host: "github.com",
105 repository: "acme/web",
106 number: 8,
107 source: "manual",
108 },
109 ];
110 for (const fixture of fixtures) {
111 const payload = yield* encodePayload({
112 linkedPullRequest: null,
113 settledOverride: ["settled", "archived"].includes(fixture.id) ? "settled" : null,
114 settledAt: fixture.id === "auto-settled" ? archivedAt : null,
115 ...(fixture.id === "side-chat" ? { lineage: { sideChat: true } } : {}),
116 pullRequests: [{ ...fixture, linkedAt: createdAt, snapshot: null, stack: null }],
117 });
118 yield* sql`
119 INSERT INTO orchestration_v2_projection_threads (
120 thread_id, project_id, title, default_provider, runtime_mode, interaction_mode,
121 created_at, updated_at, archived_at, deleted_at, payload_json
122 ) VALUES (
123 ${fixture.id}, 'project-1', ${fixture.id}, 'codex', 'full-access', 'default',
124 ${createdAt}, ${fixture.id === "archived" ? archivedAt : createdAt},
125 ${fixture.id === "archived" ? archivedAt : null},
126 ${fixture.id === "deleted" ? archivedAt : null}, ${payload}
127 )
128 `;
129 }
130 yield* sql`
131 INSERT INTO orchestration_v2_projection_threads (
132 thread_id, project_id, title, default_provider, runtime_mode, interaction_mode,
133 created_at, updated_at, payload_json
134 ) VALUES (
135 'explicitly-unlinked', 'project-1', 'explicitly-unlinked', 'codex', 'full-access', 'default',
136 ${createdAt}, ${createdAt},
137 '{"linkedPullRequest":{"projectId":"project-1","repository":"acme/web","number":7,"url":"https://github.com/acme/web/pull/7"},"pullRequests":[]}'
138 )
139 `;
140 yield* sql`
141 INSERT INTO projection_projects (
142 project_id, title, workspace_root, scripts_json, created_at, updated_at
143 ) VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt})
144 `;
145 yield* sql`
146 INSERT INTO projection_threads (
147 thread_id, project_id, title, model_selection_json, created_at, updated_at
148 ) VALUES ('v1-only', 'project-1', 'v1-only',
149 '{"instanceId":"codex","model":"gpt-5.4"}', ${createdAt}, ${createdAt})
150 `;
151 yield* sql`
152 INSERT INTO projection_thread_pull_requests (
153 thread_id, host, repository, number, url, source, linked_at
154 ) VALUES ('v1-only', 'github.com', 'acme/web', 7,
155 'https://github.com/acme/web/pull/7', 'manual', ${createdAt})
156 `;
157
158 expect(
159 (yield* listLinkedPullRequestThreads({
160 host: "forge.example:3000",
161 repository: "acme/web",
162 number: 7,
163 })).threads.map((thread) => thread.id),
164 ).toEqual(["forgejo-old"]);
165 expect(
166 (yield* listLinkedPullRequestThreads({
167 host: "forge.example:4000",
168 repository: "acme/web",
169 number: 7,
170 })).threads.map((thread) => thread.id),
171 ).toEqual(["forgejo-other-port"]);
172 expect(
173 (yield* listLinkedPullRequestThreads({
174 host: "org.visualstudio.com",
175 repository: "project/_git/web",
176 number: 7,
177 })).threads.map((thread) => thread.id),
178 ).toEqual(["azure"]);
179 const result = yield* listLinkedPullRequestThreads({
180 host: "GitHub.Com",
181 repository: "ACME/WEB",
182 number: 7,
183 });
184 expect(result).toEqual({
185 threads: [
186 { id: "archived", projectId: "project-1", title: "archived", archivedAt, settled: true },
187 {
188 id: "active",
189 projectId: "project-1",
190 title: "active",
191 archivedAt: null,
192 settled: false,
193 },
194 {
195 id: "auto-settled",
196 projectId: "project-1",
197 title: "auto-settled",
198 archivedAt: null,
199 settled: true,
200 },
201 {
202 id: "settled",
203 projectId: "project-1",
204 title: "settled",
205 archivedAt: null,
206 settled: true,
207 },
208 ],
209 });
210 assert.deepStrictEqual(
211 yield* listLinkedPullRequestThreads({
212 host: "github.com",
213 repository: "acme/web",
214 number: 99,
215 }),
216 { threads: [] },
217 );
218 }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
219);
220
221it("signals linked-thread changes only when a linked answer could change", () => {
222 const now = DateTime.makeUnsafe("2026-11-12T00:00:00.000Z");
223 const threadId = ThreadId.make("thread-a");
224 const link: ThreadPullRequestLink = {
225 host: "github.com",
226 repository: "acme/web",
227 number: 7,
228 url: "https://github.com/acme/web/pull/7",
229 source: "agent",
230 linkedAt: "2026-11-12T00:00:00.000Z",
231 snapshot: null,
232 stack: null,
233 };
234 const base = {
235 id: threadId,
236 projectId: ProjectId.make("project-a"),
237 title: "Thread A",
238 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
239 archivedAt: null,
240 settledOverride: null,
241 settledAt: null,
242 deletedAt: null,
243 updatedAt: now,
244 } as unknown as OrchestrationV2AppThread;
245 const event = (
246 type: OrchestrationV2DomainEvent["type"],
247 payload: Partial<OrchestrationV2AppThread>,
248 ) => ({ type, threadId, payload: { ...base, ...payload } }) as OrchestrationV2DomainEvent;
249 const changed = makeLinkedThreadsChangeDetector();
250
251 expect(
252 [
253 event("thread.metadata-updated", { title: "Unlinked rename" }),
254 event("thread.pull-request-synced", { pullRequests: [link] }),
255 event("thread.pull-request-synced", {
256 pullRequests: [{ ...link, watch: { since: "2026-11-12T00:00:00.000Z" } as never }],
257 }),
258 event("thread.visited", { pullRequests: [link] }),
259 event("thread.pinned", { pullRequests: [link], settledAt: now }),
260 { type: "thread.pull-request-link-updated", threadId, payload: link } as never,
261 event("thread.metadata-updated", { pullRequests: [link], title: "Renamed" }),
262 event("thread.archived", { pullRequests: [link], title: "Renamed", archivedAt: now }),
263 event("thread.pull-request-synced", {
264 pullRequests: [{ ...link, source: "stack-dismissed" }],
265 title: "Renamed",
266 archivedAt: now,
267 }),
268 event("thread.deleted", { pullRequests: [], deletedAt: now }),
269 ].map(changed),
270 ).toEqual([
271 "changed",
272 "changed",
273 null,
274 null,
275 "changed",
276 null,
277 "changed",
278 "changed",
279 "changed",
280 null,
281 ]);
282
283 const afterRestart = makeLinkedThreadsChangeDetector();
284 expect(afterRestart(event("thread.created", { title: "New" }))).toBe(null);
285 expect(
286 makeLinkedThreadsChangeDetector()(
287 event("thread.deleted", { pullRequests: [], deletedAt: now }),
288 ),
289 ).toBe("changed");
290});
291
292it("signals when activity reorders the threads linked to a pull request", () => {
293 const at = (second: number) => DateTime.makeUnsafe(`2026-11-12T00:00:0${second}.000Z`);
294 const link = {
295 host: "github.com",
296 repository: "acme/web",
297 number: 7,
298 url: "https://github.com/acme/web/pull/7",
299 source: "agent",
300 };
301 const thread = (id: string, second: number, pullRequests: ReadonlyArray<unknown> = [link]) =>
302 ({
303 type: "thread.pull-request-synced",
304 threadId: ThreadId.make(id),
305 payload: {
306 id: ThreadId.make(id),
307 projectId: ProjectId.make("project-a"),
308 title: id,
309 lineage: { rootThreadId: ThreadId.make(id) },
310 pullRequests,
311 archivedAt: null,
312 settledOverride: null,
313 settledAt: null,
314 deletedAt: null,
315 updatedAt: at(second),
316 },
317 }) as unknown as OrchestrationV2DomainEvent;
318 const activity = (id: string, second: number) =>
319 ({
320 type: "message.updated",
321 threadId: ThreadId.make(id),
322 occurredAt: at(second),
323 }) as unknown as OrchestrationV2DomainEvent;
324 const changed = makeLinkedThreadsChangeDetector();
325 changed(thread("a", 1));
326 changed(thread("b", 2));
327
328 expect(
329 [
330 { ...thread("a", 3), type: "thread.pinned" } as OrchestrationV2DomainEvent,
331 activity("a", 4),
332 activity("b", 5),
333 activity("b", 5),
334 // Seen since startup without links, so its activity can't reorder an answer.
335 thread("unlinked", 5, []),
336 activity("unlinked", 6),
337 // Not seen since startup, so it may be linked from before the restart.
338 activity("persisted", 6),
339 ].map(changed),
340 ).toEqual(["reordered", "reordered", "reordered", null, "changed", null, "reordered"]);
341
342 // Other threads linked to the pull request may have had no event since the restart.
343 const afterRestart = makeLinkedThreadsChangeDetector();
344 afterRestart(thread("a", 2));
345 expect(
346 afterRestart({ ...thread("a", 6), type: "thread.pinned" } as OrchestrationV2DomainEvent),
347 ).toBe("reordered");
348});