1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as Layer from "effect/Layer";
5import * as SqlClient from "effect/sql/SqlClient";
6
7import { runMigrations } from "../Migrations.ts";
8import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
9
10const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
11
12const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
13
14interface PullRequestRow {
15 readonly threadId: string;
16 readonly host: string;
17 readonly repository: string;
18 readonly number: number;
19 readonly url: string;
20 readonly source: string;
21 readonly linkedAt: string;
22 readonly snapshotJson: string | null;
23 readonly stackJson: string | null;
24}
25
26layer("050_ProjectionThreadPullRequests", (it) => {
27 it.effect("creates the link table and backfills legacy single links", () =>
28 Effect.gen(function* () {
29 const sql = yield* SqlClient.SqlClient;
30
31 yield* runMigrations({ toMigrationInclusive: 49 });
32
33 yield* sql`
34 INSERT INTO projection_projects (
35 project_id,
36 title,
37 workspace_root,
38 scripts_json,
39 created_at,
40 updated_at,
41 deleted_at
42 )
43 VALUES (
44 'project-1',
45 'Project 1',
46 '/tmp/project-1',
47 '[]',
48 '2026-03-01T00:00:00.000Z',
49 '2026-03-01T00:00:00.000Z',
50 NULL
51 )
52 `;
53
54 yield* sql`
55 INSERT INTO projection_threads (
56 thread_id,
57 project_id,
58 title,
59 model_selection_json,
60 linked_pull_request_json,
61 created_at,
62 updated_at
63 )
64 VALUES
65 (
66 'thread-github',
67 'project-1',
68 'GitHub link',
69 '{"instanceId":"codex","model":"gpt-5.4"}',
70 '{"projectId":"project-1","repository":"PingDotGG/T3Code","number":42,"url":"https://GitHub.com/pingdotgg/t3code/pull/42"}',
71 '2026-03-01T00:00:01.000Z',
72 '2026-03-02T00:00:00.000Z'
73 ),
74 (
75 'thread-bad-url',
76 'project-1',
77 'Unparseable URL',
78 '{"instanceId":"codex","model":"gpt-5.4"}',
79 '{"projectId":"project-1","repository":"acme/widgets","number":7,"url":"not a url"}',
80 '2026-03-01T00:00:02.000Z',
81 '2026-03-03T00:00:00.000Z'
82 ),
83 (
84 'thread-malformed',
85 'project-1',
86 'Malformed JSON',
87 '{"instanceId":"codex","model":"gpt-5.4"}',
88 '{"repository":"acme/widgets"}',
89 '2026-03-01T00:00:03.000Z',
90 '2026-03-04T00:00:00.000Z'
91 ),
92 (
93 'thread-unlinked',
94 'project-1',
95 'No link',
96 '{"instanceId":"codex","model":"gpt-5.4"}',
97 NULL,
98 '2026-03-01T00:00:04.000Z',
99 '2026-03-05T00:00:00.000Z'
100 )
101 `;
102
103 yield* runMigrations({ toMigrationInclusive: 50 });
104
105 const rows = yield* sql<PullRequestRow>`
106 SELECT
107 thread_id AS "threadId",
108 host,
109 repository,
110 number,
111 url,
112 source,
113 linked_at AS "linkedAt",
114 snapshot_json AS "snapshotJson",
115 stack_json AS "stackJson"
116 FROM projection_thread_pull_requests
117 ORDER BY thread_id ASC
118 `;
119
120 assert.deepStrictEqual(rows, [
121 {
122 threadId: "thread-bad-url",
123 host: "unknown",
124 repository: "acme/widgets",
125 number: 7,
126 url: "not a url",
127 source: "manual",
128 linkedAt: "2026-03-03T00:00:00.000Z",
129 snapshotJson: null,
130 stackJson: null,
131 },
132 {
133 threadId: "thread-github",
134 host: "github.com",
135 repository: "pingdotgg/t3code",
136 number: 42,
137 url: "https://GitHub.com/pingdotgg/t3code/pull/42",
138 source: "manual",
139 linkedAt: "2026-03-02T00:00:00.000Z",
140 snapshotJson: null,
141 stackJson: null,
142 },
143 ]);
144
145 // The legacy column stays so a rollback keeps its data.
146 const columns = yield* sql<{ readonly name: string }>`
147 PRAGMA table_info(projection_threads)
148 `;
149 assert.ok(columns.some((column) => column.name === "linked_pull_request_json"));
150
151 const indexes = yield* sql<{ readonly name: string }>`
152 PRAGMA index_list(projection_thread_pull_requests)
153 `;
154 assert.ok(indexes.some((index) => index.name === "idx_projection_thread_pull_requests_pr"));
155 }),
156 );
157});
158
159it.layer(Layer.fresh(NodeSqliteClient.layer({ filename: ":memory:" })))(
160 "050 Azure legacy links",
161 (it) => {
162 it.effect("keeps legacy Azure repositories distinct across organizations", () =>
163 Effect.gen(function* () {
164 const sql = yield* SqlClient.SqlClient;
165 yield* runMigrations({ toMigrationInclusive: 49 });
166 for (const organization of ["org-a", "org-b"]) {
167 yield* sql`
168 INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at)
169 VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}',
170 ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })},
171 '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')
172 `;
173 }
174 yield* runMigrations({ toMigrationInclusive: 50 });
175 const rows =
176 yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
177 assert.deepStrictEqual(rows, [
178 { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },
179 { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
180 ]);
181 }),
182 );
183 },
184);