Skip to content
Source
Sign inSign up

pullRequests.test.ts

504 lines18.7 KB

1import { SourceControlProviderKind } from "@t3tools/contracts";
2import {
3 EnvironmentId,
4 ProjectId,
5 type PullRequestListEntry,
6 type PullRequestListInput,
7 PullRequestUnavailableError,
8 PullRequestOperationError,
9 type PullRequestListResult,
10 type PullRequestSummary,
11} from "@t3tools/contracts";
12import { describe, expect, it, vi } from "vite-plus/test";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/reactivity";
15​
16import {
17 newestPullRequestObservation,
18 observedPullRequestSummary,
19 observePullRequestChecks,
20 observePullRequestMetadata,
21 newestPullRequestSummary,
22 rememberPullRequestListAnswer,
23 restorePullRequestList,
24 restoredPullRequestList,
25 threadPullRequestLinkToSummary,
26 observeFailedPullRequestList,
27} from "./pullRequests";
28​
29it("observes both typed list failures without changing their status or replaying cached timestamps", () => {
30 const listResult: PullRequestListResult = {
31 viewers: {},
32 providers: [],
33 entries: [],
34 errors: [],
35 truncated: false,
36 nextCursors: {},
37 };
38 using clock = vi.spyOn(Date, "now").mockReturnValue(100);
39 for (const error of [
40 new PullRequestUnavailableError({ reason: "cli-unauthenticated", listResult }),
41 new PullRequestOperationError({ operation: "list", detail: "HTTP 403", listResult }),
42 ]) {
43 clock.mockReturnValue(100);
44 const failure = AsyncResult.failure<PullRequestListResult, unknown>(Cause.fail(error));
45 expect(observeFailedPullRequestList(failure)).toEqual({ value: listResult, observedAt: 100 });
46 clock.mockReturnValue(200);
47 expect(observeFailedPullRequestList(failure)?.observedAt).toBe(100);
48 expect(failure._tag).toBe("Failure");
49 expect(observeFailedPullRequestList({ ...failure, waiting: true })).toBeUndefined();
50 }
51 expect(
52 observeFailedPullRequestList(
53 AsyncResult.failure(Cause.fail(new PullRequestUnavailableError({ reason: "cli-missing" }))),
54 ),
55 ).toBeUndefined();
56});
57​
58function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {
59 return {
60 provider: SourceControlProviderKind.make("github"),
61 projectId: ProjectId.make("pull-request-cache-test"),
62 repository: "acme/widget",
63 number: 7,
64 title: "Improve widget",
65 url: "https://github.com/acme/widget/pull/7",
66 state: "open",
67 isDraft: false,
68 headBranch: "improve-widget",
69 baseBranch: "main",
70 updatedAt: "2026-09-10T00:00:00Z",
71 author: { login: "oliver", name: null, avatarUrl: null },
72 mergeability: "mergeable",
73 checksState: "passing",
74 ...overrides,
75 };
77​
78const observed = (value: PullRequestSummary, observedAt: number) => ({
79 summary: value,
80 observedAt,
81});
82​
83describe("pull request summary cache", () => {
84 it("orders same-dated snapshots by the server's read time, not by arrival", () => {
85 const stale = observed(summary({ observedAt: 200 }), 500);
86 const held = observed(
87 summary({ mergeability: "conflicting", checksState: "failing", observedAt: 300 }),
88 100,
89 );
90 expect(newestPullRequestObservation(stale, held)?.summary).toMatchObject({
91 mergeability: "conflicting",
92 checksState: "failing",
93 observedAt: 300,
94 });
95 expect(newestPullRequestObservation(held, stale)).toBe(held);
96 expect(newestPullRequestObservation(held, { ...held, observedAt: 900 })).toBe(held);
97 });
98​
99 it("uses arrival order only when neither snapshot carries a server read time", () => {
100 const first = observed(summary(), 100);
101 const later = observed(summary({ checksState: "failing" }), 200);
102 expect(newestPullRequestObservation(first, later)).toMatchObject({ observedAt: 200 });
103 expect(newestPullRequestObservation(later, first)).toBe(later);
104 const stamped = observed(summary({ observedAt: 50 }), 1);
105 expect(newestPullRequestObservation(later, stamped)?.summary.observedAt).toBe(50);
106 expect(newestPullRequestObservation(stamped, later)).toBe(stamped);
107 });
108​
109 it("keeps known status when a newer snapshot omits it, and clears it when told to", () => {
110 const list = observed(summary({ reviewDecision: "approved", observedAt: 100 }), 100);
111 const sparse = observed(
112 summary({ checksState: undefined, reviewDecision: undefined, observedAt: 200 }),
113 200,
114 );
115 expect(newestPullRequestObservation(list, sparse)?.summary).toMatchObject({
116 checksState: "passing",
117 reviewDecision: "approved",
118 });
119 const cleared = observed(summary({ checksState: null, observedAt: 300 }), 300);
120 expect(newestPullRequestObservation(list, cleared)?.summary.checksState).toBeNull();
121 });
122​
123 it("treats merged as final and otherwise prefers the later host update", () => {
124 const merged = summary({ state: "merged", updatedAt: "2026-09-01T00:00:00Z" });
125 const reopened = summary({ updatedAt: "2026-09-12T00:00:00Z", observedAt: 900 });
126 expect(newestPullRequestSummary(merged, reopened)).toBe(merged);
127 expect(newestPullRequestSummary(reopened, merged)).toBe(merged);
128 const older = summary({ updatedAt: "2026-09-11T00:00:00Z", observedAt: 999 });
129 expect(newestPullRequestSummary(older, reopened)).toBe(reopened);
130 expect(newestPullRequestSummary(reopened, older)).toBe(reopened);
131 });
132​
133 it("reuses saved thread state while detail is stale and lets newer detail replace it", () => {
134 const stale = summary();
135 const link = {
136 host: "github.com",
137 repository: stale.repository,
138 number: stale.number,
139 url: stale.url,
140 source: "manual" as const,
141 linkedAt: stale.updatedAt,
142 stack: null,
143 snapshot: {
144 ...stale,
145 state: "closed" as const,
146 isDraft: false,
147 updatedAt: "2026-09-11T00:00:00Z",
148 syncedAt: "2026-09-12T00:00:00Z",
149 },
150 };
151 const saved = threadPullRequestLinkToSummary(link, stale.projectId);
152 expect(newestPullRequestSummary(stale, saved)?.state).toBe("closed");
153 const fresh = summary({ isDraft: true, updatedAt: "2026-09-13T00:00:00Z" });
154 expect(newestPullRequestSummary(fresh, saved)).toBe(fresh);
155 expect(threadPullRequestLinkToSummary({ ...link, snapshot: null }, stale.projectId)).toBeNull();
156 expect(
157 threadPullRequestLinkToSummary(
158 { ...link, snapshot: { ...link.snapshot, updatedAt: null } },
159 stale.projectId,
160 ),
161 ).toBeNull();
162 });
163});
164​
165describe("pull request observations", () => {
166 const fromSummary = (value: PullRequestSummary, observedAt: number) =>
167 observePullRequestMetadata({ metadata: null, checks: null }, observed(value, observedAt));
168 const checksRead = (
169 checksState: "passing" | "failing" | "pending" | null,
170 readAt: number | undefined,
171 observedAt: number,
172 ) => ({ checksState, readAt, observedAt });
173​
174 it("keeps a newer list's mergeability when a checks read lands after it", () => {
175 const detail = summary({
176 mergeability: "conflicting",
177 checksState: "pending",
178 observedAt: 100,
179 });
180 let state = fromSummary(detail, 1);
181 state = observePullRequestMetadata(
182 state,
183 observed(summary({ mergeability: "mergeable", checksState: "pending", observedAt: 200 }), 2),
184 );
185 state = observePullRequestChecks(state, checksRead("passing", 300, 3));
186 expect(observedPullRequestSummary(state)).toMatchObject({
187 mergeability: "mergeable",
188 checksState: "passing",
189 });
190 });
191​
192 it("keeps a newer checks read when an older list read lands after it", () => {
193 let state = fromSummary(
194 summary({ mergeability: "conflicting", checksState: "pending", observedAt: 100 }),
195 1,
196 );
197 state = observePullRequestChecks(state, checksRead("passing", 300, 2));
198 state = observePullRequestMetadata(
199 state,
200 observed(summary({ mergeability: "mergeable", checksState: "pending", observedAt: 250 }), 3),
201 );
202 expect(observedPullRequestSummary(state)).toMatchObject({
203 mergeability: "mergeable",
204 checksState: "passing",
205 });
206 });
207​
208 it("lets a newer read's checks replace an older checks read", () => {
209 let state = fromSummary(summary({ checksState: "pending", observedAt: 100 }), 1);
210 state = observePullRequestChecks(state, checksRead("passing", 300, 2));
211 state = observePullRequestMetadata(
212 state,
213 observed(summary({ checksState: "failing", observedAt: 400 }), 3),
214 );
215 expect(observedPullRequestSummary(state)?.checksState).toBe("failing");
216 });
217​
218 it("orders checks by arrival when the server reports no read time", () => {
219 let state = fromSummary(summary({ checksState: "pending" }), 1);
220 state = observePullRequestChecks(state, checksRead("passing", undefined, 2));
221 expect(observedPullRequestSummary(state)?.checksState).toBe("passing");
222 state = observePullRequestChecks(state, checksRead("failing", undefined, 1));
223 expect(observedPullRequestSummary(state)?.checksState).toBe("passing");
224 });
225​
226 it("shows nothing until some read has described the pull request", () => {
227 const state = observePullRequestChecks(
228 { metadata: null, checks: null },
229 checksRead("failing", 1, 1),
230 );
231 expect(observedPullRequestSummary(state)).toBeNull();
232 });
233});
234​
235describe("pull request list answers across a reload", () => {
236 const makeStorage = () => {
237 const held = new Map<string, string>();
238 return {
239 held,
240 getItem: (key: string) => held.get(key) ?? null,
241 setItem: (key: string, value: string) => void held.set(key, value),
242 removeItem: (key: string) => void held.delete(key),
243 key: (index: number) => [...held.keys()][index] ?? null,
244 get length() {
245 return held.size;
246 },
247 };
248 };
249 const listEntry = (number: number, updatedAt = "2026-07-02T00:00:00Z") =>
250 ({
251 provider: SourceControlProviderKind.make("github"),
252 host: "github.com",
253 projectId: ProjectId.make("project-1"),
254 projectTitle: "t3code",
255 repository: "pingdotgg/t3code",
256 number,
257 title: `Pull request ${number}`,
258 url: `https://github.com/pingdotgg/t3code/pull/${number}`,
259 author: { login: "octocat", name: null, avatarUrl: null },
260 headBranch: `feat/branch-${number}`,
261 baseBranch: "main",
262 state: "open",
263 isDraft: false,
264 mergeability: "mergeable",
265 additions: 1,
266 deletions: 0,
267 createdAt: "2026-07-01T00:00:00Z",
268 updatedAt,
269 viewerReviewRequested: false,
270 labels: [],
271 }) as PullRequestListEntry;
272 const answer = (entries: ReadonlyArray<PullRequestListEntry>, host = "github.com") =>
273 ({
274 entries,
275 viewers: { [host]: "Bilal" },
276 providers: [
277 {
278 host,
279 kind: SourceControlProviderKind.make("github"),
280 searchesOnHost: true,
281 projectCount: 1,
282 configured: true,
283 detail: null,
284 },
285 ],
286 errors: [{ projectId: ProjectId.make("project-1"), projectTitle: "t3code", message: "boom" }],
287 truncated: true,
288 nextCursors: { "pingdotgg/t3code": "cursor-1" },
289 }) as PullRequestListResult;
290 const local = EnvironmentId.make("env-local");
291 const remote = EnvironmentId.make("env-remote");
292 const input: PullRequestListInput = { state: "open", involvement: "all", limit: 99 };
293​
294 it("restores every environment's first page as one merged list without stale failures or cursors", () => {
295 const storage = makeStorage();
296 rememberPullRequestListAnswer(
297 storage,
298 { environmentId: local, input },
299 answer([listEntry(1, "2026-07-01T00:00:00Z")]),
300 );
301 rememberPullRequestListAnswer(
302 storage,
303 { environmentId: remote, input },
304 answer([listEntry(2, "2026-07-03T00:00:00Z")], "gitlab.com"),
305 );
306 const restored = restorePullRequestList(storage, [
307 { environmentId: local, input },
308 { environmentId: remote, input },
309 ]);
310 expect(restored?.entries.map((row) => [row.environmentId, row.number])).toEqual([
311 [remote, 2],
312 [local, 1],
313 ]);
314 expect(restored?.viewers).toEqual({
315 "env-local github.com": "Bilal",
316 "env-remote gitlab.com": "Bilal",
317 });
318 expect(restored?.providers.map((provider) => provider.host)).toEqual([
319 "github.com",
320 "gitlab.com",
321 ]);
322 expect(restored?.errors).toEqual([]);
323 expect(restored?.nextCursors).toEqual({});
324 });
325​
326 it("falls back to a fresh read when the browser blocks storage", () => {
327 vi.stubGlobal("window", {
328 get localStorage(): Storage {
329 throw new DOMException("The operation is insecure.", "SecurityError");
330 },
331 });
332 try {
333 expect(restoredPullRequestList([{ environmentId: local, input }])).toBeNull();
334 } finally {
335 vi.unstubAllGlobals();
336 }
337 });
338​
339 it("keeps a page of rows and only first-page reads", () => {
340 const storage = makeStorage();
341 const rows = Array.from({ length: 120 }, (_, index) => listEntry(index + 1));
342 rememberPullRequestListAnswer(storage, { environmentId: local, input }, answer(rows));
343 const searched = { ...input, query: "fix" };
344 rememberPullRequestListAnswer(storage, { environmentId: local, input: searched }, answer(rows));
345 const continued = { ...input, cursors: { "pingdotgg/t3code": "cursor-1" } };
346 rememberPullRequestListAnswer(
347 storage,
348 { environmentId: local, input: continued },
349 answer(rows),
350 );
351 expect(
352 restorePullRequestList(storage, [{ environmentId: local, input }])?.entries,
353 ).toHaveLength(99);
354 expect(restorePullRequestList(storage, [{ environmentId: local, input: searched }])).toBeNull();
355 expect(
356 restorePullRequestList(storage, [{ environmentId: local, input: continued }]),
357 ).toBeNull();
358 });
359​
360 it("restores answers kept under a project assignment for a cold start that has none yet", () => {
361 const storage = makeStorage();
362 rememberPullRequestListAnswer(
363 storage,
364 {
365 environmentId: local,
366 input: { ...input, projectIds: [ProjectId.make("project-1")] },
367 },
368 answer([listEntry(1)]),
369 );
370 rememberPullRequestListAnswer(
371 storage,
372 {
373 environmentId: remote,
374 input: { ...input, projectIds: [ProjectId.make("project-2")] },
375 },
376 answer([listEntry(2, "2026-07-03T00:00:00Z")]),
377 );
378 const restored = restorePullRequestList(storage, [
379 { environmentId: local, input },
380 { environmentId: remote, input },
381 ]);
382 expect(restored?.entries.map((row) => [row.environmentId, row.number])).toEqual([
383 [remote, 2],
384 [local, 1],
385 ]);
386 });
387​
388 it("shows a pull request once when answers from before a reassignment overlap", () => {
389 const storage = makeStorage();
390 rememberPullRequestListAnswer(
391 storage,
392 { environmentId: local, input },
393 answer([listEntry(1, "2026-07-01T00:00:00Z")]),
394 );
395 rememberPullRequestListAnswer(
396 storage,
397 { environmentId: remote, input },
398 answer([listEntry(1, "2026-07-03T00:00:00Z")]),
399 );
400 const restored = restorePullRequestList(storage, [
401 { environmentId: local, input },
402 { environmentId: remote, input },
403 ]);
404 expect(restored?.entries.map((row) => [row.environmentId, row.updatedAt])).toEqual([
405 [remote, "2026-07-03T00:00:00Z"],
406 ]);
407 });
408​
409 it("keeps a shared repository's rows when it moves to an environment that has not answered yet", () => {
410 const storage = makeStorage();
411 const own = ProjectId.make("project-own");
412 const shared = { ...listEntry(2), projectId: ProjectId.make("project-shared") };
413 rememberPullRequestListAnswer(
414 storage,
415 { environmentId: local, input },
416 answer([{ ...listEntry(1), projectId: own }, shared]),
417 );
418 rememberPullRequestListAnswer(
419 storage,
420 { environmentId: remote, input },
421 answer([listEntry(3, "2026-07-03T00:00:00Z")]),
422 );
423 // The shared repository now belongs to the remote environment, which has not answered yet.
424 rememberPullRequestListAnswer(
425 storage,
426 { environmentId: local, input: { ...input, projectIds: [own] } },
427 answer([{ ...listEntry(1), projectId: own }]),
428 );
429 const restored = restorePullRequestList(storage, [
430 { environmentId: local, input },
431 { environmentId: remote, input },
432 ]);
433 expect(restored?.entries.map((row) => row.number).toSorted()).toEqual([1, 2, 3]);
434 });
435​
436 it("keeps one page per environment when retained rows join a partial answer", () => {
437 const storage = makeStorage();
438 const own = ProjectId.make("project-own");
439 const shared = ProjectId.make("project-shared");
440 const rows = (projectId: ProjectId, first: number) =>
441 Array.from({ length: 99 }, (_, index) => ({ ...listEntry(first + index), projectId }));
442 rememberPullRequestListAnswer(
443 storage,
444 { environmentId: local, input },
445 answer(rows(shared, 1)),
446 );
447 rememberPullRequestListAnswer(
448 storage,
449 { environmentId: local, input: { ...input, projectIds: [own] } },
450 answer(rows(own, 101)),
451 );
452 const restored = restorePullRequestList(storage, [{ environmentId: local, input }]);
453 expect(restored?.entries).toHaveLength(99);
454 expect(restored?.entries.every((row) => row.projectId === own)).toBe(true);
455 });
456​
457 it("does not keep grown pages", () => {
458 const storage = makeStorage();
459 const grown = { ...input, limit: 198 };
460 rememberPullRequestListAnswer(storage, { environmentId: local, input: grown }, answer([]));
461 expect(restorePullRequestList(storage, [{ environmentId: local, input: grown }])).toBeNull();
462 });
463​
464 it("forgets whole scopes, never one environment of a scope that is kept", () => {
465 const storage = makeStorage();
466 const scoped = (projectIndex: number) => ({
467 ...input,
468 projectId: ProjectId.make(`p-${projectIndex}`),
469 });
470 for (let projectIndex = 0; projectIndex < 9; projectIndex++) {
471 for (const [offset, environmentId] of [local, remote].entries()) {
472 rememberPullRequestListAnswer(
473 storage,
474 { environmentId, input: scoped(projectIndex) },
475 answer([listEntry(projectIndex * 2 + offset + 1, `2026-07-0${2 - offset}T00:00:00Z`)]),
476 );
477 }
478 }
479 const restore = (projectIndex: number) =>
480 restorePullRequestList(storage, [
481 { environmentId: local, input: scoped(projectIndex) },
482 { environmentId: remote, input: scoped(projectIndex) },
483 ]);
484 expect(restore(0)).toBeNull();
485 for (let projectIndex = 1; projectIndex < 9; projectIndex++) {
486 expect(restore(projectIndex)?.entries.map((row) => row.environmentId)).toEqual([
487 local,
488 remote,
489 ]);
490 }
491 });
492​
493 it("removes the composite snapshots it replaced", () => {
494 const storage = makeStorage();
495 storage.held.set("t3.pullRequests.list:env-local,env-remote", "{}");
496 storage.held.set("t3.pullRequests.list:env-local", "{}");
497 storage.held.set("t3.other", "kept");
498 rememberPullRequestListAnswer(storage, { environmentId: local, input }, answer([]));
499 expect(
500 [...storage.held.keys()].filter((key) => key.startsWith("t3.pullRequests.list:")),
501 ).toEqual([]);
502 expect(storage.held.get("t3.other")).toBe("kept");
503 });
504});