Skip to content
Source
Sign inSign up

threadPullRequests.test.ts

487 lines16.9 KB

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 legacyThreadPullRequestKey,
10 threadPullRequestSearchTerms,
11 resolveThreadCurrentPullRequest,
12 resolveThreadPullRequestChains,
13 resolveThreadPullRequestBadge,
14 threadPullRequestKeysEqual,
15} from "./threadPullRequests.ts";
16​
17// Remove ES2023 array methods to match Hermes on mobile.
18beforeEach(() => {
19 const methods = ["toSorted", "toReversed", "toSpliced"] as const;
20 const descriptors = methods.map((method) =>
21 Object.getOwnPropertyDescriptor(Array.prototype, method),
22 );
23 for (const method of methods) Reflect.deleteProperty(Array.prototype, method);
24 return () => {
25 for (const [index, method] of methods.entries()) {
26 const descriptor = descriptors[index];
27 if (descriptor) Reflect.defineProperty(Array.prototype, method, descriptor);
28 }
29 };
30});
31​
32function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot {
33 return {
34 state: "open",
35 title: "Change",
36 headBranch: "feature",
37 baseBranch: "main",
38 isDraft: false,
39 updatedAt: "2026-01-01T00:00:00.000Z",
40 syncedAt: "2026-01-01T00:00:00.000Z",
41 ...input,
42 };
44​
45function link(
46 number: number,
47 input: Partial<Omit<ThreadPullRequestLink, "number">> = {},
48): ThreadPullRequestLink {
49 return {
50 host: "github.com",
51 repository: "pingdotgg/t3code",
52 number,
53 url: `https://github.com/pingdotgg/t3code/pull/${number}`,
54 source: "manual",
55 linkedAt: `2026-01-01T00:00:${String(number).padStart(2, "0")}.000Z`,
56 snapshot: null,
57 stack: null,
58 ...input,
59 };
61​
62describe("threadPullRequestKeysEqual", () => {
63 it("recovers Forgejo ports from old stored URLs and keeps separate servers distinct", () => {
64 const old = link(1, {
65 host: "forge.example",
66 repository: "team/repo",
67 url: "http://forge.example:3000/team/repo/pulls/1",
68 });
69 expect(threadPullRequestKeysEqual(old, { ...old, host: "forge.example:3000" })).toBe(true);
70 expect(
71 threadPullRequestKeysEqual(old, {
72 host: "forge.example:3000",
73 repository: "team/repo",
74 number: 1,
75 }),
76 ).toBe(true);
77 expect(
78 threadPullRequestKeysEqual(old, {
79 ...old,
80 url: "http://forge.example:4000/team/repo/pulls/1",
81 }),
82 ).toBe(false);
83 });
84​
85 it("recovers Azure repository keys from stored URLs", () => {
86 const own = link(7, {
87 host: "dev.azure.com",
88 repository: "org-a/project/_git/web",
89 url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
90 });
91 expect(legacyThreadPullRequestKey({ ...own, repository: "web" })).toEqual({
92 host: "dev.azure.com",
93 repository: "org-a/project/_git/web",
94 number: 7,
95 });
96 expect(
97 threadPullRequestKeysEqual(own, {
98 host: "org-a.visualstudio.com",
99 repository: "DefaultCollection/project/_git/web",
100 number: 7,
101 }),
102 ).toBe(true);
103 });
104});
105​
106describe("resolveThreadCurrentPullRequest", () => {
107 it("selects the same terminal link as stable sorting across input orders", () => {
108 const dates = ["invalid", "1969-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"];
109 for (let offset = 0; offset < dates.length; offset++) {
110 const links = Array.from({ length: 12 }, (_, index) =>
111 link(index + 1, {
112 snapshot: snapshot({
113 state: "merged",
114 updatedAt: dates[(index + offset) % dates.length]!,
115 }),
116 }),
117 );
118 for (const input of [links, [...links].sort((left, right) => right.number - left.number)]) {
119 const expected = [...input].sort(
120 (left, right) =>
121 (Date.parse(right.snapshot?.updatedAt ?? right.linkedAt) || 0) -
122 (Date.parse(left.snapshot?.updatedAt ?? left.linkedAt) || 0),
123 )[0];
124 expect(resolveThreadCurrentPullRequest(input)).toEqual({ kind: "single", link: expected });
125 }
126 }
127 });
128​
129 it("preserves input order when open chains have equal link dates", () => {
130 const linkedAt = "2026-01-01T00:00:00.000Z";
131 const current = resolveThreadCurrentPullRequest([
132 link(3, { linkedAt }),
133 link(1, { linkedAt }),
134 link(2, { linkedAt }),
135 ]);
136 expect(current?.kind === "stack" ? current.open.map((entry) => entry.number) : null).toEqual([
137 3, 1, 2,
138 ]);
139 });
140​
141 it("keeps the first terminal link on ties and treats invalid update dates as zero", () => {
142 const first = link(3, { snapshot: snapshot({ state: "merged", updatedAt: "invalid" }) });
143 const second = link(1, {
144 snapshot: snapshot({ state: "closed", updatedAt: "1970-01-01T00:00:00.000Z" }),
145 });
146 expect(resolveThreadCurrentPullRequest([first, second])).toEqual({
147 kind: "single",
148 link: first,
149 });
150 expect(resolveThreadCurrentPullRequest([second, first])).toEqual({
151 kind: "single",
152 link: second,
153 });
154 });
155​
156 it("prefers the single open link over terminal ones", () => {
157 const current = resolveThreadCurrentPullRequest([
158 link(1, { snapshot: snapshot({ state: "merged" }) }),
159 link(2, { snapshot: snapshot({ state: "open" }) }),
160 link(3, { snapshot: snapshot({ state: "closed" }) }),
161 ]);
162 expect(current).toMatchObject({ kind: "single", link: { number: 2 } });
163 });
164​
165 it("reports a stack when several links are open and puts the highest layer on top", () => {
166 const stack = {
167 kind: "native" as const,
168 id: "s1",
169 number: 1,
170 url: "https://github.com/pingdotgg/t3code/stacks/1",
171 base: "main",
172 layers: [
173 { number: 10, headBranch: "a", state: "open" as const },
174 { number: 11, headBranch: "b", state: "open" as const },
175 ],
176 };
177 const current = resolveThreadCurrentPullRequest([
178 link(11, { snapshot: snapshot(), stack }),
179 link(10, { snapshot: snapshot(), stack }),
180 ]);
181 expect(current).toMatchObject({ kind: "stack", top: { number: 11 } });
182 if (current?.kind === "stack") {
183 expect(current.open.map((entry) => entry.number)).toEqual([11, 10]);
184 }
185 });
186​
187 it("orders an open set without stack data by most recent link", () => {
188 const current = resolveThreadCurrentPullRequest([link(1), link(2)]);
189 expect(current).toMatchObject({ kind: "stack", top: { number: 2 } });
190 });
191});
192​
193describe("resolveThreadPullRequestChains", () => {
194 it("chains links by base → head within a repository, bottom to top", () => {
195 const chains = resolveThreadPullRequestChains([
196 link(3, { snapshot: snapshot({ headBranch: "c", baseBranch: "b" }) }),
197 link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "main" }) }),
198 link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
199 link(9, { snapshot: snapshot({ headBranch: "solo", baseBranch: "main" }) }),
200 ]);
201 expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
202 ["derived", [1, 2, 3]],
203 ["derived", [9]],
204 ]);
205 });
206​
207 it("keeps every link built on the same parent under it", () => {
208 const links = [
209 link(3, { snapshot: snapshot({ headBranch: "c", baseBranch: "a" }) }),
210 link(5, { snapshot: snapshot({ headBranch: "p", baseBranch: "main" }) }),
211 link(6, { snapshot: snapshot({ headBranch: "q", baseBranch: "p" }) }),
212 link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "p" }) }),
213 link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
214 link(9, { snapshot: snapshot({ headBranch: "solo", baseBranch: "main" }) }),
215 ];
216 const chains = resolveThreadPullRequestChains(links);
217 expect(
218 chains.map((chain) => [chain.layers.map((layer) => layer.number), chain.depths]),
219 ).toEqual([
220 [
221 [5, 6, 1, 3, 2],
222 [0, 1, 1, 2, 2],
223 ],
224 [[9], [0]],
225 ]);
226 expect(resolveThreadPullRequestBadge(links.slice(0, 5))).toEqual({
227 kind: "stack",
228 layers: 5,
229 state: "open",
230 });
231 });
232​
233 it("points at the highest layer of a branched stack, not the last sibling listed", () => {
234 const branched = (state: ThreadPullRequestSnapshot["state"]) => [
235 link(1, { snapshot: snapshot({ state, headBranch: "a" }) }),
236 link(2, { snapshot: snapshot({ state, headBranch: "b", baseBranch: "a" }) }),
237 link(3, { snapshot: snapshot({ state, headBranch: "c", baseBranch: "b" }) }),
238 link(4, { snapshot: snapshot({ state, headBranch: "d", baseBranch: "a" }) }),
239 ];
240 const open = resolveThreadCurrentPullRequest(branched("open"));
241 expect(open?.kind === "stack" ? open.open.map((entry) => entry.number) : null).toEqual([
242 3, 4, 2, 1,
243 ]);
244 expect(resolveThreadCurrentPullRequest(branched("merged"))).toMatchObject({
245 kind: "single",
246 link: { number: 3 },
247 });
248 });
249​
250 it("uses a later complete native snapshot for layer order and the current pull request", () => {
251 const links = [18, 20, 83, 84].map((number) =>
252 link(number, {
253 linkedAt: "2026-01-01T00:00:00.000Z",
254 snapshot: snapshot(),
255 stack: {
256 kind: "native",
257 id: "80",
258 number: 80,
259 url: "https://github.com/pingdotgg/t3code/stacks/80",
260 base: "main",
261 layers: (number < 83
262 ? [79, 37, 38, 39, 76, 18, 20]
263 : [79, 37, 38, 39, 76, 18, 20, 83, 84]
264 ).map((number) => ({ number, headBranch: `pr-${number}`, state: "open" })),
265 },
266 }),
267 );
268 expect
269 .soft(
270 resolveThreadPullRequestChains(links).map((chain) =>
271 chain.layers.map((layer) => layer.number),
272 ),
273 )
274 .toEqual([[18, 20, 83, 84]]);
275 expect(resolveThreadCurrentPullRequest(links)).toMatchObject({
276 kind: "stack",
277 top: { number: 84 },
278 open: [{ number: 84 }, { number: 83 }, { number: 20 }, { number: 18 }],
279 });
280 });
281​
282 it("uses the earliest longest partial snapshot and appends missing members in stored order", () => {
283 const links = [
284 [3, 2],
285 [9, 2, 3],
286 [9, 3, 2],
287 [9, 3, 2],
288 ].map((numbers, index) =>
289 link(index + 1, {
290 stack: {
291 kind: "native",
292 id: "s1",
293 number: 1,
294 url: "https://github.com/pingdotgg/t3code/stacks/1",
295 base: "main",
296 layers: numbers.map((number) => ({ number, headBranch: `pr-${number}`, state: "open" })),
297 },
298 }),
299 );
300 expect(
301 resolveThreadPullRequestChains(links).map((chain) =>
302 chain.layers.map((layer) => layer.number),
303 ),
304 ).toEqual([[2, 3, 1, 4]]);
305 });
306​
307 it("uses the native stack order when the host provides one", () => {
308 const stack = {
309 kind: "native" as const,
310 id: "s1",
311 number: 1,
312 url: "https://github.com/pingdotgg/t3code/stacks/1",
313 base: "main",
314 layers: [
315 { number: 5, headBranch: "a", state: "merged" as const },
316 { number: 6, headBranch: "b", state: "open" as const },
317 ],
318 };
319 const chains = resolveThreadPullRequestChains([
320 link(6, { snapshot: snapshot({ headBranch: "b", baseBranch: "main" }), stack }),
321 link(5, {
322 snapshot: snapshot({ state: "merged", headBranch: "a", baseBranch: "main" }),
323 stack,
324 }),
325 link(8),
326 ]);
327 expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
328 ["native", [5, 6]],
329 ["derived", [8]],
330 ]);
331 });
332});
333​
334describe("chain selection and badge state", () => {
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("lists a pull request once when two links share its identity", () => {
436 const links = [
437 link(1, { snapshot: snapshot({ headBranch: "a" }) }),
438 link(1, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
439 link(1, { snapshot: snapshot({ headBranch: "c" }) }),
440 ];
441 expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual([
442 [links[0]],
443 ]);
444 });
445​
446 it("does not guess a parent when a head branch was reused", () => {
447 const links = [
448 link(1, { snapshot: snapshot({ state: "merged", headBranch: "reused" }) }),
449 link(2, { snapshot: snapshot({ headBranch: "reused" }) }),
450 link(3, { snapshot: snapshot({ headBranch: "top", baseBranch: "reused" }) }),
451 ];
452 expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual(
453 links.map((entry) => [entry]),
454 );
455 });
456});
457​
458describe("threadPullRequestSearchTerms", () => {
459 it("includes completed and unsynced links but excludes dismissed links", () => {
460 const terms = threadPullRequestSearchTerms({
461 pullRequests: [
462 link(12, { snapshot: snapshot({ title: "Fix login", state: "merged" }) }),
463 link(34),
464 link(56, { source: "stack-dismissed" }),
465 ],
466 });
467 expect(terms).toContain("#12");
468 expect(terms).toContain("pingdotgg/t3code#12");
469 expect(terms).toContain("https://github.com/pingdotgg/t3code/pull/12");
470 expect(terms).toContain("Fix login");
471 expect(terms).toContain("#34");
472 expect(terms.join(" ")).not.toContain("56");
473 });
474});
475​
476it("searches the legacy projection when old environments decode to an empty links list", () => {
477 const linkedPullRequest = {
478 projectId: ProjectId.make("project"),
479 repository: "pingdotgg/t3code",
480 number: 12,
481 url: "https://github.com/pingdotgg/t3code/pull/12",
482 };
483 expect(threadPullRequestSearchTerms({ pullRequests: [], linkedPullRequest })).toContain("#12");
484 expect(
485 threadPullRequestSearchTerms({ pullRequests: [link(34)], linkedPullRequest }),
486 ).not.toContain("#12");
487});