1import { SourceControlProviderKind } from "@t3tools/contracts";
2import type {
3 EnvironmentId,
4 ProjectId,
5 PullRequestAction,
6 PullRequestListEntry,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 pullRequestHostActions,
12 pullRequestQuickActions,
13 filterPullRequestsByInvolvement,
14 findScopedProject,
15 mergePullRequestLists,
16 pullRequestEntryKey,
17 pullRequestEnvironmentSetKey,
18 groupPullRequestsByInvolvement,
19 matchesPullRequestFilters,
20 matchesPullRequestQuery,
21 parsePullRequestQuery,
22 pullRequestStatsBatches,
23 pullRequestStatsKeysToRequest,
24 pullRequestStatsRefreshBatches,
25 pullRequestStatsRequestBatches,
26 mergePullRequestDiffStats,
27 narrowPullRequestsToFilters,
28 partitionPullRequestsWithPriority,
29 readPullRequestListSnapshot,
30 writePullRequestListSnapshot,
31 rankPullRequestMatches,
32 rankPullRequestsBlockedOnAuthor,
33 rankPullRequestsBlockedOnReviewer,
34 rankPullRequestsByMergeReadiness,
35 scorePullRequestMatch,
36 sortPullRequestGroups,
37 retainVisiblePullRequestStatsBatches,
38 withDiffStat,
39 resolveProjectScope,
40 resolveQueryEnvironmentIds,
41 resolveSelectedEnvironmentId,
42 type EnvironmentPullRequestEntry,
43 applyPullRequestOverrides,
44 pullRequestOverrideAfterAction,
45 reusePullRequestEntries,
46 settlePullRequestOverrides,
47} from "./pullRequestList.logic";
48import {
49 pullRequestListPreferences,
50 readPullRequestListPreferences,
51 writePullRequestListPreferences,
52} from "./pullRequestListPreferences";
53
54const VIEWERS = { "github.com": "Bilal" } as const;
55const NO_VIEWERS = {} as const;
56
57function entry(
58 overrides: Partial<EnvironmentPullRequestEntry> & Pick<PullRequestListEntry, "number">,
59) {
60 return {
61 environmentId: "env-1",
62 provider: "github",
63 host: "github.com",
64 projectId: "project-1",
65 projectTitle: "t3code",
66 repository: "pingdotgg/t3code",
67 title: "Add the pull requests page",
68 url: `https://github.com/pingdotgg/t3code/pull/${overrides.number}`,
69 author: { login: "octocat", name: null, avatarUrl: null },
70 headBranch: `feat/branch-${overrides.number}`,
71 baseBranch: "main",
72 state: "open",
73 isDraft: false,
74 mergeability: "mergeable",
75 additions: 1,
76 deletions: 0,
77 createdAt: "2026-07-01T00:00:00Z",
78 updatedAt: "2026-07-02T00:00:00Z",
79 viewerReviewRequested: false,
80 labels: [],
81 ...overrides,
82 } as EnvironmentPullRequestEntry;
83}
84
85describe("visible pull request line-count targets", () => {
86 it("reuses counts supplied by the listing and only requests missing counts", () => {
87 const entries = [
88 entry({ number: 1, additions: 12, deletions: 0 }),
89 entry({ number: 2, additions: 0, deletions: 7 }),
90 entry({ number: 3, additions: 0, deletions: 0 }),
91 ];
92 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
93 const keys = pullRequestStatsKeysToRequest(
94 entriesByKey,
95 new Set(entries.map(pullRequestEntryKey)),
96 [],
97 new Map(),
98 );
99
100 expect(pullRequestStatsBatches(entriesByKey, keys)[0]?.input.refs).toEqual([
101 { projectId: "project-1", repository: "pingdotgg/t3code", number: 3 },
102 ]);
103 });
104
105 it("does not request a row again after its received batch is pruned", () => {
106 const entries = [
107 entry({ additions: 0, deletions: 0, number: 1 }),
108 entry({ additions: 0, deletions: 0, number: 2 }),
109 ];
110 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
111 const firstKey = pullRequestEntryKey(entries[0]!);
112 const secondKey = pullRequestEntryKey(entries[1]!);
113 const completedStats = mergePullRequestDiffStats(new Map(), [
114 {
115 environmentId: ENV_1,
116 projectId: "project-1",
117 number: 1,
118 additions: 1,
119 deletions: 1,
120 },
121 ]);
122
123 const keys = pullRequestStatsKeysToRequest(
124 entriesByKey,
125 new Set([firstKey, secondKey]),
126 [],
127 completedStats,
128 );
129 expect([...keys]).toEqual([secondKey]);
130 expect(pullRequestStatsBatches(entriesByKey, keys)[0]?.input.refs).toEqual([
131 { projectId: "project-1", repository: "pingdotgg/t3code", number: 2 },
132 ]);
133 });
134
135 it("drops historical rows after a long scroll so refresh stays bounded to the viewport", () => {
136 const entries = Array.from({ length: 500 }, (_, index) =>
137 entry({ additions: 0, deletions: 0, number: index + 1 }),
138 );
139 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
140 const batches = entries.map(
141 (item) => pullRequestStatsBatches(entriesByKey, new Set([pullRequestEntryKey(item)]))[0]!,
142 );
143 const visibleKeys = new Set(entries.slice(-12).map(pullRequestEntryKey));
144
145 const retained = retainVisiblePullRequestStatsBatches(batches, visibleKeys);
146 expect(retained).toHaveLength(12);
147 expect(retained.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual(
148 entries.slice(-12).map((item) => item.number),
149 );
150 });
151
152 it("keeps every per-environment batch within the stats contract limit", () => {
153 const entries = Array.from({ length: 501 }, (_, index) =>
154 entry({ additions: 0, deletions: 0, number: index + 1 }),
155 );
156 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
157 const batches = pullRequestStatsBatches(
158 entriesByKey,
159 new Set(entries.map(pullRequestEntryKey)),
160 );
161
162 expect(batches.map((batch) => batch.input.refs.length)).toEqual([500, 1]);
163 expect(batches.flatMap((batch) => [...batch.keys])).toEqual(entries.map(pullRequestEntryKey));
164 });
165
166 it("selects visible rows for date modes and every uncached row for size modes", () => {
167 const entries = [
168 entry({ additions: 0, deletions: 0, number: 1 }),
169 entry({ additions: 0, deletions: 0, number: 2 }),
170 entry({ additions: 0, deletions: 0, number: 3, environmentId: "env-2" as EnvironmentId }),
171 ];
172 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
173 const [firstKey, secondKey] = entries.map(pullRequestEntryKey);
174 const cachedStats = mergePullRequestDiffStats(new Map(), [
175 {
176 environmentId: ENV_1,
177 projectId: "project-1",
178 number: 1,
179 additions: 1,
180 deletions: 1,
181 },
182 ]);
183
184 const visible = pullRequestStatsRequestBatches({
185 entriesByKey,
186 candidateKeys: new Set([firstKey!, secondKey!]),
187 policy: "visible",
188 activeBatches: [],
189 statsByRow: cachedStats,
190 });
191 expect(visible.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2]);
192
193 const eager = pullRequestStatsRequestBatches({
194 entriesByKey,
195 candidateKeys: new Set([firstKey!]),
196 policy: "eager",
197 activeBatches: [],
198 statsByRow: cachedStats,
199 });
200 expect(eager.map((batch) => batch.environmentId)).toEqual([ENV_1, "env-2"]);
201 expect(eager.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2, 3]);
202 });
203
204 it("refreshes only visible rows unless size sorting needs every loaded row", () => {
205 const entries = [
206 entry({ additions: 0, deletions: 0, number: 1 }),
207 entry({ additions: 0, deletions: 0, number: 2 }),
208 entry({ additions: 0, deletions: 0, number: 3 }),
209 ];
210 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
211 const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]);
212 const cachedStats = mergePullRequestDiffStats(
213 new Map(),
214 entries.map((item) => ({
215 environmentId: ENV_1,
216 projectId: item.projectId,
217 number: item.number,
218 additions: item.additions,
219 deletions: item.deletions,
220 })),
221 );
222
223 const visible = pullRequestStatsRequestBatches({
224 entriesByKey,
225 candidateKeys: visibleKeys,
226 policy: "visible",
227 activeBatches: [],
228 statsByRow: cachedStats,
229 refresh: true,
230 });
231 expect(visible[0]?.input.refs.map((ref) => ref.number)).toEqual([2]);
232
233 const eager = pullRequestStatsRequestBatches({
234 entriesByKey,
235 candidateKeys: visibleKeys,
236 policy: "eager",
237 activeBatches: [],
238 statsByRow: cachedStats,
239 refresh: true,
240 });
241 expect(eager[0]?.input.refs.map((ref) => ref.number)).toEqual([1, 2, 3]);
242 });
243
244 it("ignores a late refresh after the filter or stats policy changes", () => {
245 const entries = [
246 entry({ additions: 0, deletions: 0, number: 1 }),
247 entry({ additions: 0, deletions: 0, number: 2 }),
248 ];
249 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
250 const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]);
251 const requestedScope = { key: "open", policy: "visible" } as const;
252 const refresh = (currentScope: { key: string; policy: "visible" | "eager" }) =>
253 pullRequestStatsRefreshBatches({
254 requestedScope,
255 currentScope,
256 entriesByKey,
257 candidateKeys: visibleKeys,
258 statsByRow: new Map(),
259 });
260
261 expect(refresh(requestedScope)?.[0]?.input.refs.map((ref) => ref.number)).toEqual([2]);
262 expect(refresh({ key: "closed", policy: "visible" })).toBeNull();
263 expect(refresh({ key: "open", policy: "eager" })).toBeNull();
264 });
265
266 it("does not add request batches again while rows are active or cached", () => {
267 const entries = [
268 entry({ additions: 0, deletions: 0, number: 1 }),
269 entry({ additions: 0, deletions: 0, number: 2 }),
270 ];
271 const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item]));
272 const first = pullRequestStatsRequestBatches({
273 entriesByKey,
274 candidateKeys: new Set(),
275 policy: "eager",
276 activeBatches: [],
277 statsByRow: new Map(),
278 });
279
280 expect(
281 pullRequestStatsRequestBatches({
282 entriesByKey,
283 candidateKeys: new Set(),
284 policy: "eager",
285 activeBatches: first,
286 statsByRow: new Map(),
287 }),
288 ).toEqual([]);
289
290 const cachedStats = mergePullRequestDiffStats(
291 new Map(),
292 entries.map((item) => ({
293 environmentId: ENV_1,
294 projectId: item.projectId,
295 number: item.number,
296 additions: item.additions,
297 deletions: item.deletions,
298 })),
299 );
300 expect(
301 pullRequestStatsRequestBatches({
302 entriesByKey,
303 candidateKeys: new Set(entries.map(pullRequestEntryKey)),
304 policy: "visible",
305 activeBatches: [],
306 statsByRow: cachedStats,
307 }),
308 ).toEqual([]);
309 });
310});
311
312describe("pull request involvement filtering", () => {
313 const entries = [
314 entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }),
315 entry({ number: 2, viewerReviewRequested: true }),
316 entry({ number: 3 }),
317 ];
318
319 it("matches the viewer's own pull requests case-insensitively", () => {
320 expect(
321 filterPullRequestsByInvolvement(entries, VIEWERS, "authored").map((item) => item.number),
322 ).toEqual([1]);
323 });
324
325 it("returns nothing for Authored when the viewer is unknown", () => {
326 expect(filterPullRequestsByInvolvement(entries, NO_VIEWERS, "authored")).toEqual([]);
327 });
328
329 it("uses the server-computed review-request flag for Reviewing", () => {
330 expect(
331 filterPullRequestsByInvolvement(entries, VIEWERS, "reviewing").map((item) => item.number),
332 ).toEqual([2]);
333 });
334
335 it("does not treat a matching login on another host as the viewer", () => {
336 // The same name on GitLab is a different account, and its viewer is unknown here.
337 const mixed = [
338 entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }),
339 entry({
340 number: 2,
341 provider: SourceControlProviderKind.make("gitlab"),
342 host: "gitlab.com",
343 author: { login: "Bilal", name: null, avatarUrl: null },
344 }),
345 ];
346 expect(
347 filterPullRequestsByInvolvement(mixed, VIEWERS, "authored").map((item) => item.number),
348 ).toEqual([1]);
349 });
350
351 it("leaves the superset untouched for All", () => {
352 expect(filterPullRequestsByInvolvement(entries, VIEWERS, "all")).toHaveLength(3);
353 });
354
355 it("keeps two hosts of one provider kind as two accounts", () => {
356 // A GitHub Enterprise install is a different account from github.com, so the viewer for
357 // one must not claim authorship of the other's change requests.
358 const mixed = [
359 entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }),
360 entry({
361 number: 2,
362 host: "github.acme.dev",
363 author: { login: "Bilal", name: null, avatarUrl: null },
364 }),
365 ];
366 expect(
367 filterPullRequestsByInvolvement(mixed, VIEWERS, "authored").map((item) => item.number),
368 ).toEqual([1]);
369 });
370});
371
372describe("pull request grouping", () => {
373 it("buckets authored above review-requested and drops empty groups", () => {
374 const groups = groupPullRequestsByInvolvement(
375 [
376 entry({ number: 1, author: { login: "bilal", name: null, avatarUrl: null } }),
377 entry({ number: 2, viewerReviewRequested: true }),
378 ],
379 VIEWERS,
380 );
381 expect(groups.map((group) => [group.key, group.entries.length])).toEqual([
382 ["authored", 1],
383 ["reviewRequested", 1],
384 ]);
385 });
386
387 it("puts everything in Others when the viewer is unknown", () => {
388 const groups = groupPullRequestsByInvolvement([entry({ number: 1 })], NO_VIEWERS);
389 expect(groups.map((group) => group.key)).toEqual(["others"]);
390 });
391
392 it("counts an authored pull request once, even with a review request on it", () => {
393 const groups = groupPullRequestsByInvolvement(
394 [
395 entry({
396 number: 1,
397 author: { login: "bilal", name: null, avatarUrl: null },
398 viewerReviewRequested: true,
399 }),
400 ],
401 VIEWERS,
402 );
403 expect(groups.map((group) => group.key)).toEqual(["authored"]);
404 });
405});
406
407describe("pull request search", () => {
408 const target = entry({
409 number: 4711,
410 title: "Restore sidebar actions",
411 headBranch: "fix/sidebar",
412 });
413
414 it("matches the number with or without the leading hash", () => {
415 expect(matchesPullRequestQuery(target, "#4711")).toBe(true);
416 expect(matchesPullRequestQuery(target, "4711")).toBe(true);
417 });
418
419 it("matches title, branch and author case-insensitively", () => {
420 expect(matchesPullRequestQuery(target, "SIDEBAR")).toBe(true);
421 expect(matchesPullRequestQuery(target, "fix/side")).toBe(true);
422 expect(matchesPullRequestQuery(target, "octocat")).toBe(true);
423 });
424
425 it("ignores surrounding whitespace and rejects non-matches", () => {
426 expect(matchesPullRequestQuery(target, " ")).toBe(true);
427 expect(matchesPullRequestQuery(target, "kanban")).toBe(false);
428 });
429});
430
431describe("carrying rows already read into filters nothing has answered yet", () => {
432 const rows = [
433 entry({ number: 1 }),
434 entry({ number: 2, state: "merged" }),
435 entry({ number: 3, state: "closed" }),
436 entry({ number: 4, host: "github.acme.dev" }),
437 entry({ number: 5, projectId: "project-2" as PullRequestListEntry["projectId"] }),
438 ];
439 const everything = { state: "all", projectId: undefined, host: undefined } as const;
440
441 it("never lets a merged pull request sit under Open", () => {
442 expect(
443 narrowPullRequestsToFilters(rows, { ...everything, state: "open" }).map((row) => row.number),
444 ).toEqual([1, 4, 5]);
445 });
446
447 it("keeps only the state that was asked for", () => {
448 expect(
449 narrowPullRequestsToFilters(rows, { ...everything, state: "merged" }).map(
450 (row) => row.number,
451 ),
452 ).toEqual([2]);
453 });
454
455 it("narrows to one host, which two installs of one kind cannot share", () => {
456 expect(
457 narrowPullRequestsToFilters(rows, { ...everything, host: "github.acme.dev" }).map(
458 (row) => row.number,
459 ),
460 ).toEqual([4]);
461 });
462
463 it("narrows to one project", () => {
464 expect(
465 narrowPullRequestsToFilters(rows, { ...everything, projectId: "project-2" }).map(
466 (row) => row.number,
467 ),
468 ).toEqual([5]);
469 });
470
471 it("holds on to everything when nothing narrows it", () => {
472 expect(narrowPullRequestsToFilters(rows, everything)).toEqual(rows);
473 });
474});
475
476describe("narrowing rows by the filters a host may not have applied", () => {
477 const rows = [
478 entry({ number: 1 }),
479 entry({ number: 2, isDraft: true }),
480 entry({ number: 3, reviewDecision: "approved" }),
481 entry({
482 number: 4,
483 labels: [{ name: "Needs design", color: null }],
484 author: { login: "Hubot", name: null, avatarUrl: null },
485 reviewDecision: "changes-requested",
486 }),
487 ];
488 const narrow = (filters: Parameters<typeof matchesPullRequestFilters>[1]) =>
489 rows.filter((row) => matchesPullRequestFilters(row, filters)).map((row) => row.number);
490
491 it("keeps or drops drafts as asked", () => {
492 expect(narrow({ draft: "only" })).toEqual([2]);
493 expect(narrow({ draft: "hide" })).toEqual([1, 3, 4]);
494 });
495
496 it("keeps the review state that was asked for, and no reviews means none at all", () => {
497 expect(narrow({ review: "approved" })).toEqual([3]);
498 expect(narrow({ review: "changes-requested" })).toEqual([4]);
499 expect(narrow({ review: "none" })).toEqual([1, 2]);
500 });
501
502 it("matches labels and authors however either was capitalized", () => {
503 expect(narrow({ labels: [["needs DESIGN"]] })).toEqual([4]);
504 expect(narrow({ excludedLabels: ["needs design"] })).toEqual([1, 2, 3]);
505 expect(narrow({ author: "hubot" })).toEqual([4]);
506 });
507
508 it("resolves author:me to the given viewer, case-insensitively", () => {
509 const mine = entry({ number: 30, author: { login: "Bilal", name: null, avatarUrl: null } });
510 const theirs = entry({ number: 31, author: { login: "Hubot", name: null, avatarUrl: null } });
511 expect(matchesPullRequestFilters(mine, { author: "me" }, "bilal")).toBe(true);
512 expect(matchesPullRequestFilters(theirs, { author: "me" }, "bilal")).toBe(false);
513 });
514
515 it("resolves author:@me the same way as author:me", () => {
516 const mine = entry({ number: 32, author: { login: "Bilal", name: null, avatarUrl: null } });
517 const theirs = entry({ number: 33, author: { login: "Hubot", name: null, avatarUrl: null } });
518 expect(matchesPullRequestFilters(mine, { author: "@me" }, "Bilal")).toBe(true);
519 expect(matchesPullRequestFilters(theirs, { author: "@me" }, "Bilal")).toBe(false);
520 });
521
522 it("compares author:me against the literal name when there is no viewer", () => {
523 const literalMe = entry({ number: 34, author: { login: "me", name: null, avatarUrl: null } });
524 const someoneElse = entry({
525 number: 35,
526 author: { login: "Bilal", name: null, avatarUrl: null },
527 });
528 expect(matchesPullRequestFilters(literalMe, { author: "me" })).toBe(true);
529 expect(matchesPullRequestFilters(someoneElse, { author: "me" })).toBe(false);
530 });
531
532 it("takes a group of labels as an either-or", () => {
533 const sized = [
534 entry({ number: 10, labels: [{ name: "size:S", color: null }] }),
535 entry({ number: 11, labels: [{ name: "size:XS", color: null }] }),
536 entry({ number: 12, labels: [{ name: "size:L", color: null }] }),
537 ];
538 const kept = (labels: ReadonlyArray<ReadonlyArray<string>>) =>
539 sized.filter((row) => matchesPullRequestFilters(row, { labels })).map((row) => row.number);
540
541 expect(kept([["size:S", "size:XS"]])).toEqual([10, 11]);
542 expect(kept([["size:XXL"]])).toEqual([]);
543 });
544
545 it("takes two groups as an and, each satisfied on its own", () => {
546 const row = entry({
547 number: 20,
548 labels: [
549 { name: "size:S", color: null },
550 { name: "bug", color: null },
551 ],
552 });
553 expect(matchesPullRequestFilters(row, { labels: [["size:S", "size:XS"], ["bug"]] })).toBe(true);
554 // The second group holds nothing this row carries, so the first one satisfied is not enough.
555 expect(matchesPullRequestFilters(row, { labels: [["size:S"], ["wip"]] })).toBe(false);
556 });
557
558 it("holds on to every row for a filter no row carries the answer to", () => {
559 expect(narrow({ checks: "passing" })).toEqual([1, 2, 3, 4]);
560 });
561});
562
563describe("reading qualifiers out of a typed query", () => {
564 it("keeps quoted values whole and separates negation", () => {
565 expect(parsePullRequestQuery('label:"needs design" -label:wip Fix the parser')).toEqual({
566 text: "Fix the parser",
567 filters: { labels: [["needs design"]], excludedLabels: ["wip"] },
568 });
569 });
570
571 it("reads a comma-separated list as one group either name satisfies", () => {
572 expect(parsePullRequestQuery("label:size:S,size:XS").filters.labels).toEqual([
573 ["size:S", "size:XS"],
574 ]);
575 });
576
577 it("keeps the spaces in a quoted name of a list, and drops an empty part", () => {
578 expect(parsePullRequestQuery('label:"needs design","wip"').filters.labels).toEqual([
579 ["needs design", "wip"],
580 ]);
581 expect(parsePullRequestQuery("label:a,,b").filters.labels).toEqual([["a", "b"]]);
582 });
583
584 it("excludes every name of a negated list", () => {
585 expect(parsePullRequestQuery("-label:a,b").filters).toEqual({ excludedLabels: ["a", "b"] });
586 });
587
588 it("makes each label qualifier its own group, so the groups are an AND", () => {
589 expect(parsePullRequestQuery("label:bug label:a,b").filters.labels).toEqual([
590 ["bug"],
591 ["a", "b"],
592 ]);
593 });
594
595 it("reads the scalar qualifiers in GitHub's own spelling", () => {
596 expect(
597 parsePullRequestQuery("author:octocat DRAFT:false review:changes_requested status:failure")
598 .filters,
599 ).toEqual({
600 author: "octocat",
601 draft: "hide",
602 review: "changes-requested",
603 checks: "failing",
604 });
605 });
606
607 it("leaves an unknown value and a stray colon as text, and reads an unknown key as a label", () => {
608 const parsed = parsePullRequestQuery("milestone:v2 draft:maybe status: parser");
609 expect(parsed.filters).toEqual({ labels: [["milestone:v2"]] });
610 // A known key whose value it does not take is text, so "status:" itself stays findable.
611 expect(parsed.text).toBe("draft:maybe status: parser");
612 });
613
614 it("reads a quoted name holding a comma as the one label it is", () => {
615 expect(parsePullRequestQuery('label:"needs,triage"').filters.labels).toEqual([
616 ["needs,triage"],
617 ]);
618 expect(parsePullRequestQuery('-label:"needs,triage"').filters.excludedLabels).toEqual([
619 "needs,triage",
620 ]);
621 });
622
623 it("cuts a typed query back to what the contract carries", () => {
624 const many = Array.from({ length: 14 }, (_, index) => `l${index}`).join(",");
625 const long = "x".repeat(240);
626 const parsed = parsePullRequestQuery(`label:${many} label:${long} author:${long}`);
627 expect(parsed.filters.labels?.[0]).toHaveLength(10);
628 expect(parsed.filters.labels?.[1]).toEqual(["x".repeat(200)]);
629 expect(parsed.filters.author).toBe("x".repeat(200));
630 });
631
632 it("cuts a namespaced label after its key is prefixed on", () => {
633 const parsed = parsePullRequestQuery(`size:${"x".repeat(240)}`);
634 expect(parsed.filters.labels).toEqual([[`size:${"x".repeat(240)}`.slice(0, 200)]]);
635 });
636
637 it("answers an empty query with an empty everything", () => {
638 expect(parsePullRequestQuery(" ")).toEqual({ text: "", filters: {} });
639 });
640});
641
642describe("resolveProjectScope", () => {
643 const projects = [{ id: "p1" }, { id: "p2" }];
644
645 it("keeps an id the environment has", () => {
646 expect(resolveProjectScope("p2", projects, true)).toBe("p2");
647 });
648
649 it("drops an id from another environment", () => {
650 expect(resolveProjectScope("p9", projects, false)).toBe("p9");
651 expect(resolveProjectScope("p9", projects, true)).toBeUndefined();
652 });
653
654 it("drops an id once the environment is known to have no projects", () => {
655 expect(resolveProjectScope("p9", [], true)).toBeUndefined();
656 });
657
658 it("keeps an id while the projects are still unknown", () => {
659 // Dropping here would list every project for a moment before narrowing back down.
660 expect(resolveProjectScope("p9", [], false)).toBe("p9");
661 });
662});
663
664describe("ranking what a search found", () => {
665 const row = (overrides: Partial<PullRequestListEntry> & { number?: number }) =>
666 entry({ number: 1, ...overrides });
667
668 it("puts the pull request whose title says it above one that merely mentions it", () => {
669 const titled = row({
670 number: 1,
671 title: "Add the welcome wizard",
672 updatedAt: "2026-07-01T00:00:00Z",
673 });
674 const mentioned = row({
675 number: 2,
676 title: "Unrelated work",
677 updatedAt: "2026-08-01T00:00:00Z",
678 });
679 expect(
680 rankPullRequestMatches([mentioned, titled], "welcome wizard").map((entry) => entry.number),
681 ).toEqual([1, 2]);
682 });
683
684 it("finds the words in any order, which is how people type them", () => {
685 // Substrings count, so "welcomes" answers "welcome" — a search is not a spelling test.
686 expect(
687 scorePullRequestMatch(row({ title: "The wizard that welcomes" }), "welcome wizard"),
688 ).toBe(70);
689 expect(
690 scorePullRequestMatch(row({ title: "Wizard for the welcome flow" }), "welcome wizard"),
691 ).toBe(70);
692 // One of the two words is a mention, not an answer, and ranks under both.
693 expect(scorePullRequestMatch(row({ title: "A wizard, of sorts" }), "welcome wizard")).toBe(30);
694 });
695
696 it("answers a number with the pull request that has it, and nothing else", () => {
697 expect(scorePullRequestMatch(row({ number: 42 }), "#42")).toBe(100);
698 expect(scorePullRequestMatch(row({ number: 42 }), "43")).toBe(0);
699 });
700
701 it("keeps recency where nothing tells two results apart", () => {
702 const older = row({ number: 1, title: "no match here", updatedAt: "2026-07-01T00:00:00Z" });
703 const newer = row({ number: 2, title: "none here either", updatedAt: "2026-08-01T00:00:00Z" });
704 expect(rankPullRequestMatches([older, newer], "wizard").map((entry) => entry.number)).toEqual([
705 2, 1,
706 ]);
707 });
708
709 it("leaves a listing with no search in the order it arrived", () => {
710 const first = row({ number: 1, updatedAt: "2026-07-01T00:00:00Z" });
711 const second = row({ number: 2, updatedAt: "2026-08-01T00:00:00Z" });
712 expect(rankPullRequestMatches([first, second], " ")).toEqual([first, second]);
713 });
714});
715
716describe("default merge-readiness ranking", () => {
717 it("puts ready work first, finished work after open work, and every conflict last", () => {
718 const conflict = entry({
719 number: 1,
720 mergeability: "conflicting",
721 checksState: "passing",
722 reviewDecision: "approved",
723 updatedAt: "2026-09-01T00:00:00Z",
724 });
725 const other = entry({
726 number: 2,
727 checksState: "pending",
728 updatedAt: "2026-08-01T00:00:00Z",
729 });
730 const green = entry({
731 number: 3,
732 checksState: "passing",
733 reviewDecision: "review-required",
734 updatedAt: "2026-07-01T00:00:00Z",
735 });
736 const approved = entry({
737 number: 4,
738 checksState: "passing",
739 reviewDecision: "approved",
740 additions: 1_000,
741 updatedAt: "2026-06-01T00:00:00Z",
742 });
743 const draft = entry({
744 number: 5,
745 isDraft: true,
746 checksState: "passing",
747 reviewDecision: "approved",
748 updatedAt: "2026-09-02T00:00:00Z",
749 });
750 const finished = entry({
751 number: 6,
752 state: "merged",
753 checksState: "passing",
754 reviewDecision: "approved",
755 updatedAt: "2026-09-03T00:00:00Z",
756 });
757
758 expect(
759 rankPullRequestsByMergeReadiness([conflict, other, green, approved, draft, finished]).map(
760 (row) => row.number,
761 ),
762 ).toEqual([4, 3, 5, 2, 6, 1]);
763 });
764
765 it("uses recency when readiness and diff size tie", () => {
766 const older = entry({ number: 1, checksState: "passing" });
767 const newer = entry({
768 number: 2,
769 checksState: "passing",
770 updatedAt: "2026-08-01T00:00:00Z",
771 });
772
773 expect(rankPullRequestsByMergeReadiness([older, newer]).map((row) => row.number)).toEqual([
774 2, 1,
775 ]);
776 });
777
778 it("ranks smaller measured diffs first within a readiness tier as counts arrive", () => {
779 const larger = entry({
780 number: 1,
781 checksState: "passing",
782 reviewDecision: "approved",
783 additions: 1,
784 deletions: 49,
785 updatedAt: "2026-09-01T00:00:00Z",
786 });
787 const smaller = entry({
788 number: 2,
789 checksState: "passing",
790 reviewDecision: "approved",
791 additions: 3,
792 deletions: 2,
793 updatedAt: "2026-08-01T00:00:00Z",
794 });
795 const unknown = entry({
796 number: 3,
797 checksState: "passing",
798 reviewDecision: "approved",
799 additions: 0,
800 deletions: 0,
801 updatedAt: "2026-09-02T00:00:00Z",
802 });
803
804 expect(
805 rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number),
806 ).toEqual([2, 1, 3]);
807 expect(
808 rankPullRequestsByMergeReadiness([
809 larger,
810 { ...unknown, additions: 1, deletions: 0 },
811 smaller,
812 ]).map((row) => row.number),
813 ).toEqual([3, 2, 1]);
814 });
815
816 it("distinguishes measured empty diffs from missing counts when sorting groups", () => {
817 const unknown = entry({ number: 1, additions: 0, deletions: 0 });
818 const measured = entry({ number: 2 });
819 const empty = entry({ number: 3, additions: 0, deletions: 0 });
820 const groups = [
821 { key: "others", label: "Others", entries: [unknown, measured, empty] },
822 ] as const;
823
824 const sorted = sortPullRequestGroups(groups, "ready", "", (row) => row.number !== 1);
825
826 expect(sorted[0]!.entries.map((row) => row.number)).toEqual([3, 2, 1]);
827 expect(sortPullRequestGroups(groups, "ready", "sidebar", (row) => row.number !== 1)).toEqual(
828 groups,
829 );
830 });
831
832 it("keeps authored work first and ranks each group by readiness", () => {
833 const authoredWaiting = entry({ number: 1, checksState: "pending" });
834 const authoredReady = entry({
835 number: 2,
836 checksState: "passing",
837 reviewDecision: "approved",
838 });
839 const otherReady = entry({
840 number: 3,
841 checksState: "passing",
842 reviewDecision: "approved",
843 });
844 const sorted = sortPullRequestGroups(
845 [
846 { key: "authored", label: "Authored", entries: [authoredWaiting, authoredReady] },
847 { key: "others", label: "Others", entries: [otherReady] },
848 ],
849 "ready",
850 "",
851 );
852
853 expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]);
854 expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]);
855 });
856
857 it.each([
858 ["updated", [1, 2]],
859 ["newest", [2, 1]],
860 ["oldest", [1, 2]],
861 ["largest", [1, 2]],
862 ["smallest", [2, 1]],
863 ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => {
864 const olderLarger = entry({
865 number: 1,
866 additions: 20,
867 createdAt: "2026-07-01T00:00:00Z",
868 updatedAt: "2026-08-01T00:00:00Z",
869 });
870 const newerSmaller = entry({
871 number: 2,
872 additions: 2,
873 createdAt: "2026-08-01T00:00:00Z",
874 updatedAt: "2026-07-01T00:00:00Z",
875 });
876 const sorted = sortPullRequestGroups(
877 [
878 { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] },
879 { key: "others", label: "Others", entries: [entry({ number: 3 })] },
880 ],
881 sort,
882 "",
883 );
884
885 expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]);
886 expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order);
887 });
888});
889
890describe("blocked-on-me ranking", () => {
891 it("ranks authored work by how surely it is the author's to unblock, newest first within a tier", () => {
892 const conflict = entry({
893 number: 1,
894 mergeability: "conflicting",
895 checksState: "failing",
896 updatedAt: "2026-08-01T00:00:00Z",
897 });
898 const olderFailing = entry({
899 number: 2,
900 checksState: "failing",
901 updatedAt: "2026-08-01T00:00:00Z",
902 });
903 const changesRequested = entry({
904 number: 3,
905 reviewDecision: "changes-requested",
906 checksState: "failing",
907 updatedAt: "2026-08-02T00:00:00Z",
908 });
909 const approved = entry({ number: 4, checksState: "passing", reviewDecision: "approved" });
910 const draft = entry({ number: 5, isDraft: true, checksState: "passing" });
911 const waiting = entry({ number: 6 });
912 const merged = entry({ number: 7, state: "merged", updatedAt: "2026-08-01T00:00:00Z" });
913 const newerClosed = entry({ number: 8, state: "closed", updatedAt: "2026-08-03T00:00:00Z" });
914 const newerFailing = entry({
915 number: 9,
916 checksState: "failing",
917 updatedAt: "2026-08-03T00:00:00Z",
918 });
919
920 expect(
921 rankPullRequestsBlockedOnAuthor([
922 waiting,
923 merged,
924 olderFailing,
925 draft,
926 approved,
927 changesRequested,
928 newerClosed,
929 conflict,
930 newerFailing,
931 ]).map((row) => row.number),
932 ).toEqual([1, 3, 9, 2, 5, 6, 4, 8, 7]);
933 });
934
935 it("ranks reviewer work with open rows first, newest first", () => {
936 const olderOpen = entry({ number: 1, updatedAt: "2026-08-01T00:00:00Z" });
937 const newerOpen = entry({ number: 2, updatedAt: "2026-08-02T00:00:00Z" });
938 const olderFinished = entry({ number: 3, state: "merged", updatedAt: "2026-08-01T00:00:00Z" });
939 const newerFinished = entry({ number: 4, state: "closed", updatedAt: "2026-08-03T00:00:00Z" });
940
941 expect(
942 rankPullRequestsBlockedOnReviewer([newerFinished, newerOpen, olderFinished, olderOpen]).map(
943 (row) => row.number,
944 ),
945 ).toEqual([2, 1, 4, 3]);
946 });
947
948 it("ranks blocked groups according to their involvement", () => {
949 const authored = [
950 entry({ number: 1, isDraft: true }),
951 entry({ number: 2, checksState: "failing" }),
952 ];
953 const reviewing = [
954 entry({ number: 3, updatedAt: "2026-08-02T00:00:00Z" }),
955 entry({ number: 4, updatedAt: "2026-08-01T00:00:00Z" }),
956 ];
957 const others = [
958 entry({ number: 5, updatedAt: "2026-08-02T00:00:00Z" }),
959 entry({ number: 6, isDraft: true }),
960 ];
961 const groups = [
962 { key: "authored", label: "Authored", entries: authored },
963 { key: "reviewRequested", label: "Review requested", entries: reviewing },
964 { key: "others", label: "Others", entries: others },
965 ] as const;
966
967 expect(
968 sortPullRequestGroups(groups, "blocked", "", undefined, "all").map((group) =>
969 group.entries.map((row) => row.number),
970 ),
971 ).toEqual([
972 [2, 1],
973 [3, 4],
974 [5, 6],
975 ]);
976 expect(
977 sortPullRequestGroups([groups[2]], "blocked", "", undefined, "authored")[0]!.entries.map(
978 (row) => row.number,
979 ),
980 ).toEqual([6, 5]);
981 expect(
982 sortPullRequestGroups([groups[2]], "blocked", "", undefined, "reviewing")[0]!.entries.map(
983 (row) => row.number,
984 ),
985 ).toEqual([5, 6]);
986 expect(sortPullRequestGroups(groups, "blocked", "needle", undefined, "authored")).toEqual(
987 groups,
988 );
989 });
990});
991
992describe("line counts that arrive after the rows", () => {
993 const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]);
994
995 it("fills in a row whose host left the counts for later", () => {
996 const row = entry({ number: 7, additions: 0, deletions: 0 });
997 expect(withDiffStat(row, stats)).toEqual({ ...row, additions: 42, deletions: 3 });
998 });
999
1000 it("leaves a row the host already counted alone", () => {
1001 const row = entry({ number: 7, additions: 1, deletions: 1 });
1002 expect(withDiffStat(row, stats)).toBe(row);
1003 });
1004
1005 it("leaves a row whose counts have not arrived as it is", () => {
1006 const row = entry({ number: 9, additions: 0, deletions: 0 });
1007 expect(withDiffStat(row, stats)).toBe(row);
1008 });
1009});
1010
1011describe("merging line counts across keyed stats queries", () => {
1012 it("keeps counts already held while a fresh batch says nothing about them", () => {
1013 const held = mergePullRequestDiffStats(new Map(), [
1014 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 10, deletions: 2 },
1015 { environmentId: "env-1", projectId: "project-1", number: 2, additions: 5, deletions: 1 },
1016 ]);
1017 // A third row appeared; its batch is still pending and contributes nothing yet.
1018 const merged = mergePullRequestDiffStats(held, []);
1019 expect(merged.get("env-1 project-1 1")).toEqual({ additions: 10, deletions: 2 });
1020 expect(merged.get("env-1 project-1 2")).toEqual({ additions: 5, deletions: 1 });
1021 });
1022
1023 it("replaces a count once its replacement arrives, keeping its neighbours", () => {
1024 const held = mergePullRequestDiffStats(new Map(), [
1025 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 10, deletions: 2 },
1026 ]);
1027 const merged = mergePullRequestDiffStats(held, [
1028 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 11, deletions: 2 },
1029 { environmentId: "env-1", projectId: "project-1", number: 3, additions: 7, deletions: 0 },
1030 ]);
1031 expect(merged.get("env-1 project-1 1")).toEqual({ additions: 11, deletions: 2 });
1032 expect(merged.get("env-1 project-1 3")).toEqual({ additions: 7, deletions: 0 });
1033 });
1034
1035 it("does not mutate the map it was handed", () => {
1036 const held = new Map([["env-1 project-1 1", { additions: 1, deletions: 1 }]]);
1037 mergePullRequestDiffStats(held, [
1038 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 2, deletions: 2 },
1039 ]);
1040 expect(held.get("env-1 project-1 1")).toEqual({ additions: 1, deletions: 1 });
1041 });
1042});
1043
1044describe("partitioning with the hosts' own priority reads", () => {
1045 const authoredRow = (number: number, updatedAt: string) =>
1046 entry({ number, updatedAt, author: { login: "Bilal", name: null, avatarUrl: null } });
1047
1048 it("keeps Others in feed order when a continuation lands an authored row already partitioned", () => {
1049 const older = entry({ number: 1, updatedAt: "2026-07-05T00:00:00Z" });
1050 const newer = entry({ number: 2, updatedAt: "2026-07-06T00:00:00Z" });
1051 const mine = authoredRow(3, "2026-07-04T00:00:00Z");
1052 // The authored partition already holds the row the continuation carries.
1053 const groups = partitionPullRequestsWithPriority([newer, older, mine], [mine], []);
1054 expect(groups.map((group) => group.key)).toEqual(["authored", "others"]);
1055 expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 1]);
1056 expect(groups[0]!.entries.map((item) => item.number)).toEqual([3]);
1057 });
1058
1059 it("appends an authored row the partition page missed to Others rather than moving it up", () => {
1060 const shown = entry({ number: 1, updatedAt: "2026-07-06T00:00:00Z" });
1061 const olderMine = authoredRow(9, "2026-01-01T00:00:00Z");
1062 const groups = partitionPullRequestsWithPriority([shown, olderMine], [], []);
1063 expect(groups).toHaveLength(1);
1064 expect(groups[0]!.entries.map((item) => item.number)).toEqual([1, 9]);
1065 });
1066
1067 it("gives authored precedence over review-requested and orders partitions by recency", () => {
1068 const both = authoredRow(1, "2026-07-01T00:00:00Z");
1069 const requested = entry({
1070 number: 2,
1071 viewerReviewRequested: true,
1072 updatedAt: "2026-07-02T00:00:00Z",
1073 });
1074 const requestedOlder = entry({
1075 number: 3,
1076 viewerReviewRequested: true,
1077 updatedAt: "2026-06-02T00:00:00Z",
1078 });
1079 const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]);
1080 expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]);
1081 expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]);
1082 expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]);
1083 });
1084
1085 it("lets the feed's copy of a partitioned row replace the partition's", () => {
1086 const stale = authoredRow(1, "2026-07-01T00:00:00Z");
1087 const fresh = { ...stale, title: "Retitled" };
1088 const groups = partitionPullRequestsWithPriority([fresh], [stale], []);
1089 expect(groups[0]!.entries[0]!.title).toBe("Retitled");
1090 });
1091});
1092
1093describe("the list snapshot across a reload", () => {
1094 const makeStorage = () => {
1095 const held = new Map<string, string>();
1096 return {
1097 getItem: (key: string) => held.get(key) ?? null,
1098 setItem: (key: string, value: string) => void held.set(key, value),
1099 };
1100 };
1101 const data = {
1102 entries: [entry({ number: 1 })],
1103 viewers: { "github.com": "Bilal" },
1104 providers: [],
1105 errors: [{ projectId: "project-1", message: "boom" }],
1106 truncated: true,
1107 nextCursors: { "pingdotgg/t3code": "cursor-1" },
1108 } as never;
1109
1110 it("hydrates the retained rows so ghosts never replace them", () => {
1111 const storage = makeStorage();
1112 writePullRequestListSnapshot(storage, "env-1", { scope: "env-1:open:all::", data });
1113 const snapshot = readPullRequestListSnapshot(storage, "env-1");
1114 expect(snapshot?.scope).toBe("env-1:open:all::");
1115 expect(snapshot?.data.entries.map((item) => item.number)).toEqual([1]);
1116 });
1117
1118 it("carries neither stale failures nor stale cursors", () => {
1119 const storage = makeStorage();
1120 writePullRequestListSnapshot(storage, "env-1", { scope: "s", data });
1121 const snapshot = readPullRequestListSnapshot(storage, "env-1");
1122 expect(snapshot?.data.errors).toEqual([]);
1123 expect(snapshot?.data.nextCursors).toEqual({});
1124 });
1125
1126 it("answers nothing for another environment", () => {
1127 const storage = makeStorage();
1128 writePullRequestListSnapshot(storage, "env-1", { scope: "s", data });
1129 expect(readPullRequestListSnapshot(storage, "env-2")).toBeNull();
1130 });
1131
1132 it("rejects a snapshot whose rows do not decode as entries", () => {
1133 const storage = makeStorage();
1134 storage.setItem(
1135 "t3.pullRequests.list:env-1",
1136 JSON.stringify({ scope: "s", data: { entries: [null] } }),
1137 );
1138 expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull();
1139 storage.setItem(
1140 "t3.pullRequests.list:env-1",
1141 JSON.stringify({ scope: "s", data: { entries: [{ host: "github.com" }] } }),
1142 );
1143 expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull();
1144 });
1145
1146 it("shrugs off corrupt storage and no storage at all", () => {
1147 const storage = makeStorage();
1148 storage.setItem("t3.pullRequests.list:env-1", "{not json");
1149 expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull();
1150 expect(readPullRequestListSnapshot(undefined, "env-1")).toBeNull();
1151 });
1152
1153 it("caps the accumulated rows but keeps the whole host set", () => {
1154 // The page keeps growing as the reader scrolls, so what gets written is the accumulated
1155 // list rather than one round's answer — capped at the one page a cold start needs, while
1156 // the hosts it was read from (which do not grow with the page) are kept in full.
1157 const storage = makeStorage();
1158 const manyEntries = Array.from({ length: 120 }, (_, index) => entry({ number: index + 1 }));
1159 const viewers = { "github.com": "Bilal", "gitlab.com": "Bilal" };
1160 const providers = [
1161 {
1162 host: "github.com",
1163 kind: "github",
1164 searchesOnHost: true,
1165 projectCount: 1,
1166 configured: true,
1167 detail: null,
1168 },
1169 {
1170 host: "gitlab.com",
1171 kind: "gitlab",
1172 searchesOnHost: true,
1173 projectCount: 1,
1174 configured: true,
1175 detail: null,
1176 },
1177 ];
1178 const accumulated = {
1179 entries: manyEntries,
1180 viewers,
1181 providers,
1182 errors: [],
1183 truncated: true,
1184 nextCursors: {},
1185 } as never;
1186 writePullRequestListSnapshot(storage, "env-1", { scope: "s", data: accumulated });
1187 const snapshot = readPullRequestListSnapshot(storage, "env-1");
1188 expect(snapshot?.data.entries).toHaveLength(99);
1189 expect(snapshot?.data.viewers).toEqual(viewers);
1190 expect(snapshot?.data.providers).toEqual(providers);
1191 });
1192});
1193
1194describe("remembered pull request list controls", () => {
1195 const makeStorage = () => {
1196 const held = new Map<string, string>();
1197 return {
1198 getItem: (key: string) => held.get(key) ?? null,
1199 setItem: (key: string, value: string) => void held.set(key, value),
1200 };
1201 };
1202
1203 it("restores every filter and the selected sort", () => {
1204 const storage = makeStorage();
1205 const preferences = {
1206 involvement: "reviewing",
1207 state: "merged",
1208 environmentId: "env-1" as EnvironmentId,
1209 projectId: "project-1" as ProjectId,
1210 host: "github.com",
1211 q: "workflow",
1212 draft: "hide",
1213 review: "approved",
1214 checks: "passing",
1215 author: "octocat",
1216 labels: ["bug", "priority"],
1217 sort: "largest",
1218 } as const;
1219
1220 writePullRequestListPreferences(preferences, storage);
1221 expect(readPullRequestListPreferences(storage)).toEqual(preferences);
1222 });
1223
1224 it("omits the default sort from storage", () => {
1225 expect(
1226 pullRequestListPreferences({
1227 involvement: "all",
1228 state: "open",
1229 sort: "ready",
1230 }),
1231 ).toEqual({ involvement: "all", state: "open" });
1232 });
1233
1234 it("keeps an explicit recency sort", () => {
1235 expect(
1236 pullRequestListPreferences({ involvement: "all", state: "open", sort: "updated" }),
1237 ).toEqual({ involvement: "all", state: "open", sort: "updated" });
1238 });
1239
1240 it("falls back to the default controls when storage is corrupt", () => {
1241 const storage = makeStorage();
1242 storage.setItem("t3.pullRequests.preferences", "{not json");
1243 expect(readPullRequestListPreferences(storage)).toEqual({ involvement: "all", state: "open" });
1244 });
1245
1246 it("falls back when browser policy denies storage", () => {
1247 const denied = {
1248 getItem: () => {
1249 throw new Error("storage denied");
1250 },
1251 setItem: () => {
1252 throw new Error("storage denied");
1253 },
1254 };
1255 expect(readPullRequestListPreferences(denied)).toEqual({ involvement: "all", state: "open" });
1256 expect(() =>
1257 writePullRequestListPreferences({ involvement: "all", state: "open" }, denied),
1258 ).not.toThrow();
1259 });
1260});
1261
1262const ENV_1 = "env-1" as EnvironmentId;
1263const ENV_2 = "env-2" as EnvironmentId;
1264
1265describe("merging the environments' own listings", () => {
1266 const answer = (
1267 overrides: Partial<Parameters<typeof mergePullRequestLists>[0][number][1]> = {},
1268 ) =>
1269 ({
1270 viewers: { "github.com": "Bilal" },
1271 providers: [
1272 {
1273 host: "github.com",
1274 kind: "github",
1275 searchesOnHost: true,
1276 projectCount: 1,
1277 configured: true,
1278 detail: null,
1279 },
1280 ],
1281 entries: [],
1282 errors: [],
1283 truncated: false,
1284 nextCursors: {},
1285 ...overrides,
1286 }) as Parameters<typeof mergePullRequestLists>[0][number][1];
1287
1288 it("answers nothing until an environment has", () => {
1289 expect(mergePullRequestLists([])).toBeNull();
1290 });
1291
1292 it("tags every row with the environment that read it, newest first", () => {
1293 const merged = mergePullRequestLists([
1294 [ENV_1, answer({ entries: [entry({ number: 1, updatedAt: "2026-07-01T00:00:00Z" })] })],
1295 [ENV_2, answer({ entries: [entry({ number: 2, updatedAt: "2026-08-01T00:00:00Z" })] })],
1296 ]);
1297 expect(merged?.entries.map((row) => [row.environmentId, row.number])).toEqual([
1298 [ENV_2, 2],
1299 [ENV_1, 1],
1300 ]);
1301 });
1302
1303 it("tells two environments' copies of one pull request apart", () => {
1304 const row = entry({ number: 4 });
1305 expect(pullRequestEntryKey({ ...row, environmentId: ENV_1 })).not.toBe(
1306 pullRequestEntryKey({ ...row, environmentId: ENV_2 }),
1307 );
1308 });
1309
1310 it("keeps project errors scoped to the environment that reported them", () => {
1311 const error = {
1312 projectId: "project-1" as ProjectId,
1313 projectTitle: "Web",
1314 message: "Not signed in",
1315 } as const;
1316 const merged = mergePullRequestLists([
1317 [ENV_1, answer({ errors: [error] })],
1318 [ENV_2, answer()],
1319 ]);
1320
1321 expect(merged?.errors).toEqual([{ ...error, environmentId: ENV_1 }]);
1322 });
1323
1324 it("folds a host reached from two environments into one switcher row", () => {
1325 const merged = mergePullRequestLists([
1326 [ENV_1, answer()],
1327 [
1328 ENV_2,
1329 answer({
1330 providers: [
1331 {
1332 host: "github.com",
1333 kind: SourceControlProviderKind.make("github"),
1334 searchesOnHost: false,
1335 projectCount: 2,
1336 configured: false,
1337 detail: "Not signed in.",
1338 },
1339 ],
1340 }),
1341 ],
1342 ]);
1343 // Readable because one environment could read it, but narrowed locally because the other
1344 // answers unsearched.
1345 expect(merged?.providers).toEqual([
1346 {
1347 host: "github.com",
1348 kind: "github",
1349 searchesOnHost: false,
1350 projectCount: 3,
1351 configured: true,
1352 detail: null,
1353 },
1354 ]);
1355 });
1356
1357 it("keeps each environment's continuation to itself", () => {
1358 const merged = mergePullRequestLists([
1359 [ENV_1, answer({ nextCursors: { "github.com pingdotgg/t3code": "cursor-1" } })],
1360 [ENV_2, answer()],
1361 ]);
1362 expect(merged?.nextCursors).toEqual({
1363 [ENV_1]: { "github.com pingdotgg/t3code": "cursor-1" },
1364 });
1365 });
1366
1367 it("reads the same key whichever order the environments connected in", () => {
1368 expect(pullRequestEnvironmentSetKey(["env-2", "env-1"])).toBe(
1369 pullRequestEnvironmentSetKey(["env-1", "env-2"]),
1370 );
1371 });
1372});
1373
1374describe('who "I" am, per server', () => {
1375 const answer = (viewers: Record<string, string>, entries: ReadonlyArray<PullRequestListEntry>) =>
1376 ({
1377 viewers,
1378 providers: [],
1379 entries,
1380 errors: [],
1381 truncated: false,
1382 nextCursors: {},
1383 }) as Parameters<typeof mergePullRequestLists>[0][number][1];
1384 const byBilal = { login: "Bilal", name: null, avatarUrl: null };
1385
1386 it("does not let one server's account decide who authored another server's rows", () => {
1387 // Both servers reach github.com, signed in as different people. Folded into one host-keyed
1388 // record, whichever answered last spoke for both — and every row of the reader's own work
1389 // was filed under Others.
1390 const merged = mergePullRequestLists([
1391 [ENV_1, answer({ "github.com": "Bilal" }, [entry({ number: 1, author: byBilal })])],
1392 [ENV_2, answer({ "github.com": "Octocat" }, [entry({ number: 2, author: byBilal })])],
1393 ])!;
1394
1395 const groups = groupPullRequestsByInvolvement(merged.entries, merged.viewers);
1396 expect(
1397 groups.find((group) => group.key === "authored")?.entries.map((row) => row.number),
1398 ).toEqual([1]);
1399 expect(
1400 filterPullRequestsByInvolvement(merged.entries, merged.viewers, "authored").map(
1401 (row) => row.number,
1402 ),
1403 ).toEqual([1]);
1404 });
1405
1406 it("still reads a single server's host-keyed viewers, which is what a snapshot carries", () => {
1407 expect(
1408 filterPullRequestsByInvolvement(
1409 [entry({ number: 1, author: byBilal })],
1410 { "github.com": "Bilal" },
1411 "authored",
1412 ).map((row) => row.number),
1413 ).toEqual([1]);
1414 });
1415
1416 it("names the servers with more rows and no cursor to reach them by", () => {
1417 const merged = mergePullRequestLists([
1418 [
1419 ENV_1,
1420 { ...answer({}, []), truncated: true, nextCursors: { "github.com acme/web": "cursor-1" } },
1421 ],
1422 [ENV_2, { ...answer({}, []), truncated: true }],
1423 ])!;
1424
1425 expect(merged.truncatedEnvironments).toEqual([ENV_1, ENV_2]);
1426 expect(Object.keys(merged.nextCursors)).toEqual([ENV_1]);
1427 });
1428});
1429
1430describe("the project an id names", () => {
1431 const projects = [
1432 { id: "project-1", environmentId: ENV_1 },
1433 { id: "project-1", environmentId: ENV_2 },
1434 { id: "project-2", environmentId: ENV_2 },
1435 ];
1436
1437 it("takes the server it was given", () => {
1438 expect(findScopedProject(projects, ENV_2, "project-1")?.environmentId).toBe(ENV_2);
1439 });
1440
1441 it("answers a bare id only where one server has it", () => {
1442 expect(findScopedProject(projects, null, "project-2")?.environmentId).toBe(ENV_2);
1443 // Two servers hold this id: narrowing to either would be a coin toss the reader cannot see.
1444 expect(findScopedProject(projects, null, "project-1")).toBeUndefined();
1445 });
1446
1447 it("answers nothing for a server that does not have it", () => {
1448 expect(findScopedProject(projects, ENV_1, "project-2")).toBeUndefined();
1449 });
1450});
1451
1452describe("which environments a listing should ask", () => {
1453 const ENV_3 = "env-3" as EnvironmentId;
1454 const environmentIds = [ENV_1, ENV_2, ENV_3];
1455
1456 it("asks only the owning server once the project is unambiguous", () => {
1457 const projects = [{ id: "project-1", environmentId: ENV_2 }];
1458 expect(
1459 resolveQueryEnvironmentIds(
1460 environmentIds,
1461 projects,
1462 { environmentId: ENV_2 },
1463 "project-1",
1464 true,
1465 ),
1466 ).toEqual([ENV_2]);
1467 });
1468
1469 it("asks every environment when there is no project narrowing at all", () => {
1470 expect(resolveQueryEnvironmentIds(environmentIds, [], undefined, undefined, true)).toEqual(
1471 environmentIds,
1472 );
1473 });
1474
1475 it("asks only the environments that actually hold an ambiguous bare id, never a third that doesn't", () => {
1476 const projects = [
1477 { id: "project-1", environmentId: ENV_1 },
1478 { id: "project-1", environmentId: ENV_2 },
1479 ];
1480 // Two servers can genuinely hold the same project id string; the third holds no such project
1481 // and asking it would return an unrelated project's rows rather than an honest empty answer.
1482 expect(
1483 resolveQueryEnvironmentIds(environmentIds, projects, undefined, "project-1", true),
1484 ).toEqual([ENV_1, ENV_2]);
1485 });
1486
1487 it("asks nothing for a bare id no environment holds", () => {
1488 const projects = [{ id: "project-1", environmentId: ENV_1 }];
1489 expect(
1490 resolveQueryEnvironmentIds(environmentIds, projects, undefined, "project-9", true),
1491 ).toEqual([]);
1492 });
1493
1494 it("asks every environment while the servers have not said what they hold yet", () => {
1495 // Nothing matches the id because nothing has been read yet, which is not the same answer as
1496 // no server holding it: reading none of them would show an empty page for a project that is
1497 // there.
1498 expect(resolveQueryEnvironmentIds(environmentIds, [], undefined, "project-1", false)).toEqual(
1499 environmentIds,
1500 );
1501 });
1502
1503 it("keeps the single-environment case unchanged", () => {
1504 const projects = [{ id: "project-1", environmentId: ENV_1 }];
1505 expect(resolveQueryEnvironmentIds([ENV_1], projects, undefined, "project-1", true)).toEqual([
1506 ENV_1,
1507 ]);
1508 });
1509});
1510
1511describe("the server a saved selection names", () => {
1512 const known = new Set([ENV_1, ENV_2]);
1513
1514 it("resolves to nothing yet for a server that is known but not ready, rather than falling back", () => {
1515 // ENV_2 is in the catalog (still connecting, say) but not the fallback; a caller that then
1516 // looks a project up under ENV_2 finds none rather than one belonging to the fallback server.
1517 expect(resolveSelectedEnvironmentId(ENV_2, known, ENV_1)).toBe(ENV_2);
1518 });
1519
1520 it("falls back for a server the workspace genuinely no longer has", () => {
1521 const goneServer = "env-gone" as EnvironmentId;
1522 expect(resolveSelectedEnvironmentId(goneServer, known, ENV_1)).toBe(ENV_1);
1523 });
1524
1525 it("falls back to nothing when no server names a fallback either", () => {
1526 const goneServer = "env-gone" as EnvironmentId;
1527 expect(resolveSelectedEnvironmentId(goneServer, known, null)).toBeNull();
1528 });
1529
1530 it("uses the fallback outright when nothing was named", () => {
1531 expect(resolveSelectedEnvironmentId(undefined, known, ENV_1)).toBe(ENV_1);
1532 expect(resolveSelectedEnvironmentId(undefined, known, null)).toBeNull();
1533 });
1534});
1535
1536describe("colon-namespaced labels typed as a search", () => {
1537 it("reads an unknown key as the label it almost always is", () => {
1538 expect(parsePullRequestQuery("size:XXL").filters.labels).toEqual([["size:XXL"]]);
1539 expect(parsePullRequestQuery("vouch:trusted").filters.labels).toEqual([["vouch:trusted"]]);
1540 expect(parsePullRequestQuery("size:XXL").text).toBe("");
1541 });
1542
1543 it("reads the key as the namespace of every bare name in its list", () => {
1544 expect(parsePullRequestQuery("size:S,XS").filters.labels).toEqual([["size:S", "size:XS"]]);
1545 });
1546
1547 it("leaves a name that already carries its own namespace alone", () => {
1548 // `size:S,size:XS` is the same pair written out; prefixing again would ask for `size:size:XS`.
1549 expect(parsePullRequestQuery("size:S,size:XS").filters.labels).toEqual([["size:S", "size:XS"]]);
1550 });
1551
1552 it("excludes one the same way", () => {
1553 expect(parsePullRequestQuery("-size:XXL").filters.excludedLabels).toEqual(["size:XXL"]);
1554 expect(parsePullRequestQuery("-size:S,XS").filters.excludedLabels).toEqual([
1555 "size:S",
1556 "size:XS",
1557 ]);
1558 });
1559
1560 it("keeps a quoted token as text, which is the way back to a literal search", () => {
1561 const parsed = parsePullRequestQuery('"size:XXL"');
1562 expect(parsed.filters.labels).toBeUndefined();
1563 expect(parsed.text).toBe('"size:XXL"');
1564 });
1565
1566 it("reads a hyphenated namespace as a label too", () => {
1567 expect(parsePullRequestQuery("priority-1:high").filters.labels).toEqual([["priority-1:high"]]);
1568 expect(parsePullRequestQuery("priority-1:high").text).toBe("");
1569 });
1570
1571 it("leaves a pasted link alone rather than naming a label after its scheme", () => {
1572 const parsed = parsePullRequestQuery("https://github.com/pingdotgg/t3code/pull/1");
1573 expect(parsed.filters.labels).toBeUndefined();
1574 expect(parsed.text).toBe("https://github.com/pingdotgg/t3code/pull/1");
1575 });
1576
1577 it("mixes with the keys it does know, and with plain words", () => {
1578 const parsed = parsePullRequestQuery("area:web draft:true wizard label:bug");
1579 expect(parsed.filters).toEqual({ labels: [["area:web"], ["bug"]], draft: "only" });
1580 expect(parsed.text).toBe("wizard");
1581 });
1582
1583 it("shares the ceiling with the labels typed as labels", () => {
1584 const many = Array.from({ length: 12 }, (_, index) => `area:${index}`).join(" ");
1585 expect(parsePullRequestQuery(many).filters.labels).toHaveLength(10);
1586 });
1587});
1588
1589describe("the priority groups against a paginated feed", () => {
1590 const authoredRow = (number: number, updatedAt: string) =>
1591 entry({ number, updatedAt, author: { login: "Bilal", name: null, avatarUrl: null } });
1592
1593 it("shows every authored row the host reported, not only the ones the feed page holds", () => {
1594 // The feed is one page ordered by recency, so on a busy repository it can hold exactly one of
1595 // somebody's own pull requests while the rest sit further down the host's list. The Authored
1596 // group comes from its own server-filtered read for that reason: grouping the page instead
1597 // leaves the older ones under Others, or off the page altogether.
1598 const newest = authoredRow(6039, "2026-08-11T08:00:00Z");
1599 const older = [
1600 authoredRow(5499, "2026-08-10T17:00:00Z"),
1601 authoredRow(5544, "2026-08-10T16:00:00Z"),
1602 ];
1603 const feed = [newest, entry({ number: 6123, updatedAt: "2026-08-11T09:00:00Z" })];
1604
1605 const groups = partitionPullRequestsWithPriority(feed, [newest, ...older], []);
1606
1607 expect(
1608 groups.find((group) => group.key === "authored")?.entries.map((row) => row.number),
1609 ).toEqual([6039, 5499, 5544]);
1610 expect(
1611 groups.find((group) => group.key === "others")?.entries.map((row) => row.number),
1612 ).toEqual([6123]);
1613 });
1614});
1615
1616describe("pull request list overrides", () => {
1617 const entry = (number: number, state: "open" | "closed" | "merged") =>
1618 ({
1619 host: "github.com",
1620 repository: "pingdotgg/t3code",
1621 number,
1622 state,
1623 isDraft: false,
1624 updatedAt: "2026-07-01T00:00:00Z",
1625 labels: [],
1626 }) as unknown as PullRequestListEntry;
1627 const key = (row: { number: number }) => `#${row.number}`;
1628
1629 it("maps the actions that change a row's state and nothing else", () => {
1630 const now = new Date("2026-07-02T00:00:00Z");
1631 expect(pullRequestOverrideAfterAction(entry(1, "open"), "close", now, 7)).toEqual({
1632 state: "closed",
1633 updatedAt: "2026-07-02T00:00:00.000Z",
1634 token: 7,
1635 at: now.getTime(),
1636 });
1637 expect(pullRequestOverrideAfterAction(entry(1, "closed"), "reopen", now, 1)?.state).toBe(
1638 "open",
1639 );
1640 expect(pullRequestOverrideAfterAction(entry(1, "open"), "merge", now, 1)?.state).toBe("merged");
1641 expect(pullRequestOverrideAfterAction(entry(1, "open"), "draft", now, 1)?.isDraft).toBe(true);
1642 expect(pullRequestOverrideAfterAction(entry(1, "open"), "update-branch", now, 1)).toBeNull();
1643 });
1644
1645 it("writes the override over the row and drops it from a list whose state it left", () => {
1646 const rows = [entry(1, "open"), entry(2, "open")];
1647 const overrides = new Map([
1648 ["#1", { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at: 0 }],
1649 ]);
1650 expect(
1651 applyPullRequestOverrides(rows, overrides, key, "open").map((row) => row.number),
1652 ).toEqual([2]);
1653 const all = applyPullRequestOverrides(rows, overrides, key, "all");
1654 expect(all.map((row) => [row.number, row.state])).toEqual([
1655 [1, "closed"],
1656 [2, "open"],
1657 ]);
1658 expect(applyPullRequestOverrides(rows, new Map(), key, "open")).toBe(rows);
1659 });
1660
1661 it.each([
1662 ["open", [1]],
1663 ["closed", [2]],
1664 ["merged", [3]],
1665 ["all", [1, 2, 3]],
1666 ] as const)("filters host rows by %s after pending actions settle", (state, expected) => {
1667 const rows = [entry(1, "open"), entry(2, "closed"), entry(3, "merged")];
1668 expect(applyPullRequestOverrides(rows, new Map(), key, state).map((row) => row.number)).toEqual(
1669 expected,
1670 );
1671 });
1672
1673 it("keeps a confirmed close hidden when its override is cleared", () => {
1674 const at = 1_000_000;
1675 const rows = [entry(1, "closed"), entry(2, "open"), entry(3, "merged")];
1676 const overrides = new Map([
1677 ["#1", { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at }],
1678 ]);
1679 const settled = settlePullRequestOverrides(overrides, rows, key, at + 5_000);
1680 expect(settled.size).toBe(0);
1681 expect(applyPullRequestOverrides(rows, settled, key, "open").map((row) => row.number)).toEqual([
1682 2,
1683 ]);
1684 });
1685
1686 it("hands back the held object for a row a refresh did not change", () => {
1687 const previous = [entry(1, "open"), entry(2, "open")];
1688 const next = [{ ...entry(1, "open") }, { ...entry(2, "open"), state: "merged" as const }];
1689 const reused = reusePullRequestEntries(previous, next, key);
1690 expect(reused[0]).toBe(previous[0]);
1691 expect(reused[1]).toBe(next[1]);
1692 expect(
1693 reusePullRequestEntries(previous, [{ ...entry(1, "open") }, { ...entry(2, "open") }], key),
1694 ).toBe(previous);
1695 });
1696});
1697
1698describe("pull request list override settlement", () => {
1699 const entry = (number: number, state: "open" | "closed" | "merged") =>
1700 ({ number, state, isDraft: false, labels: [] }) as unknown as PullRequestListEntry;
1701 const key = (row: { number: number }) => `#${row.number}`;
1702
1703 it("keeps an override until an answer agrees with it", () => {
1704 const at = 1_000_000;
1705 const closed = { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at };
1706 const overrides = new Map([["#1", closed]]);
1707 // A read from before the action still says open: the override stands.
1708 expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 5_000)).toBe(
1709 overrides,
1710 );
1711 // Absent from the answer says nothing: the row may live in another group or page.
1712 expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, at + 5_000).size).toBe(1);
1713 // Present as closed: confirmed.
1714 expect(settlePullRequestOverrides(overrides, [entry(1, "closed")], key, at + 5_000).size).toBe(
1715 0,
1716 );
1717 // Present as open a good while later: the host's news, which outranks the note.
1718 expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 90_000).size).toBe(
1719 0,
1720 );
1721 });
1722
1723 it("does not hand back the old order when only the order changed", () => {
1724 const previous = [entry(1, "open"), entry(2, "open")];
1725 const swapped = reusePullRequestEntries(previous, [entry(2, "open"), entry(1, "open")], key);
1726 expect(swapped).not.toBe(previous);
1727 expect(swapped.map((row) => row.number)).toEqual([2, 1]);
1728 });
1729});
1730
1731describe("pull request quick actions", () => {
1732 const summary = (host: string, kind: string, actions?: ReadonlyArray<PullRequestAction>) => ({
1733 host,
1734 kind: SourceControlProviderKind.make(kind),
1735 searchesOnHost: true,
1736 projectCount: 1,
1737 configured: true,
1738 detail: null,
1739 ...(actions === undefined ? {} : { actions }),
1740 });
1741
1742 it("offers what each host reports, and GitHub's legacy set when a server reports none", () => {
1743 const actionsOf = pullRequestHostActions([
1744 summary("bitbucket.org", "bitbucket", ["merge", "close"]),
1745 summary("github.com", "github"),
1746 ]);
1747 const bitbucket = actionsOf({
1748 host: "bitbucket.org",
1749 provider: SourceControlProviderKind.make("bitbucket"),
1750 });
1751 expect(pullRequestQuickActions({ state: "closed", isDraft: false }, bitbucket)).toEqual([]);
1752 const github = actionsOf({
1753 host: "github.com",
1754 provider: SourceControlProviderKind.make("github"),
1755 });
1756 expect(pullRequestQuickActions({ state: "open", isDraft: true }, github)).toEqual([
1757 "close",
1758 "ready",
1759 ]);
1760 });
1761});