1import {
2 ProjectId,
3 type ThreadPullRequestLink,
4 type ThreadPullRequestSnapshot,
5} from "@t3tools/contracts";
6import { beforeEach, describe, expect, it } from "vite-plus/test";
7
8import {
9 legacyLinkedPullRequestOf,
10 legacyThreadPullRequestKey,
11 threadPullRequestSearchTerms,
12 resolveThreadCurrentPullRequest,
13 resolveThreadPullRequestChains,
14 resolveThreadPullRequestBadge,
15 threadPullRequestKeysEqual,
16} from "./threadPullRequests.ts";
17
18// Match Hermes: these ES2023 array methods are absent on mobile, and this module runs in
19// the home thread list on every launch.
20beforeEach(() => {
21 const methods = ["toSorted", "toReversed", "toSpliced"] as const;
22 const descriptors = methods.map((method) =>
23 Object.getOwnPropertyDescriptor(Array.prototype, method),
24 );
25 for (const method of methods) Reflect.deleteProperty(Array.prototype, method);
26 return () => {
27 for (const [index, method] of methods.entries()) {
28 const descriptor = descriptors[index];
29 if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor);
30 }
31 };
32});
33
34function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot {
35 return {
36 state: "open",
37 title: "Change",
38 headBranch: "feature",
39 baseBranch: "main",
40 isDraft: false,
41 updatedAt: "2026-01-01T00:00:00.000Z",
42 syncedAt: "2026-01-01T00:00:00.000Z",
43 ...input,
44 };
45}
46
47function link(
48 number: number,
49 input: Partial<Omit<ThreadPullRequestLink, "number">> = {},
50): ThreadPullRequestLink {
51 return {
52 host: "github.com",
53 repository: "pingdotgg/t3code",
54 number,
55 url: `https://github.com/pingdotgg/t3code/pull/${number}`,
56 source: "manual",
57 linkedAt: `2026-01-01T00:00:${String(number).padStart(2, "0")}.000Z`,
58 snapshot: null,
59 stack: null,
60 ...input,
61 };
62}
63
64describe("threadPullRequestKeysEqual", () => {
65 it("recovers Forgejo ports from old stored URLs and keeps separate servers distinct", () => {
66 const old = link(1, {
67 host: "forge.example",
68 repository: "team/repo",
69 url: "http://forge.example:3000/team/repo/pulls/1",
70 });
71 expect(threadPullRequestKeysEqual(old, { ...old, host: "forge.example:3000" })).toBe(true);
72 expect(
73 threadPullRequestKeysEqual(old, {
74 host: "forge.example:3000",
75 repository: "team/repo",
76 number: 1,
77 }),
78 ).toBe(true);
79 expect(
80 threadPullRequestKeysEqual(old, {
81 ...old,
82 url: "http://forge.example:4000/team/repo/pulls/1",
83 }),
84 ).toBe(false);
85 });
86
87 it("ignores host and repository case", () => {
88 expect(
89 threadPullRequestKeysEqual(
90 { host: "GitHub.com", repository: "PingDotGG/t3code", number: 1 },
91 { host: "github.com", repository: "pingdotgg/t3code", number: 1 },
92 ),
93 ).toBe(true);
94 expect(
95 threadPullRequestKeysEqual(
96 { host: "github.com", repository: "pingdotgg/t3code", number: 1 },
97 { host: "gitlab.com", repository: "pingdotgg/t3code", number: 1 },
98 ),
99 ).toBe(false);
100 });
101});
102
103describe("resolveThreadCurrentPullRequest", () => {
104 it("returns null with no visible links", () => {
105 expect(resolveThreadCurrentPullRequest([])).toBeNull();
106 expect(resolveThreadCurrentPullRequest([link(1, { source: "stack-dismissed" })])).toBeNull();
107 });
108
109 it("treats an unsynced link as open", () => {
110 expect(resolveThreadCurrentPullRequest([link(1)])).toMatchObject({
111 kind: "single",
112 link: { number: 1 },
113 });
114 });
115
116 it("prefers the single open link over terminal ones", () => {
117 const current = resolveThreadCurrentPullRequest([
118 link(1, { snapshot: snapshot({ state: "merged" }) }),
119 link(2, { snapshot: snapshot({ state: "open" }) }),
120 link(3, { snapshot: snapshot({ state: "closed" }) }),
121 ]);
122 expect(current).toMatchObject({ kind: "single", link: { number: 2 } });
123 });
124
125 it("reports a stack when several links are open and puts the highest layer on top", () => {
126 const stack = {
127 kind: "native" as const,
128 id: "s1",
129 number: 1,
130 url: "https://github.com/pingdotgg/t3code/stacks/1",
131 base: "main",
132 layers: [
133 { number: 10, headBranch: "a", state: "open" as const },
134 { number: 11, headBranch: "b", state: "open" as const },
135 ],
136 };
137 const current = resolveThreadCurrentPullRequest([
138 link(11, { snapshot: snapshot(), stack }),
139 link(10, { snapshot: snapshot(), stack }),
140 ]);
141 expect(current).toMatchObject({ kind: "stack", top: { number: 11 } });
142 if (current?.kind === "stack") {
143 expect(current.open.map((entry) => entry.number)).toEqual([11, 10]);
144 }
145 });
146
147 it("orders an open set without stack data by most recent link", () => {
148 const current = resolveThreadCurrentPullRequest([link(1), link(2)]);
149 expect(current).toMatchObject({ kind: "stack", top: { number: 2 } });
150 });
151
152 it("falls back to the most recently updated terminal link", () => {
153 const current = resolveThreadCurrentPullRequest([
154 link(1, { snapshot: snapshot({ state: "merged", updatedAt: "2026-01-03T00:00:00.000Z" }) }),
155 link(2, { snapshot: snapshot({ state: "closed", updatedAt: "2026-01-02T00:00:00.000Z" }) }),
156 ]);
157 expect(current).toMatchObject({ kind: "single", link: { number: 1 } });
158 });
159});
160
161describe("legacyLinkedPullRequestOf", () => {
162 const identity = {
163 canonicalKey: "github.com/pingdotgg/t3code",
164 provider: "github",
165 displayName: "pingdotgg/t3code",
166 locator: {
167 source: "git-remote" as const,
168 remoteName: "origin",
169 remoteUrl: "https://github.com/pingdotgg/t3code.git",
170 },
171 };
172 it("projects only links the owning project can route without a host", () => {
173 expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, identity)).toEqual({
174 projectId: "project-1",
175 repository: "pingdotgg/t3code",
176 number: 7,
177 url: "https://github.com/pingdotgg/t3code/pull/7",
178 });
179 expect(legacyLinkedPullRequestOf([], "project-1" as never, identity)).toBeNull();
180 });
181 it.each([
182 {
183 host: "github.enterprise.test",
184 url: "https://github.enterprise.test/pingdotgg/t3code/pull/7",
185 },
186 { repository: "acme/other", url: "https://github.com/acme/other/pull/7" },
187 ])("omits an unsafe legacy route %j", (foreign) => {
188 expect(
189 legacyLinkedPullRequestOf([link(7, foreign)], "project-1" as never, identity),
190 ).toBeNull();
191 expect(
192 legacyLinkedPullRequestOf([link(7, foreign), link(8)], "project-1" as never, identity)
193 ?.number,
194 ).toBe(8);
195 });
196 it.each([
197 "dev.azure.com/org-a/project/_git/web",
198 "ssh.dev.azure.com/v3/org-a/project/web",
199 "org-a.visualstudio.com/DefaultCollection/project/_git/web",
200 ])("projects Azure links through the legacy selector for %s", (canonicalKey) => {
201 const azureIdentity = {
202 ...identity,
203 provider: "azure-devops",
204 canonicalKey,
205 displayName: canonicalKey.slice(canonicalKey.indexOf("/") + 1),
206 name: "web",
207 };
208 const own = link(7, {
209 host: "dev.azure.com",
210 repository: "org-a/project/_git/web",
211 url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
212 });
213 const foreign = link(7, {
214 host: "dev.azure.com",
215 repository: "web",
216 url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7",
217 linkedAt: "2026-01-02T00:00:00.000Z",
218 });
219 for (const repository of ["web", "org-a/project/_git/web"]) {
220 expect(
221 legacyLinkedPullRequestOf(
222 [{ ...own, repository }, foreign],
223 "project-1" as never,
224 azureIdentity,
225 ),
226 ).toEqual({ projectId: "project-1", repository: "web", number: 7, url: own.url });
227 }
228 expect(legacyLinkedPullRequestOf([foreign], "project-1" as never, azureIdentity)).toBeNull();
229 expect(legacyThreadPullRequestKey({ ...own, repository: "web" })).toEqual({
230 host: "dev.azure.com",
231 repository: "org-a/project/_git/web",
232 number: 7,
233 });
234 expect(
235 threadPullRequestKeysEqual(own, {
236 host: "org-a.visualstudio.com",
237 repository: "DefaultCollection/project/_git/web",
238 number: 7,
239 }),
240 ).toBe(true);
241 });
242 it("does not guess when the project identity is unavailable", () => {
243 expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, null)).toBeNull();
244 });
245 it("does not route links for a local-path remote with no host or provider", () => {
246 const localIdentity = {
247 canonicalKey: "/tmp/r/remote",
248 displayName: "remote",
249 locator: {
250 source: "git-remote" as const,
251 remoteName: "origin",
252 remoteUrl: "/tmp/r/remote.git",
253 },
254 };
255 expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, localIdentity)).toBeNull();
256 });
257});
258
259describe("resolveThreadPullRequestChains", () => {
260 it("chains links by base → head within a repository, bottom to top", () => {
261 const chains = resolveThreadPullRequestChains([
262 link(3, { snapshot: snapshot({ headBranch: "c", baseBranch: "b" }) }),
263 link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "main" }) }),
264 link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
265 link(9, { snapshot: snapshot({ headBranch: "solo", baseBranch: "main" }) }),
266 ]);
267 expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
268 ["derived", [1, 2, 3]],
269 ["derived", [9]],
270 ]);
271 });
272
273 it("uses the native stack order when the host provides one", () => {
274 const stack = {
275 kind: "native" as const,
276 id: "s1",
277 number: 1,
278 url: "https://github.com/pingdotgg/t3code/stacks/1",
279 base: "main",
280 layers: [
281 { number: 5, headBranch: "a", state: "merged" as const },
282 { number: 6, headBranch: "b", state: "open" as const },
283 ],
284 };
285 const chains = resolveThreadPullRequestChains([
286 link(6, { snapshot: snapshot({ headBranch: "b", baseBranch: "main" }), stack }),
287 link(5, {
288 snapshot: snapshot({ state: "merged", headBranch: "a", baseBranch: "main" }),
289 stack,
290 }),
291 link(8),
292 ]);
293 expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
294 ["native", [5, 6]],
295 ["derived", [8]],
296 ]);
297 });
298});
299
300describe("chain selection and badge state", () => {
301 it.each([
302 ["open", false, "open", false, "open"],
303 ["closed", false, "closed", false, "closed"],
304 ["open", true, "open", true, "draft"],
305 ["open", false, "open", true, "open"],
306 ["closed", false, "open", true, "open"],
307 ["merged", false, "merged", false, "merged"],
308 ["merged", false, "closed", true, "closed"],
309 ] as const)(
310 "aggregates %s (draft %s) and %s (draft %s) as %s",
311 (firstState, firstDraft, secondState, secondDraft, state) => {
312 for (const stacked of [false, true]) {
313 const links = [
314 link(1, {
315 snapshot: snapshot({ state: firstState, isDraft: firstDraft, headBranch: "base" }),
316 }),
317 link(2, {
318 snapshot: snapshot({
319 state: secondState,
320 isDraft: secondDraft,
321 baseBranch: stacked ? "base" : "main",
322 }),
323 }),
324 link(3, { source: "stack-dismissed" }),
325 ];
326 expect(resolveThreadPullRequestBadge(links)).toEqual(
327 stacked
328 ? { kind: "stack", layers: 2, state }
329 : { kind: "pull-request", others: 1, state },
330 );
331 }
332 },
333 );
334
335 it.each(["open", "merged", "closed"] as const)(
336 "targets the top of a derived %s chain despite a later bottom update and link",
337 (state) => {
338 const bottom = link(2, {
339 snapshot: snapshot({ state, headBranch: "base", updatedAt: "2026-02-01T00:00:00.000Z" }),
340 });
341 const top = link(1, {
342 snapshot: snapshot({ state, headBranch: "top", baseBranch: "base" }),
343 });
344 expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject(
345 state === "open"
346 ? { kind: "stack", top: { number: 1 }, open: [top, bottom] }
347 : { kind: "single", link: { number: 1 } },
348 );
349 expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({
350 kind: "stack",
351 layers: 2,
352 state,
353 });
354 },
355 );
356
357 it("keeps the top native layer after completion and treats an unsynced layer as open", () => {
358 const stack = {
359 kind: "native" as const,
360 id: "native-1",
361 number: 1,
362 url: "https://github.com/pingdotgg/t3code/stacks/1",
363 base: "main",
364 layers: [
365 { number: 2, headBranch: "base", state: "merged" as const },
366 { number: 1, headBranch: "top", state: "merged" as const },
367 ],
368 };
369 const bottom = link(2, {
370 stack,
371 snapshot: snapshot({ state: "merged", updatedAt: "2026-02-01T00:00:00.000Z" }),
372 });
373 const top = link(1, { stack, snapshot: snapshot({ state: "merged" }) });
374 expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject({
375 kind: "single",
376 link: { number: 1 },
377 });
378 expect(resolveThreadPullRequestBadge([bottom, { ...top, snapshot: null }])).toEqual({
379 kind: "stack",
380 layers: 2,
381 state: "open",
382 });
383 });
384
385 it("uses aggregate state and distinguishes unrelated work from a stack", () => {
386 const bottom = link(1, { snapshot: snapshot({ state: "merged", headBranch: "base" }) });
387 const top = link(2, { snapshot: snapshot({ state: "closed", baseBranch: "base" }) });
388 expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({
389 kind: "stack",
390 layers: 2,
391 state: "closed",
392 });
393 expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({
394 kind: "pull-request",
395 others: 2,
396 state: "open",
397 });
398 expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull();
399 });
400
401 it("keeps branch matching case-sensitive while ignoring repository case", () => {
402 const bottom = link(1, {
403 repository: "PingDotGG/T3code",
404 snapshot: snapshot({ headBranch: "Base" }),
405 });
406 const top = link(2, { snapshot: snapshot({ headBranch: "top", baseBranch: "base" }) });
407 expect(resolveThreadPullRequestChains([bottom, top])).toHaveLength(2);
408 expect(
409 resolveThreadPullRequestChains([
410 bottom,
411 { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) },
412 ])[0]?.layers,
413 ).toEqual([bottom, { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) }]);
414 });
415
416 it("preserves cyclic links without presenting a guessed stack order", () => {
417 const links = [
418 link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "b" }) }),
419 link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
420 ];
421 expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual(
422 links.map((entry) => [entry]),
423 );
424 expect(resolveThreadCurrentPullRequest(links)).toMatchObject({
425 kind: "stack",
426 top: { number: 2 },
427 });
428 expect(resolveThreadPullRequestBadge(links)).toEqual({
429 kind: "pull-request",
430 others: 1,
431 state: "open",
432 });
433 });
434
435 it("does not guess a parent when a head branch was reused", () => {
436 const links = [
437 link(1, { snapshot: snapshot({ state: "merged", headBranch: "reused" }) }),
438 link(2, { snapshot: snapshot({ headBranch: "reused" }) }),
439 link(3, { snapshot: snapshot({ headBranch: "top", baseBranch: "reused" }) }),
440 ];
441 expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual(
442 links.map((entry) => [entry]),
443 );
444 });
445});
446
447describe("threadPullRequestSearchTerms", () => {
448 it.each([undefined, [], [link(34)]])(
449 "searches the branch PR alongside existing links (%j)",
450 (pullRequests) => {
451 const branchPullRequest = {
452 projectId: ProjectId.make("project"),
453 repository: "pingdotgg/t3code",
454 number: 11809,
455 url: "https://github.com/pingdotgg/t3code/pull/11809",
456 };
457 const terms = threadPullRequestSearchTerms({
458 pullRequests,
459 branchPullRequest,
460 linkedPullRequest: {
461 ...branchPullRequest,
462 number: 12,
463 url: "https://github.com/pingdotgg/t3code/pull/12",
464 },
465 });
466 for (const query of ["11809", "#11809", "pingdotgg/t3code#11809", branchPullRequest.url]) {
467 expect(terms.some((term) => term.includes(query))).toBe(true);
468 }
469 expect(terms).toContain(pullRequests?.length ? "#34" : "#12");
470 },
471 );
472
473 it("includes completed and unsynced links but excludes dismissed links", () => {
474 const terms = threadPullRequestSearchTerms({
475 pullRequests: [
476 link(12, { snapshot: snapshot({ title: "Fix login", state: "merged" }) }),
477 link(34),
478 link(56, { source: "stack-dismissed" }),
479 ],
480 });
481 expect(terms).toContain("#12");
482 expect(terms).toContain("pingdotgg/t3code#12");
483 expect(terms).toContain("https://github.com/pingdotgg/t3code/pull/12");
484 expect(terms).toContain("Fix login");
485 expect(terms).toContain("#34");
486 expect(terms.join(" ")).not.toContain("56");
487 });
488});
489
490it("searches the legacy projection when old environments decode to an empty links list", () => {
491 const linkedPullRequest = {
492 projectId: ProjectId.make("project"),
493 repository: "pingdotgg/t3code",
494 number: 12,
495 url: "https://github.com/pingdotgg/t3code/pull/12",
496 };
497 expect(threadPullRequestSearchTerms({ pullRequests: [], linkedPullRequest })).toContain("#12");
498 expect(
499 threadPullRequestSearchTerms({ pullRequests: [link(34)], linkedPullRequest }),
500 ).not.toContain("#12");
501});