1import { describe, expect, it } from "vite-plus/test";
2import {
3 EnvironmentId,
4 ProjectId,
5 SourceControlProviderKind,
6 type PullRequestListEntry,
7 type PullRequestListResult,
8} from "@t3tools/contracts";
9import {
10 appendPullRequestListPage,
11 DEFAULT_PR_LIST_CONTROLS,
12 EMPTY_PULL_REQUEST_STATS_CACHE,
13 filterPullRequestList,
14 mergePullRequestListEntries,
15 mergeEnvironmentPullRequestPages,
16 nextPullRequestListInput,
17 PR_LIST_PAGE_SIZE,
18 patchPullRequestListEntry,
19 pullRequestFilterChoices,
20 pullRequestLabelColors,
21 pullRequestListHead,
22 pullRequestListInput,
23 scopedPullRequestListKey,
24 shownPullRequestListPages,
25 pullRequestListRowsAreEqual,
26 withAnsweredPullRequestStats,
27 withListedPullRequests,
28 withShownPullRequestStats,
29 withoutScratchProjectScope,
30 pullRequestProjectChoices,
31} from "./pullRequestList";
32
33function entry(number: number, patch: Partial<PullRequestListEntry> = {}): PullRequestListEntry {
34 return {
35 number,
36 provider: SourceControlProviderKind.make("github"),
37 host: "github.com",
38 repository: "org/repo",
39 projectId: "project" as PullRequestListEntry["projectId"],
40 projectTitle: "Project",
41 title: `Change ${number}`,
42 url: `https://github.com/org/repo/pull/${number}`,
43 author: { login: "octocat", name: null, avatarUrl: null },
44 headBranch: `branch-${number}`,
45 baseBranch: "main",
46 state: "open",
47 isDraft: false,
48 mergeability: "unknown",
49 additions: 0,
50 deletions: 0,
51 createdAt: "2026-10-01T00:00:00Z",
52 updatedAt: `2026-10-${String(number).padStart(2, "0")}T00:00:00Z`,
53 observedAt: 1,
54 viewerReviewRequested: false,
55 labels: [],
56 ...patch,
57 };
58}
59function page(
60 entries: readonly PullRequestListEntry[],
61 nextCursors: PullRequestListResult["nextCursors"] = {},
62): PullRequestListResult {
63 return {
64 entries,
65 nextCursors,
66 truncated: Object.keys(nextCursors).length > 0,
67 viewers: { "github.com": "octocat" },
68 providers: [],
69 errors: [],
70 };
71}
72
73describe("recycled PR list rows", () => {
74 const scopedEntry = (patch: Partial<PullRequestListEntry> = {}) => ({
75 ...entry(1, patch),
76 environmentId: EnvironmentId.make("env-one"),
77 });
78
79 it("updates navigation and commands when a duplicate moves to another environment", () => {
80 const original = scopedEntry();
81 expect(
82 pullRequestListRowsAreEqual(original, {
83 ...original,
84 environmentId: EnvironmentId.make("env-two"),
85 }),
86 ).toBe(false);
87 });
88});
89
90describe("mobile PR list pagination", () => {
91 it("continues only the repositories that have more, preserving the query and filters", () => {
92 const first = pullRequestListHead(
93 null,
94 page([entry(3)], { "github.com org/repo": "opaque-boundary" }),
95 );
96 expect(
97 nextPullRequestListInput(first, {
98 state: "open",
99 query: "fix",
100 filters: { draft: "hide" },
101 limit: 30,
102 }),
103 ).toEqual({
104 state: "open",
105 query: "fix",
106 filters: { draft: "hide" },
107 limit: 30,
108 cursors: { "github.com org/repo": "opaque-boundary" },
109 });
110 const next = appendPullRequestListPage(first, page([entry(2), entry(1)]), {
111 state: "open",
112 cursors: first.nextCursors,
113 });
114 expect(next.entries.map((row) => row.number)).toEqual([3, 2, 1]);
115 expect(nextPullRequestListInput(next, { state: "open" })).toBeNull();
116 });
117 it("deduplicates page boundaries and never overwrites a fresh row with an older slice", () => {
118 const current = entry(2, { title: "Retitled", observedAt: 20 });
119 expect(
120 mergePullRequestListEntries([current], [entry(2, { observedAt: 10 }), entry(1)]),
121 ).toEqual([current, entry(1)]);
122 expect(
123 mergePullRequestListEntries([entry(1)], [entry(2, { host: "enterprise.test" }), entry(2)]),
124 ).toHaveLength(3);
125 });
126 it("patches a row that only the list head supplies, and keeps it while that head is current", () => {
127 const head = page([entry(2), entry(1)], { repo: "first" });
128 const patched = patchPullRequestListEntry(null, head, entry(1), { state: "merged" }, 5);
129 expect(shownPullRequestListPages(patched, head)?.entries.map((row) => row.state)).toEqual([
130 "open",
131 "merged",
132 ]);
133 });
134 it("refreshes the head without dropping older loaded pages or rewinding their cursor", () => {
135 const first = pullRequestListHead(null, page([entry(3)], { repo: "first" }));
136 const continued = appendPullRequestListPage(first, page([entry(2)], { repo: "second" }), {
137 state: "open",
138 cursors: first.nextCursors,
139 });
140 const updated = pullRequestListHead(
141 continued,
142 page([entry(4), entry(3, { title: "New title", observedAt: 2 })], { repo: "first-new" }),
143 );
144 expect(updated.entries.map((row) => row.number)).toEqual([4, 3, 2]);
145 expect(updated.entries[1]?.title).toBe("New title");
146 expect(updated.nextCursors).toEqual({ repo: "second" });
147 expect(pullRequestListHead(updated, updated.head)).toBe(updated);
148 });
149 it("can grow legacy listings that provide no cursor, without exceeding the wire limit", () => {
150 const first = pullRequestListHead(null, { ...page([entry(1)]), truncated: true });
151 expect(nextPullRequestListInput(first, { state: "all" })?.limit).toBe(60);
152 expect(nextPullRequestListInput({ ...first, limit: 490 }, { state: "all" })?.limit).toBe(500);
153 expect(nextPullRequestListInput({ ...first, limit: 500 }, { state: "all" })).toBeNull();
154 });
155});
156
157describe("all-environment PR list", () => {
158 const cups = EnvironmentId.make("cups");
159 const mbp = EnvironmentId.make("mbp");
160 it("combines independent pages, collapses duplicate PRs, and keeps their source for navigation", () => {
161 const first = pullRequestListHead(null, page([entry(3), entry(1)], { repo: "cups-next" }));
162 const second = pullRequestListHead(null, page([entry(2), entry(1)], { repo: "mbp-next" }));
163 const merged = mergeEnvironmentPullRequestPages([
164 { environmentId: cups, pages: first },
165 { environmentId: mbp, pages: second },
166 ]);
167 const rows = filterPullRequestList(
168 merged.entries,
169 DEFAULT_PR_LIST_CONTROLS,
170 merged.viewers,
171 "",
172 merged.localSearchHosts,
173 );
174 expect(rows.map((row) => [row.number, row.environmentId])).toEqual([
175 [3, cups],
176 [2, mbp],
177 [1, cups],
178 ]);
179 expect(scopedPullRequestListKey(rows[1]!)).toBe("mbp:github.com:org/repo:2");
180 expect(nextPullRequestListInput(first, { state: "open" })?.cursors).toEqual({
181 repo: "cups-next",
182 });
183 expect(nextPullRequestListInput(second, { state: "open" })?.cursors).toEqual({
184 repo: "mbp-next",
185 });
186 const continued = appendPullRequestListPage(first, page([entry(4)]), {
187 state: "open",
188 cursors: first.nextCursors,
189 });
190 expect(nextPullRequestListInput(continued, { state: "open" })).toBeNull();
191 expect(nextPullRequestListInput(second, { state: "open" })?.cursors).toEqual({
192 repo: "mbp-next",
193 });
194 });
195 it("uses the viewer account and search support from each environment before deduplicating", () => {
196 const remote = {
197 ...page([entry(1, { author: { login: "other", name: null, avatarUrl: null } })]),
198 viewers: { "github.com": "other" },
199 };
200 const local = page([entry(1), entry(2)]);
201 const merged = mergeEnvironmentPullRequestPages([
202 { environmentId: cups, pages: pullRequestListHead(null, remote) },
203 { environmentId: mbp, pages: pullRequestListHead(null, local) },
204 ]);
205 const byMe = filterPullRequestList(
206 merged.entries,
207 { ...DEFAULT_PR_LIST_CONTROLS, involvement: "authored" },
208 merged.viewers,
209 "",
210 merged.localSearchHosts,
211 );
212 expect(byMe.map((row) => [row.number, row.environmentId])).toEqual([
213 [2, mbp],
214 [1, cups],
215 ]);
216 const localOnlySearch = new Set([`${cups} github.com`]);
217 const searched = filterPullRequestList(
218 merged.entries,
219 DEFAULT_PR_LIST_CONTROLS,
220 merged.viewers,
221 "body match",
222 localOnlySearch,
223 );
224 expect(searched.map((row) => [row.number, row.environmentId])).toEqual([
225 [2, mbp],
226 [1, mbp],
227 ]);
228 });
229 it("scopes a project to its owning environment even if another has the same project id", () => {
230 const merged = mergeEnvironmentPullRequestPages([
231 { environmentId: cups, pages: pullRequestListHead(null, page([entry(1)])) },
232 { environmentId: mbp, pages: pullRequestListHead(null, page([entry(2)])) },
233 ]);
234 expect(
235 filterPullRequestList(
236 merged.entries,
237 { ...DEFAULT_PR_LIST_CONTROLS, projectId: "project", projectEnvironmentId: mbp },
238 merged.viewers,
239 "",
240 merged.localSearchHosts,
241 ).map((row) => row.environmentId),
242 ).toEqual([mbp]);
243 });
244});
245
246describe("mobile PR scan and filters", () => {
247 it("resolves authored and author:me per host rather than mixing account identities", () => {
248 const rows = [entry(1), entry(2, { host: "enterprise.test" })];
249 const viewers = { "github.com": "Octocat", "enterprise.test": "someone-else" };
250 expect(
251 filterPullRequestList(
252 rows,
253 { ...DEFAULT_PR_LIST_CONTROLS, involvement: "authored" },
254 viewers,
255 "",
256 new Set(),
257 ).map((row) => row.number),
258 ).toEqual([1]);
259 expect(
260 filterPullRequestList(
261 rows,
262 { ...DEFAULT_PR_LIST_CONTROLS, filters: { author: "me" } },
263 viewers,
264 "",
265 new Set(),
266 ).map((row) => row.number),
267 ).toEqual([1]);
268 expect(
269 filterPullRequestList(rows, DEFAULT_PR_LIST_CONTROLS, viewers, "author:@me", new Set()).map(
270 (row) => row.number,
271 ),
272 ).toEqual([1]);
273 });
274 it("reads a typed draft qualifier as a filter and searches only the text left over", () => {
275 expect(pullRequestListInput(DEFAULT_PR_LIST_CONTROLS, "draft:false fix")).toEqual({
276 state: "open",
277 involvement: "all",
278 filters: { draft: "hide" },
279 limit: PR_LIST_PAGE_SIZE,
280 query: "fix",
281 });
282 const rows = [
283 entry(1, { title: "Fix parser" }),
284 entry(2, { title: "Fix drafts", isDraft: true }),
285 entry(3),
286 ];
287 expect(
288 filterPullRequestList(
289 rows,
290 DEFAULT_PR_LIST_CONTROLS,
291 {},
292 "draft:false fix",
293 new Set(["github.com"]),
294 ).map((row) => row.number),
295 ).toEqual([1]);
296 });
297 it("does not label unsupported review summaries as reviewed or match text locally on host-search results", () => {
298 expect(
299 filterPullRequestList(
300 [entry(1)],
301 { ...DEFAULT_PR_LIST_CONTROLS, filters: { review: "approved" } },
302 {},
303 "body text",
304 new Set(),
305 ),
306 ).toHaveLength(1);
307 expect(
308 filterPullRequestList(
309 [entry(1)],
310 DEFAULT_PR_LIST_CONTROLS,
311 {},
312 "body text",
313 new Set(["github.com"]),
314 ),
315 ).toHaveLength(0);
316 });
317 it("lists a shared repository's row from a connected environment over an offline one", () => {
318 const rows = [
319 { ...entry(1), environmentId: "laptop" },
320 { ...entry(1), environmentId: "server" },
321 ];
322 const pick = (connected: ReadonlySet<string>) =>
323 filterPullRequestList(rows, DEFAULT_PR_LIST_CONTROLS, {}, "", new Set(), connected).map(
324 (row) => row.environmentId,
325 );
326 expect(pick(new Set(["server"]))).toEqual(["server"]);
327 expect(pick(new Set(["laptop", "server"]))).toEqual(["laptop"]);
328 expect(pick(new Set())).toEqual(["laptop"]);
329 });
330});
331
332describe("withoutScratchProjectScope", () => {
333 const projects = [
334 { id: "repo", environmentId: "env-a" },
335 { id: "scratch", environmentId: "env-a", isScratch: true as const },
336 ];
337
338 it("reads a scope saved on the Scratch folder as all projects", () => {
339 const controls = withoutScratchProjectScope(
340 { ...DEFAULT_PR_LIST_CONTROLS, projectId: "scratch", projectEnvironmentId: "env-a" },
341 projects,
342 );
343 expect(controls.projectId).toBeUndefined();
344 expect(controls.projectEnvironmentId).toBeUndefined();
345 });
346
347 it("keeps a repository scope, and one whose projects have not arrived yet", () => {
348 const repository = {
349 ...DEFAULT_PR_LIST_CONTROLS,
350 projectId: "repo",
351 projectEnvironmentId: "env-a",
352 };
353 expect(withoutScratchProjectScope(repository, projects)).toBe(repository);
354 const pending = { ...repository, projectId: "scratch" };
355 expect(withoutScratchProjectScope(pending, [])).toBe(pending);
356 });
357
358 it("keeps another server's project that shares the Scratch folder's id", () => {
359 const elsewhere = {
360 ...DEFAULT_PR_LIST_CONTROLS,
361 projectId: "scratch",
362 projectEnvironmentId: "env-b",
363 };
364 expect(withoutScratchProjectScope(elsewhere, projects)).toBe(elsewhere);
365 });
366});
367
368describe("pullRequestLabelColors", () => {
369 it("keeps the first usable color each label name carries", () => {
370 expect(
371 pullRequestLabelColors([
372 { name: "Bug", color: null },
373 { name: "bug", color: "d73a4a" },
374 { name: "BUG", color: "#000000" },
375 { name: "docs", color: "not-a-color" },
376 { name: "size:S", color: "#69C35F" },
377 ]),
378 ).toEqual({ bug: "#d73a4a", "size:s": "#69C35F" });
379 });
380});
381
382describe("pullRequestFilterChoices", () => {
383 it("keeps the picked value in view and adds host answers no loaded row carries, once", () => {
384 expect(
385 pullRequestFilterChoices({
386 selected: ["size:XL"],
387 suggestions: ["bug", "size:XL", "docs"],
388 hosted: ["Bug", "backend"],
389 query: "b",
390 }),
391 ).toEqual(["size:XL", "bug", "backend"]);
392 });
393
394 it("leaves room for the host's answers when loaded matches alone would fill the list", () => {
395 const loaded = Array.from({ length: 120 }, (_, index) => `label-${index}`);
396 const rows = pullRequestFilterChoices({
397 selected: [],
398 suggestions: loaded,
399 hosted: ["label-from-host"],
400 query: "label",
401 });
402 expect(rows).toHaveLength(100);
403 expect(rows[50]).toBe("label-from-host");
404 });
405});
406
407describe("pullRequestProjectChoices", () => {
408 const laptop = EnvironmentId.make("env-laptop");
409 const desktop = EnvironmentId.make("env-desktop");
410 const project = (id: string, environmentId: EnvironmentId) => ({
411 id: ProjectId.make(id),
412 environmentId,
413 title: "t3code",
414 workspaceRoot: `/work/${id}`,
415 repositoryIdentity: { canonicalKey: "github.com/pingdotgg/t3code" },
416 });
417 const projects = [project("main", laptop), project("worktree", laptop), project("main", desktop)];
418 const choices = (
419 servesPullRequests: (environmentId: EnvironmentId) => boolean,
420 scope: { projectId?: string; projectEnvironmentId?: string } = {},
421 ) =>
422 pullRequestProjectChoices(projects, {
423 environmentId: null,
424 controls: { ...DEFAULT_PR_LIST_CONTROLS, ...scope },
425 environmentLabels: new Map([
426 [laptop, "laptop"],
427 [desktop, "desktop"],
428 ]),
429 servesPullRequests,
430 }).map(({ id, environmentId, title }) => ({ id, environmentId, title }));
431
432 it("lists each repository once per environment that serves pull requests", () => {
433 expect(choices(() => true)).toEqual([
434 { id: "main", environmentId: desktop, title: "t3code · desktop" },
435 { id: "main", environmentId: laptop, title: "t3code · laptop" },
436 ]);
437 expect(choices((environmentId) => environmentId === desktop)).toEqual([
438 { id: "main", environmentId: desktop, title: "t3code" },
439 ]);
440 });
441
442 it("keeps the saved scope when its environment stops serving or has not loaded", () => {
443 const scope = { projectId: "worktree", projectEnvironmentId: laptop };
444 expect(choices((environmentId) => environmentId === desktop, scope)).toEqual([
445 { id: "main", environmentId: desktop, title: "t3code · desktop" },
446 { id: "worktree", environmentId: laptop, title: "t3code · laptop" },
447 ]);
448 expect(choices(() => false, scope)).toEqual([
449 { id: "worktree", environmentId: laptop, title: "t3code" },
450 ]);
451 });
452});
453
454describe("shown PR diff stats", () => {
455 const cups = EnvironmentId.make("cups");
456 const stat = (number: number, additions: number, deletions: number) => ({
457 projectId: "project" as PullRequestListEntry["projectId"],
458 repository: "org/repo",
459 number,
460 additions,
461 deletions,
462 });
463 const rows = [
464 { ...entry(1), environmentId: cups },
465 { ...entry(2, { additions: 4, deletions: 1 }), environmentId: cups },
466 ];
467
468 it("keeps the same list when the answers change no row", () => {
469 expect(withShownPullRequestStats(rows, [])).toBe(rows);
470 expect(withShownPullRequestStats(rows, [{ environmentId: cups, stats: [stat(1, 0, 0)] }])).toBe(
471 rows,
472 );
473 expect(withShownPullRequestStats(rows, [{ environmentId: cups, stats: [stat(2, 9, 9)] }])).toBe(
474 rows,
475 );
476 });
477
478 it("fills only rows without stats, in their own environment", () => {
479 const filled = withShownPullRequestStats(rows, [
480 { environmentId: EnvironmentId.make("mbp"), stats: [stat(1, 7, 7)] },
481 { environmentId: cups, stats: [stat(1, 3, 2), stat(2, 9, 9)] },
482 ]);
483 expect(filled.map(({ additions, deletions }) => [additions, deletions])).toEqual([
484 [3, 2],
485 [4, 1],
486 ]);
487 expect(filled[1]).toBe(rows[1]);
488 });
489});
490
491describe("cached PR diff stats", () => {
492 const stat = (number: number, additions: number) => ({
493 projectId: "project" as PullRequestListEntry["projectId"],
494 repository: "org/repo",
495 number,
496 additions,
497 deletions: 0,
498 });
499
500 it("drops a row's stat and refreshes its stats query when its updatedAt moves", () => {
501 const query = {
502 refs: [1, 2].map((number) => ({
503 projectId: stat(number, 0).projectId,
504 repository: "org/repo",
505 number,
506 })),
507 };
508 const listed = withListedPullRequests(EMPTY_PULL_REQUEST_STATS_CACHE, [entry(1), entry(2)]);
509 expect(listed.refresh).toEqual([]);
510 const answered = withAnsweredPullRequestStats(listed.cache, query, [stat(1, 5), stat(2, 7)]);
511 expect(withListedPullRequests(answered, [entry(1), entry(2)])).toEqual({
512 cache: answered,
513 refresh: [],
514 });
515
516 const pushed = withListedPullRequests(answered, [
517 entry(1, { updatedAt: "2026-10-20T00:00:00Z" }),
518 entry(2),
519 ]);
520 expect([...pushed.cache.stats.values()]).toEqual([stat(2, 7)]);
521 expect(pushed.refresh).toEqual([query]);
522 });
523});