1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeV8 from "node:v8";
4import * as NodeVM from "node:vm";
5import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
6import * as KeyValueStore from "effect/persistence/KeyValueStore";
7import { assert, it } from "@effect/vitest";
8import * as Cause from "effect/Cause";
9import * as Clock from "effect/Clock";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Redacted from "effect/Redacted";
17import * as Stream from "effect/Stream";
18import { HttpClient, HttpClientResponse } from "effect/http";
19import * as TestClock from "effect/testing/TestClock";
20import type {
21 OrchestrationProjectShell,
22 ProjectId,
23 PullRequestReviewCapabilities,
24 PullRequestReviewerCapabilities,
25} from "@t3tools/contracts";
26import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings";
27import type { ServerSettings } from "@t3tools/contracts";
28import * as ServerSettingsService from "../serverSettings.ts";
29import { PullRequestOperationError } from "@t3tools/contracts";
30
31import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
32import * as ProjectService from "../project/ProjectService.ts";
33import * as SqlitePersistence from "../persistence/Sqlite.ts";
34import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
35import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
36import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
37import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
38import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
39import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
40import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
41import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
42import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
43import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider";
44import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota";
45import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
46import {
47 PullRequestProviderError,
48 type ProviderChangeRequest,
49 type PullRequestProviderApi,
50} from "@t3tools/source-control-core/server/PullRequestProvider";
51import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts";
52import * as PullRequestService from "./PullRequestService.ts";
53import * as PullRequestReadCache from "./PullRequestReadCache.ts";
54import {
55 FILE_REVISIONS_CACHE_CAPACITY,
56 MAX_FILE_REVISION_PATHS,
57} from "./pullRequestViewedFiles.ts";
58
59function project(input: {
60 readonly id: string;
61 readonly title: string;
62 readonly workspaceRoot: string;
63 readonly repository?: string;
64 readonly provider?: string;
65 readonly host?: string;
66 readonly remoteUrl?: string;
67}): OrchestrationProjectShell {
68 // The host defaults from the provider, so a fixture only names one when the point of the
69 // test is two hosts of the same kind.
70 const host = input.host ?? (input.provider === "gitlab" ? "gitlab.com" : "github.com");
71 return {
72 id: input.id as ProjectId,
73 title: input.title,
74 workspaceRoot: input.workspaceRoot,
75 ...(input.repository
76 ? {
77 repositoryIdentity: {
78 canonicalKey: `${host}/${input.repository}`,
79 locator: {
80 source: "git-remote" as const,
81 remoteName: "origin",
82 remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`,
83 },
84 provider: input.provider ?? "github",
85 displayName: input.repository,
86 },
87 }
88 : {}),
89 defaultModelSelection: null,
90 scripts: [],
91 createdAt: "2026-07-01T00:00:00Z",
92 updatedAt: "2026-07-01T00:00:00Z",
93 };
94}
95
96function changeRequest(number: number, updatedAt: string): ProviderChangeRequest {
97 return {
98 number,
99 title: `Change request ${number}`,
100 url: `https://host/pull/${number}`,
101 author: { login: "octocat", name: null, avatarUrl: null },
102 headBranch: `feat/${number}`,
103 baseBranch: "main",
104 state: "open",
105 isDraft: false,
106 mergeability: "mergeable",
107 additions: 1,
108 deletions: 0,
109 createdAt: "2026-07-01T00:00:00Z",
110 updatedAt,
111 reviewRequestLogins: [],
112 labels: [],
113 };
114}
115
116function hostedChangeRequest(body: string, additions = 1) {
117 return {
118 ...changeRequest(1, "2026-07-02T00:00:00Z"),
119 body,
120 additions,
121 changedFiles: 2,
122 mergedAt: null,
123 closedAt: null,
124 reviewers: [],
125 checks: [],
126 mergeCapabilities: { merge: true, squash: true, rebase: true },
127 viewerPermissions: {
128 actions: ["merge"] as const,
129 comment: true,
130 resolve: true,
131 verdicts: ["comment", "approve", "request-changes"] as const,
132 requestReviewers: true,
133 },
134 };
135}
136
137it.effect("caches narrow previews and invalidates them after refresh or mutation", () =>
138 Effect.gen(function* () {
139 let reads = 0;
140 const service = yield* makeService({
141 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
142 providers: [
143 fakeProvider(SourceControlProviderKind.make("github"), {
144 getViewer: () => Effect.die("preview must not read the viewer"),
145 getChangeRequestPreview: () =>
146 Effect.sync(() => {
147 reads += 1;
148 return changeRequest(1, "2026-07-02T00:00:00Z");
149 }),
150 }),
151 ],
152 });
153 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
154 const previews = yield* Effect.all([service.preview(ref), service.preview(ref)], {
155 concurrency: 2,
156 });
157 assert.deepStrictEqual(previews[0], {
158 ...ref,
159 title: "Change request 1",
160 url: "https://host/pull/1",
161 author: { login: "octocat", name: null, avatarUrl: null },
162 state: "open",
163 isDraft: false,
164 createdAt: "2026-07-01T00:00:00Z",
165 });
166 assert.strictEqual(reads, 1);
167 yield* service.invalidate({ reference: ref });
168 yield* service.preview(ref);
169 assert.strictEqual(reads, 2);
170 yield* service.runAction({ ...ref, action: "close" });
171 yield* service.preview(ref);
172 assert.strictEqual(reads, 3);
173 yield* service.refreshAfterTurn(ref.projectId);
174 yield* service.preview(ref);
175 assert.strictEqual(reads, 4);
176 const error = yield* Effect.flip(service.preview({ ...ref, repository: "another/repo" }));
177 assert.strictEqual(error._tag, "PullRequestOperationError");
178 assert.strictEqual(reads, 4);
179 }),
180);
181
182it.effect("keeps cached previews available and pauses uncached previews until quota resets", () =>
183 Effect.gen(function* () {
184 let reads = 0;
185 const retryAt = Date.parse("2099-08-13T14:00:00Z");
186 const service = yield* makeService({
187 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
188 providers: [
189 fakeProvider(SourceControlProviderKind.make("github"), {
190 getChangeRequestPreview: ({ number }) =>
191 Effect.gen(function* () {
192 reads++;
193 if (reads === 2)
194 return yield* new PullRequestProviderError({
195 provider: SourceControlProviderKind.make("github"),
196 operation: "getChangeRequestPreview",
197 reason: "rate-limited",
198 detail: "GitHub requests are paused until the rate limit resets.",
199 retryAt,
200 });
201 return changeRequest(number, "2026-07-02T00:00:00Z");
202 }),
203 }),
204 ],
205 });
206 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
207 yield* service.preview(ref);
208 const limited = yield* Effect.flip(service.preview({ ...ref, number: 2 }));
209 assert.instanceOf(limited, PullRequestOperationError);
210 if (limited._tag !== "PullRequestOperationError") return;
211 assert.include(limited.detail, "paused");
212 assert.strictEqual((yield* service.preview(ref)).number, 1);
213 for (const number of [2, 3, 4]) {
214 const paused = yield* Effect.flip(service.preview({ ...ref, number }));
215 assert.instanceOf(paused, PullRequestOperationError);
216 if (paused._tag !== "PullRequestOperationError") return;
217 assert.include(paused.detail, "paused");
218 }
219 assert.strictEqual(reads, 2);
220 yield* TestClock.setTime(retryAt);
221 assert.strictEqual((yield* service.preview({ ...ref, number: 2 })).number, 2);
222 assert.strictEqual(reads, 3);
223 }),
224);
225
226it.effect("reuses only unexpired detail for previews", () =>
227 Effect.gen(function* () {
228 let previewReads = 0;
229 const service = yield* makeService({
230 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
231 providers: [
232 fakeProvider(SourceControlProviderKind.make("github"), {
233 getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")),
234 getChangeRequestPreview: () =>
235 Effect.sync(() => {
236 previewReads++;
237 return {
238 ...changeRequest(1, "2026-07-02T00:00:00Z"),
239 title: "Updated title",
240 state: "closed" as const,
241 };
242 }),
243 }),
244 ],
245 });
246 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
247 yield* service.detail(ref);
248 assert.strictEqual((yield* service.preview(ref)).title, "Change request 1");
249 assert.strictEqual(previewReads, 0);
250 yield* TestClock.adjust("61 seconds");
251 const preview = yield* service.preview(ref);
252 assert.strictEqual(preview.title, "Updated title");
253 assert.strictEqual(preview.state, "closed");
254 assert.strictEqual(previewReads, 1);
255 }),
256);
257
258it.effect("shares detail and activity for a minute, and for ten once merged", () =>
259 Effect.gen(function* () {
260 const reads: Array<string> = [];
261 const service = yield* makeService({
262 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
263 providers: [
264 fakeProvider(SourceControlProviderKind.make("github"), {
265 getChangeRequest: ({ number }) =>
266 Effect.sync(() => {
267 reads.push(`detail #${number}`);
268 return {
269 ...hostedChangeRequest("Description"),
270 number,
271 state:
272 number === 1
273 ? ("open" as const)
274 : number === 2
275 ? ("merged" as const)
276 : ("closed" as const),
277 };
278 }),
279 getChangeRequestActivity: ({ number }) =>
280 Effect.sync(() => {
281 reads.push(`activity #${number}`);
282 return {
283 comments: [],
284 commentCount: 0,
285 commentsTruncated: false,
286 reviewThreads: [],
287 commits: [],
288 };
289 }),
290 }),
291 ],
292 });
293 // What every client does on focus or a refresh signal for each panel it shows.
294 // #1 is open, #2 merged, and #3 closed, which can reopen and so is held no longer than #1.
295 const reread = Effect.forEach([1, 2, 3], (number) => {
296 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number };
297 return Effect.andThen(service.detail(ref), service.activity(ref));
298 }).pipe(
299 Effect.andThen(Effect.yieldNow),
300 Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())),
301 );
302
303 const everything = [
304 "activity #1",
305 "activity #2",
306 "activity #3",
307 "detail #1",
308 "detail #2",
309 "detail #3",
310 ];
311 assert.deepStrictEqual(yield* reread, everything);
312 yield* TestClock.adjust("59 seconds");
313 assert.deepStrictEqual(yield* reread, []);
314 yield* TestClock.adjust("2 seconds");
315 assert.deepStrictEqual(yield* reread, ["activity #1", "activity #3", "detail #1", "detail #3"]);
316 yield* TestClock.adjust("9 minutes");
317 assert.deepStrictEqual(yield* reread, everything);
318 }),
319);
320
321it.effect("re-reads an open pull request after a turn, but not a merged one", () =>
322 Effect.gen(function* () {
323 const reads: Array<string> = [];
324 const service = yield* makeService({
325 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
326 providers: [
327 fakeProvider(SourceControlProviderKind.make("github"), {
328 getChangeRequest: ({ number }) =>
329 Effect.sync(() => {
330 reads.push(`detail #${number}`);
331 return {
332 ...hostedChangeRequest("Description"),
333 number,
334 state: number === 1 ? ("open" as const) : ("merged" as const),
335 };
336 }),
337 getChangeRequestActivity: ({ number }) =>
338 Effect.sync(() => {
339 reads.push(`activity #${number}`);
340 return {
341 comments: [],
342 commentCount: 0,
343 commentsTruncated: false,
344 reviewThreads: [],
345 commits: [],
346 };
347 }),
348 }),
349 ],
350 });
351 // What every client showing #1 (open) and #2 (merged) does on each refresh signal.
352 const merged = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 };
353 const reread = Effect.forEach([1, 2], (number) => {
354 const ref = { ...merged, number };
355 return Effect.andThen(service.detail(ref), service.activity(ref));
356 }).pipe(
357 Effect.andThen(Effect.yieldNow),
358 Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())),
359 );
360
361 assert.deepStrictEqual(yield* reread, ["activity #1", "activity #2", "detail #1", "detail #2"]);
362 for (let turn = 0; turn < 3; turn++) {
363 yield* service.refreshAfterTurn(merged.projectId);
364 assert.deepStrictEqual(yield* reread, ["activity #1", "detail #1"]);
365 }
366 // The refresh button still reads a merged pull request again.
367 yield* service.invalidate({ reference: merged });
368 assert.deepStrictEqual(yield* reread, ["activity #2", "detail #2"]);
369 }),
370);
371
372it.effect("does not wait for an in-flight detail read to display a preview", () =>
373 Effect.gen(function* () {
374 const detailStarted = yield* Deferred.make<void>();
375 const releaseDetail = yield* Deferred.make<void>();
376 const service = yield* makeService({
377 projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
378 providers: [
379 fakeProvider(SourceControlProviderKind.make("github"), {
380 getChangeRequest: () =>
381 Deferred.succeed(detailStarted, undefined).pipe(
382 Effect.andThen(Deferred.await(releaseDetail)),
383 Effect.as(hostedChangeRequest("Description")),
384 ),
385 getChangeRequestPreview: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
386 }),
387 ],
388 });
389 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
390 const detail = yield* Effect.forkChild(service.detail(ref));
391 yield* Deferred.await(detailStarted);
392 assert.strictEqual((yield* service.preview(ref)).title, "Change request 1");
393 yield* Deferred.succeed(releaseDetail, undefined);
394 yield* Fiber.join(detail);
395 }),
396);
397
398it.effect("keeps previews warm when another project finishes a turn", () =>
399 Effect.gen(function* () {
400 const reads: string[] = [];
401 const service = yield* makeService({
402 projects: [
403 project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }),
404 project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }),
405 ],
406 providers: [
407 fakeProvider(SourceControlProviderKind.make("github"), {
408 getChangeRequestPreview: (input) =>
409 Effect.sync(() => {
410 reads.push(input.repository);
411 return changeRequest(1, "2026-07-02T00:00:00Z");
412 }),
413 }),
414 ],
415 });
416 const web = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
417 const docs = { projectId: "p2" as ProjectId, repository: "acme/docs", number: 1 };
418 yield* service.preview(web);
419 yield* service.preview(docs);
420 yield* service.preview({ ...web, host: "github.com" });
421 assert.deepStrictEqual(reads, ["acme/web", "acme/docs"]);
422 yield* service.refreshAfterTurn(web.projectId);
423 yield* service.preview(web);
424 yield* service.preview(docs);
425 assert.deepStrictEqual(reads, ["acme/web", "acme/docs", "acme/web"]);
426 }),
427);
428
429it.effect("uses full detail for hosts without a narrow preview", () =>
430 Effect.gen(function* () {
431 let reads = 0;
432 const service = yield* makeService({
433 projects: [
434 project({
435 id: "p1",
436 title: "web",
437 workspaceRoot: "/w",
438 repository: "acme/web",
439 provider: SourceControlProviderKind.make("gitlab"),
440 }),
441 ],
442 providers: [
443 fakeProvider(SourceControlProviderKind.make("gitlab"), {
444 getChangeRequest: () =>
445 Effect.sync(() => {
446 reads += 1;
447 return hostedChangeRequest("Description");
448 }),
449 }),
450 ],
451 });
452 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
453 const preview = yield* service.preview(ref);
454 assert.strictEqual(preview.title, "Change request 1");
455 assert.strictEqual(reads, 1);
456 assert.ok(!("body" in preview));
457 }),
458);
459
460function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") {
461 return new PullRequestProviderError({
462 provider,
463 operation: "getViewer",
464 reason,
465 detail: `${provider} is not usable.`,
466 });
467}
468
469const requestFailed = new PullRequestProviderError({
470 provider: SourceControlProviderKind.make("github"),
471 operation: "listChangeRequests",
472 reason: "failed",
473 detail: "HTTP 404",
474});
475
476/** Everything a host could offer, so a fixture only narrows what its own test is about. */
477const FULL_REVIEW: PullRequestReviewCapabilities = {
478 inlineComment: true,
479 reply: true,
480 resolve: true,
481 verdicts: ["comment", "approve", "request-changes"],
482};
483
484const FULL_REVIEWERS: PullRequestReviewerCapabilities = { request: true, listCandidates: true };
485
486/** A provider whose every call is supplied by the test; anything unset succeeds emptily. */
487function fakeProvider(
488 kind: SourceControlProviderKind,
489 overrides: Partial<PullRequestProviderApi> = {},
490): PullRequestProviderApi {
491 return {
492 kind,
493 capabilities: {
494 diff: true,
495 comment: true,
496 actions: ["merge", "ready", "draft", "close", "reopen"],
497 mergeMethods: ["merge"],
498 search: true,
499 reactions: true,
500 review: FULL_REVIEW,
501 reviewers: FULL_REVIEWERS,
502 edit: { changeRequest: true, comment: true },
503 },
504 getViewer: () => Effect.succeed("bilal"),
505 // A viewer who may do everything the host can, so a test only narrows what it is about.
506 getViewerPermissions: () =>
507 Effect.succeed({
508 actions: ["merge", "ready", "draft", "close", "reopen"],
509 comment: true,
510 resolve: true,
511 verdicts: ["comment", "approve", "request-changes"],
512 requestReviewers: true,
513 }),
514 listChangeRequests: () => Effect.succeed({ items: [], truncated: false, continues: true }),
515 getChangeRequest: () => Effect.die("unused"),
516 getChangeRequestActivity: () => Effect.die("unused"),
517 getDiff: () => Effect.die("unused"),
518 runAction: () => Effect.void,
519 updateChangeRequest: () => Effect.void,
520 comment: () => Effect.void,
521 updateComment: () => Effect.void,
522 submitReview: () => Effect.void,
523 replyToThread: () => Effect.void,
524 setThreadResolution: () => Effect.void,
525 setReaction: () => Effect.void,
526 listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }),
527 setReviewerRequest: () => Effect.void,
528 // The hosts' own resolvers, which the service reads instead of the kind.
529 ...(kind === "github" ? { mergeMessageRewrite: (message: string) => message } : {}),
530 ...(kind === "azure-devops"
531 ? { repositoryKey: ({ canonicalKey }: { readonly canonicalKey: string }) => canonicalKey }
532 : {}),
533 ...overrides,
534 };
535}
536
537function makeService(input: {
538 readonly mergebot?: MergebotQueue.MergebotQueue["Service"];
539 readonly settings?: ServerSettings;
540 readonly projects: ReadonlyArray<OrchestrationProjectShell>;
541 readonly providers: ReadonlyArray<PullRequestProviderApi>;
542 readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];
543 readonly resolveRepositoryIdentity?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"];
544}) {
545 // Built into the test's own scope rather than provided call by call: the marks store owns a
546 // database, and `Effect.provide` would close it the moment the service was handed back.
547 return Effect.flatMap(
548 Layer.build(
549 Layer.mergeAll(
550 input.mergebot
551 ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot)
552 : Layer.mock(MergebotQueue.MergebotQueue)({
553 configuration: null,
554 streamChanges: Stream.empty,
555 }),
556 Layer.mock(ServerSettingsService.ServerSettingsService)({
557 getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS),
558 }),
559 Layer.succeed(
560 PullRequestProviderRegistry.PullRequestProviderRegistry,
561 PullRequestProviderRegistry.fromProviders(input.providers),
562 ),
563 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
564 resolveLink: () => undefined,
565 resolveHandle:
566 input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")),
567 }),
568 Layer.mock(ProjectService.ProjectService)({
569 listShells: (options) =>
570 Effect.succeed(
571 input.projects.filter((project) => options?.projectIds?.includes(project.id) ?? true),
572 ),
573 getShell: (projectId) =>
574 Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))),
575 }),
576 Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
577 resolve: input.resolveRepositoryIdentity ?? (() => Effect.succeed(null)),
578 }),
579 SourceControlRateLimit.layer,
580 // The real store over a database of its own, so the environment-kept marks are exercised
581 // through the SQL that holds them rather than through a stand-in that agrees with itself.
582 PullRequestFilesViewed.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
583 Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe(
584 Layer.provide(KeyValueStore.layerMemory),
585 Layer.provide(NodeServices.layer),
586 ),
587 ),
588 ),
589 (context) => Effect.provideContext(PullRequestService.make, context),
590 );
591}
592
593it.effect("lists GitHub Enterprise PRs for a stored unknown repository after host discovery", () =>
594 Effect.gen(function* () {
595 const service = yield* makeService({
596 projects: [
597 project({
598 id: "p1",
599 title: "enterprise",
600 workspaceRoot: "/repo",
601 repository: "team/project",
602 provider: SourceControlProviderKind.make("unknown"),
603 host: "code.example.test",
604 }),
605 ],
606 providers: [
607 fakeProvider(SourceControlProviderKind.make("github"), {
608 /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */
609 listChangeRequests: ({ host, repository }) => {
610 assert.strictEqual(host, "code.example.test");
611 assert.strictEqual(repository, "team/project");
612 return Effect.succeed({
613 items: [changeRequest(42, "2026-07-05T00:00:00Z")],
614 truncated: false,
615 continues: false,
616 });
617 },
618 }),
619 ],
620 /** Stands in for discovery claiming the custom host as GitHub. */
621 resolveHandle: ({ context }) => {
622 assert.ok(context);
623 return Effect.succeed({
624 context: {
625 ...context,
626 provider: {
627 ...context.provider,
628 kind: SourceControlProviderKind.make("github"),
629 name: "GitHub Self-Hosted",
630 },
631 },
632 provider: undefined as never,
633 });
634 },
635 });
636 const result = yield* service.list({ state: "open" });
637 assert.deepStrictEqual(
638 result.entries.map(({ host, number }) => [host, number]),
639 [["code.example.test", 42]],
640 );
641 }),
642);
643
644it.effect("reports each host's actions with its summary, so rows can offer them unread", () =>
645 Effect.gen(function* () {
646 const service = yield* makeService({
647 projects: [
648 project({
649 id: "p1",
650 title: "web",
651 workspaceRoot: "/repo",
652 repository: "acme/web",
653 provider: SourceControlProviderKind.make("bitbucket"),
654 host: "bitbucket.org",
655 }),
656 ],
657 providers: [
658 fakeProvider(SourceControlProviderKind.make("bitbucket"), {
659 capabilities: {
660 ...fakeProvider(SourceControlProviderKind.make("bitbucket")).capabilities,
661 actions: ["merge", "close"],
662 },
663 }),
664 ],
665 });
666
667 const result = yield* service.list({ state: "open" });
668
669 assert.deepStrictEqual(result.providers[0]?.actions, ["merge", "close"]);
670 }),
671);
672
673it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
674 Effect.gen(function* () {
675 let refinementCalls = 0;
676 const selfHosted = project({
677 id: "p1",
678 title: "self-hosted",
679 workspaceRoot: "/gitlab",
680 repository: "group/project",
681 provider: SourceControlProviderKind.make("unknown"),
682 host: "code.example.test",
683 });
684 const service = yield* makeService({
685 projects: [
686 selfHosted,
687 { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" },
688 ],
689 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
690 resolveHandle: ({ context }) => {
691 refinementCalls += 1;
692 assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git");
693 return Effect.succeed({
694 context: {
695 ...context!,
696 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
697 },
698 provider: undefined as never,
699 });
700 },
701 });
702
703 const result = yield* service.list({ state: "open" });
704
705 assert.strictEqual(refinementCalls, 1);
706 assert.strictEqual(result.providers[0]?.host, "code.example.test");
707 assert.strictEqual(result.providers[0]?.kind, "gitlab");
708 }),
709);
710
711it.effect("derives a legacy repository host after refining its provider", () =>
712 Effect.gen(function* () {
713 const current = project({
714 id: "p1",
715 title: "legacy self-hosted",
716 workspaceRoot: "/gitlab",
717 repository: "group/project",
718 provider: SourceControlProviderKind.make("unknown"),
719 host: "code.example.test",
720 });
721 const identity = current.repositoryIdentity!;
722 // Persisted identities from before canonicalKey existed are still accepted at runtime.
723 const legacy = {
724 ...current,
725 repositoryIdentity: {
726 locator: identity.locator,
727 provider: identity.provider,
728 displayName: identity.displayName,
729 },
730 } as unknown as OrchestrationProjectShell;
731 const service = yield* makeService({
732 projects: [legacy],
733 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
734 resolveHandle: ({ context }) =>
735 Effect.succeed({
736 context: {
737 ...context!,
738 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
739 },
740 provider: undefined as never,
741 }),
742 });
743
744 const result = yield* service.list({ state: "open", host: "gitlab" });
745
746 assert.strictEqual(result.providers[0]?.host, "gitlab");
747 assert.strictEqual(result.providers[0]?.kind, "gitlab");
748 }),
749);
750
751it.effect("tries another checkout when provider refinement remains unknown", () =>
752 Effect.gen(function* () {
753 const asked: string[] = [];
754 const selfHosted = project({
755 id: "p1",
756 title: "self-hosted",
757 workspaceRoot: "/gone",
758 repository: "group/project",
759 provider: SourceControlProviderKind.make("unknown"),
760 host: "code.example.test",
761 });
762 const service = yield* makeService({
763 projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }],
764 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
765 resolveHandle: ({ cwd, context }) => {
766 asked.push(cwd);
767 return cwd === "/gone"
768 ? Effect.succeed({ context: context!, provider: undefined as never })
769 : Effect.succeed({
770 context: {
771 ...context!,
772 provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") },
773 },
774 provider: undefined as never,
775 });
776 },
777 });
778
779 const result = yield* service.list({ state: "open" });
780
781 assert.deepStrictEqual(asked, ["/gone", "/healthy"]);
782 assert.strictEqual(result.providers[0]?.kind, "gitlab");
783 }),
784);
785
786/** A row as a host that reads several repositories at once hands it over. */
787function batchedChangeRequest(number: number, repository: string, updatedAt: string) {
788 return { ...changeRequest(number, updatedAt), repository };
789}
790
791it.effect("reads nothing from a host with no implementation, but reports it", () =>
792 Effect.gen(function* () {
793 const listed: string[] = [];
794 const service = yield* makeService({
795 projects: [
796 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
797 project({ id: "p2", title: "notes", workspaceRoot: "/b" }),
798 project({
799 id: "p3",
800 title: "on gitlab",
801 workspaceRoot: "/c",
802 repository: "group/project",
803 provider: SourceControlProviderKind.make("gitlab"),
804 }),
805 ],
806 providers: [
807 fakeProvider(SourceControlProviderKind.make("github"), {
808 listChangeRequests: (input) => {
809 listed.push(input.repository);
810 return Effect.succeed({
811 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
812 truncated: false,
813 continues: true,
814 });
815 },
816 }),
817 ],
818 });
819
820 const result = yield* service.list({ state: "open" });
821
822 assert.deepStrictEqual(listed, ["pingdotgg/t3code"]);
823 assert.strictEqual(result.entries[0]?.provider, "github");
824 // The GitLab project is explained rather than quietly missing from the page.
825 assert.deepStrictEqual(
826 result.providers.map((summary) => ({
827 kind: summary.kind,
828 configured: summary.configured,
829 projectCount: summary.projectCount,
830 })),
831 [
832 { kind: SourceControlProviderKind.make("github"), configured: true, projectCount: 1 },
833 { kind: SourceControlProviderKind.make("gitlab"), configured: false, projectCount: 1 },
834 ],
835 );
836 }),
837);
838
839it.effect("asks for a whole page of a host, and for the reader's own size when given one", () =>
840 Effect.gen(function* () {
841 const limits: number[] = [];
842 const service = yield* makeService({
843 projects: [
844 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
845 ],
846 providers: [
847 fakeProvider(SourceControlProviderKind.make("github"), {
848 listChangeRequests: (input) => {
849 limits.push(input.limit);
850 return Effect.succeed({ items: [], truncated: false, continues: true });
851 },
852 }),
853 ],
854 });
855
856 yield* service.list({ state: "open" });
857 yield* service.list({ state: "open", limit: 10 });
858
859 // Providers probe with one row over this, so 99 asks a host for 100 — the most GitHub and
860 // GitLab serve in one request. 100 here would cost a second round trip for a single row.
861 assert.deepStrictEqual(limits, [99, 10]);
862 }),
863);
864
865it.effect("says where each repository carries on, and from nothing it has run out of", () =>
866 Effect.gen(function* () {
867 const service = yield* makeService({
868 projects: [
869 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
870 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
871 ],
872 providers: [
873 fakeProvider(SourceControlProviderKind.make("github"), {
874 listChangeRequests: ({ repository }) =>
875 Effect.succeed({
876 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
877 truncated: repository === "pingdotgg/t3code",
878 continues: true,
879 }),
880 }),
881 ],
882 });
883
884 const result = yield* service.list({ state: "open" });
885
886 // The instant of the oldest row, how many rows have gone, and the row already sent at that
887 // instant. The repository that had nothing more is simply not in it.
888 assert.deepStrictEqual(result.nextCursors, {
889 "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|1",
890 });
891 }),
892);
893
894it.effect("offers no continuation for a host that cannot be carried on from", () =>
895 Effect.gen(function* () {
896 const service = yield* makeService({
897 projects: [
898 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
899 ],
900 providers: [
901 fakeProvider(SourceControlProviderKind.make("github"), {
902 listChangeRequests: () =>
903 Effect.succeed({
904 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
905 truncated: true,
906 continues: false,
907 }),
908 }),
909 ],
910 });
911
912 const result = yield* service.list({ state: "open" });
913
914 // More rows exist and no cursor reaches them, which is what asking for a larger page is for.
915 assert.isTrue(result.truncated);
916 assert.deepStrictEqual(result.nextCursors, {});
917 }),
918);
919
920it.effect("uses a provider's raw cursor advance when it consumed malformed rows", () =>
921 Effect.gen(function* () {
922 const service = yield* makeService({
923 projects: [
924 project({
925 id: "p1",
926 title: "web",
927 workspaceRoot: "/a",
928 repository: "acme/web",
929 provider: SourceControlProviderKind.make("azure-devops"),
930 host: "dev.azure.com",
931 }),
932 ],
933 providers: [
934 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
935 listChangeRequests: () =>
936 Effect.succeed({
937 items: [changeRequest(7, "2026-07-02T00:00:00Z")],
938 truncated: true,
939 cursorAdvance: 4,
940 continues: true,
941 }),
942 }),
943 ],
944 });
945
946 const result = yield* service.list({ state: "open" });
947
948 // Keyed by the selector Azure is actually asked with, which is the repository's own name.
949 assert.deepStrictEqual(result.nextCursors, {
950 "dev.azure.com dev.azure.com/acme/web": "2026-07-02T00:00:00Z|4|7",
951 });
952 }),
953);
954
955it.effect("reads only the repositories it was asked to carry on with", () =>
956 Effect.gen(function* () {
957 const listed: string[] = [];
958 const cursors: Array<unknown> = [];
959 const service = yield* makeService({
960 projects: [
961 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
962 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
963 ],
964 providers: [
965 fakeProvider(SourceControlProviderKind.make("github"), {
966 listChangeRequests: (input) => {
967 listed.push(input.repository);
968 cursors.push(input.cursor);
969 return Effect.succeed({ items: [], truncated: false, continues: true });
970 },
971 }),
972 ],
973 });
974
975 const result = yield* service.list({
976 state: "open",
977 cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|99|7" },
978 });
979
980 // The other repository is already on the page, and reading it again is the whole cost this
981 // is here to avoid. The host summaries stay over the workspace, because the switcher they
982 // fill is about the workspace rather than about this slice.
983 assert.deepStrictEqual(listed, ["acme/web"]);
984 assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 99 }]);
985 assert.strictEqual(result.providers.length, 1);
986 }),
987);
988
989it.effect("keeps a row already sent at the boundary instant from arriving twice", () =>
990 Effect.gen(function* () {
991 const service = yield* makeService({
992 projects: [
993 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
994 ],
995 providers: [
996 fakeProvider(SourceControlProviderKind.make("github"), {
997 // The boundary instant is asked for inclusively, so the host hands back the rows
998 // already sent at it alongside the ones beside them — which a strictly-older read
999 // would have lost instead.
1000 listChangeRequests: () =>
1001 Effect.succeed({
1002 items: [
1003 changeRequest(7, "2026-07-02T00:00:00Z"),
1004 changeRequest(8, "2026-07-02T00:00:00Z"),
1005 changeRequest(9, "2026-07-01T00:00:00Z"),
1006 ],
1007 truncated: true,
1008 continues: true,
1009 }),
1010 }),
1011 ],
1012 });
1013
1014 const result = yield* service.list({
1015 state: "open",
1016 cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|7" },
1017 });
1018
1019 assert.deepStrictEqual(
1020 result.entries.map((entry) => entry.number),
1021 [8, 9],
1022 );
1023 assert.deepStrictEqual(result.nextCursors, {
1024 "github.com pingdotgg/t3code": "2026-07-01T00:00:00Z|3|9",
1025 });
1026 }),
1027);
1028
1029it.effect("keeps the earlier exclusions when a slice ends on the instant it began on", () =>
1030 Effect.gen(function* () {
1031 const service = yield* makeService({
1032 projects: [
1033 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1034 ],
1035 providers: [
1036 fakeProvider(SourceControlProviderKind.make("github"), {
1037 listChangeRequests: () =>
1038 Effect.succeed({
1039 items: [
1040 changeRequest(7, "2026-07-02T00:00:00Z"),
1041 changeRequest(8, "2026-07-02T00:00:00Z"),
1042 ],
1043 truncated: true,
1044 continues: true,
1045 }),
1046 }),
1047 ],
1048 });
1049
1050 const result = yield* service.list({
1051 state: "open",
1052 cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|6" },
1053 });
1054
1055 // Eight rows can share one second, so a whole slice inside one is ordinary. The next read
1056 // has to keep excluding 6 as well as the two just sent, or it hands 6 over again.
1057 assert.deepStrictEqual(
1058 result.entries.map((entry) => entry.number),
1059 [7, 8],
1060 );
1061 assert.deepStrictEqual(result.nextCursors, {
1062 "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|3|6,7,8",
1063 });
1064 }),
1065);
1066
1067it.effect("refuses a continuation it did not issue, before asking any host anything", () =>
1068 Effect.gen(function* () {
1069 const service = yield* makeService({
1070 projects: [
1071 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1072 ],
1073 providers: [
1074 fakeProvider(SourceControlProviderKind.make("github"), {
1075 listChangeRequests: () => Effect.die("should not be read"),
1076 }),
1077 ],
1078 });
1079
1080 const error = yield* Effect.flip(
1081 service.list({ state: "open", cursors: { "github.com pingdotgg/t3code": "yesterday" } }),
1082 );
1083
1084 assert.strictEqual(error._tag, "PullRequestOperationError");
1085 assert.strictEqual(
1086 error.message,
1087 "Pull request operation list failed: The list could not be carried on from where it left off.",
1088 );
1089 }),
1090);
1091
1092it.effect("calls a transient viewer failure a failed operation, not a signed-out CLI", () =>
1093 Effect.gen(function* () {
1094 const service = yield* makeService({
1095 projects: [
1096 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1097 ],
1098 providers: [
1099 fakeProvider(SourceControlProviderKind.make("github"), {
1100 getViewer: () =>
1101 Effect.fail(
1102 new PullRequestProviderError({
1103 provider: SourceControlProviderKind.make("github"),
1104 operation: "getViewer",
1105 reason: "failed",
1106 detail: "HTTP 500",
1107 }),
1108 ),
1109 }),
1110 ],
1111 });
1112
1113 const error = yield* Effect.flip(service.list({ state: "open" }));
1114
1115 // `cli-unauthenticated` would send the reader to `gh auth login` over a transient error.
1116 assert.strictEqual(error._tag, "PullRequestOperationError");
1117 }),
1118);
1119
1120it.effect("reports an unusable host over a merely failing one", () =>
1121 Effect.gen(function* () {
1122 const service = yield* makeService({
1123 projects: [
1124 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1125 project({
1126 id: "p2",
1127 title: "on gitlab",
1128 workspaceRoot: "/c",
1129 repository: "group/project",
1130 provider: SourceControlProviderKind.make("gitlab"),
1131 }),
1132 ],
1133 providers: [
1134 fakeProvider(SourceControlProviderKind.make("github"), {
1135 getViewer: () =>
1136 Effect.fail(
1137 new PullRequestProviderError({
1138 provider: SourceControlProviderKind.make("github"),
1139 operation: "getViewer",
1140 reason: "failed",
1141 detail: "HTTP 500",
1142 }),
1143 ),
1144 }),
1145 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1146 getViewer: () =>
1147 Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")),
1148 }),
1149 ],
1150 });
1151
1152 const error = yield* Effect.flip(service.list({ state: "open" }));
1153
1154 assert.strictEqual(error._tag, "PullRequestUnavailableError");
1155 assert.strictEqual(error.message.includes("glab"), true);
1156 }),
1157);
1158
1159it.effect("lists every host that has an implementation", () =>
1160 Effect.gen(function* () {
1161 const service = yield* makeService({
1162 projects: [
1163 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1164 project({
1165 id: "p2",
1166 title: "on gitlab",
1167 workspaceRoot: "/b",
1168 repository: "group/sub/project",
1169 provider: SourceControlProviderKind.make("gitlab"),
1170 }),
1171 ],
1172 providers: [
1173 fakeProvider(SourceControlProviderKind.make("github"), {
1174 listChangeRequests: () =>
1175 Effect.succeed({
1176 items: [changeRequest(1, "2026-07-01T00:00:00Z")],
1177 truncated: false,
1178 continues: true,
1179 }),
1180 }),
1181 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1182 listChangeRequests: (input) =>
1183 // Nested groups need the full path, not the last two segments.
1184 input.repository === "group/sub/project"
1185 ? Effect.succeed({
1186 items: [changeRequest(2, "2026-07-05T00:00:00Z")],
1187 truncated: false,
1188 continues: true,
1189 })
1190 : Effect.die("wrong repository identity"),
1191 }),
1192 ],
1193 });
1194
1195 const result = yield* service.list({ state: "open" });
1196
1197 assert.deepStrictEqual(
1198 result.entries.map((entry) => [entry.provider, entry.number]),
1199 [
1200 ["gitlab", 2],
1201 ["github", 1],
1202 ],
1203 );
1204 }),
1205);
1206
1207it.effect("narrows the listing to one host when asked", () =>
1208 Effect.gen(function* () {
1209 const service = yield* makeService({
1210 projects: [
1211 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1212 project({
1213 id: "p2",
1214 title: "on gitlab",
1215 workspaceRoot: "/b",
1216 repository: "group/project",
1217 provider: SourceControlProviderKind.make("gitlab"),
1218 }),
1219 ],
1220 providers: [
1221 fakeProvider(SourceControlProviderKind.make("github"), {
1222 listChangeRequests: () => Effect.die("should not be read"),
1223 }),
1224 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1225 listChangeRequests: () =>
1226 Effect.succeed({
1227 items: [changeRequest(2, "2026-07-05T00:00:00Z")],
1228 truncated: false,
1229 continues: true,
1230 }),
1231 }),
1232 ],
1233 });
1234
1235 const result = yield* service.list({ state: "open", host: "gitlab.com" });
1236
1237 assert.deepStrictEqual(
1238 result.entries.map((entry) => entry.provider),
1239 ["gitlab"],
1240 );
1241 }),
1242);
1243
1244it.effect("tells two hosts of one kind apart in the switcher and the filter", () =>
1245 Effect.gen(function* () {
1246 const service = yield* makeService({
1247 projects: [
1248 project({ id: "p1", title: "on github.com", workspaceRoot: "/a", repository: "ping/one" }),
1249 project({
1250 id: "p2",
1251 title: "on the enterprise install",
1252 workspaceRoot: "/b",
1253 repository: "ping/two",
1254 host: "ghe.example.com",
1255 }),
1256 ],
1257 providers: [
1258 fakeProvider(SourceControlProviderKind.make("github"), {
1259 listChangeRequests: ({ host }) =>
1260 Effect.succeed({
1261 items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [],
1262 truncated: false,
1263 continues: true,
1264 }),
1265 }),
1266 ],
1267 });
1268
1269 // Both hosts are GitHub, so a switcher keyed by provider kind would offer one pill for the
1270 // two of them and no way to ask for either.
1271 const all = yield* service.list({ state: "open" });
1272 assert.deepStrictEqual(
1273 all.providers.map((summary) => [summary.host, summary.kind, summary.projectCount]),
1274 [
1275 ["github.com", "github", 1],
1276 ["ghe.example.com", "github", 1],
1277 ],
1278 );
1279
1280 const scoped = yield* service.list({ state: "open", host: "ghe.example.com" });
1281 assert.deepStrictEqual(
1282 scoped.entries.map((entry) => [entry.host, entry.number]),
1283 [["ghe.example.com", 2]],
1284 );
1285 }),
1286);
1287
1288it.effect("keeps one host listed when another is not set up", () =>
1289 Effect.gen(function* () {
1290 const service = yield* makeService({
1291 projects: [
1292 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1293 project({
1294 id: "p2",
1295 title: "on gitlab",
1296 workspaceRoot: "/b",
1297 repository: "group/project",
1298 provider: SourceControlProviderKind.make("gitlab"),
1299 }),
1300 ],
1301 providers: [
1302 fakeProvider(SourceControlProviderKind.make("github"), {
1303 listChangeRequests: () =>
1304 Effect.succeed({
1305 items: [changeRequest(1, "2026-07-01T00:00:00Z")],
1306 truncated: false,
1307 continues: true,
1308 }),
1309 }),
1310 fakeProvider(SourceControlProviderKind.make("gitlab"), {
1311 getViewer: () =>
1312 Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")),
1313 }),
1314 ],
1315 });
1316
1317 const result = yield* service.list({ state: "open" });
1318
1319 assert.deepStrictEqual(
1320 result.entries.map((entry) => entry.provider),
1321 ["github"],
1322 );
1323 assert.deepStrictEqual(
1324 result.providers.map((summary) => [summary.kind, summary.configured]),
1325 [
1326 ["github", true],
1327 ["gitlab", false],
1328 ],
1329 );
1330 }),
1331);
1332
1333it.effect("fails as unavailable only when no host can be read", () =>
1334 Effect.gen(function* () {
1335 const service = yield* makeService({
1336 projects: [
1337 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1338 ],
1339 providers: [
1340 fakeProvider(SourceControlProviderKind.make("github"), {
1341 getViewer: () =>
1342 Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")),
1343 }),
1344 ],
1345 });
1346
1347 const error = yield* service.list({ state: "open" }).pipe(Effect.flip);
1348
1349 assert.strictEqual(error._tag, "PullRequestUnavailableError");
1350 assert.strictEqual(
1351 error._tag === "PullRequestUnavailableError" ? error.reason : null,
1352 "cli-missing",
1353 );
1354 }),
1355);
1356
1357it.effect("reads a repository once when several worktrees share it", () =>
1358 Effect.gen(function* () {
1359 let calls = 0;
1360 const service = yield* makeService({
1361 projects: [
1362 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1363 project({
1364 id: "p2",
1365 title: "t3code worktree",
1366 workspaceRoot: "/b",
1367 repository: "PingDotGG/T3Code",
1368 }),
1369 ],
1370 providers: [
1371 fakeProvider(SourceControlProviderKind.make("github"), {
1372 listChangeRequests: () => {
1373 calls += 1;
1374 return Effect.succeed({
1375 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
1376 truncated: false,
1377 continues: true,
1378 });
1379 },
1380 }),
1381 ],
1382 });
1383
1384 const result = yield* service.list({ state: "open" });
1385
1386 assert.strictEqual(calls, 1);
1387 assert.strictEqual(result.entries.length, 1);
1388 }),
1389);
1390
1391it.effect("keeps healthy repositories when one of them cannot be read", () =>
1392 Effect.gen(function* () {
1393 const service = yield* makeService({
1394 projects: [
1395 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1396 project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }),
1397 ],
1398 providers: [
1399 fakeProvider(SourceControlProviderKind.make("github"), {
1400 listChangeRequests: (input) =>
1401 input.repository === "pingdotgg/broken"
1402 ? Effect.fail(requestFailed)
1403 : Effect.succeed({
1404 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
1405 truncated: false,
1406 continues: true,
1407 }),
1408 }),
1409 ],
1410 });
1411
1412 const result = yield* service.list({ state: "open" });
1413
1414 assert.strictEqual(result.entries.length, 1);
1415 assert.deepStrictEqual(
1416 result.errors.map((error) => error.projectTitle),
1417 ["broken"],
1418 );
1419 }),
1420);
1421
1422it.effect("tries another workspace on the same host for the viewer", () =>
1423 Effect.gen(function* () {
1424 const service = yield* makeService({
1425 projects: [
1426 project({ id: "p1", title: "broken", workspaceRoot: "/broken", repository: "acme/one" }),
1427 project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }),
1428 ],
1429 providers: [
1430 fakeProvider(SourceControlProviderKind.make("github"), {
1431 getViewer: (input) =>
1432 input.cwd === "/healthy"
1433 ? Effect.succeed("bilal")
1434 : Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")),
1435 listChangeRequests: () =>
1436 Effect.succeed({
1437 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
1438 truncated: false,
1439 continues: true,
1440 }),
1441 }),
1442 ],
1443 });
1444
1445 const result = yield* service.list({ state: "open" });
1446
1447 assert.strictEqual(result.entries.length, 2);
1448 assert.strictEqual(result.viewers["github.com"], "bilal");
1449 }),
1450);
1451
1452it.effect("routing verifies the current account on the requested host without caching it", () =>
1453 Effect.gen(function* () {
1454 let viewer = "first-account";
1455 const service = yield* makeService({
1456 projects: [
1457 project({
1458 id: "p1",
1459 title: "web",
1460 workspaceRoot: "/a",
1461 repository: "acme/web",
1462 host: "github.example.test",
1463 }),
1464 ],
1465 providers: [
1466 fakeProvider(SourceControlProviderKind.make("github"), {
1467 getRoutingIdentity: (input) => {
1468 assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" });
1469 return Effect.succeed({
1470 viewer,
1471 accountId: viewer === "first-account" ? "123" : "456",
1472 });
1473 },
1474 }),
1475 ],
1476 });
1477 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1478 assert.deepStrictEqual(yield* service.routing(ref), {
1479 host: "github.example.test",
1480 provider: SourceControlProviderKind.make("github"),
1481 viewer: "first-account",
1482 accountId: "123",
1483 projectTitle: "web",
1484 workspaceRoot: "/a",
1485 });
1486 viewer = "second-account";
1487 assert.strictEqual((yield* service.routing(ref)).viewer, "second-account");
1488 viewer = " ";
1489 const failure = yield* service.routing(ref).pipe(Effect.flip);
1490 assert.strictEqual(failure._tag, "PullRequestOperationError");
1491 if (failure._tag === "PullRequestOperationError") {
1492 assert.strictEqual(failure.operation, "routeIdentity");
1493 }
1494 }),
1495);
1496
1497it.effect("refuses an action the host never claimed it could run", () =>
1498 Effect.gen(function* () {
1499 let ran = false;
1500 const service = yield* makeService({
1501 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1502 providers: [
1503 fakeProvider(SourceControlProviderKind.make("github"), {
1504 capabilities: {
1505 diff: true,
1506 comment: true,
1507 // Bitbucket's shape: it can merge and close, but cannot reopen.
1508 actions: ["merge", "close"],
1509 mergeMethods: ["merge"],
1510 search: true,
1511 reactions: true,
1512 review: FULL_REVIEW,
1513 reviewers: FULL_REVIEWERS,
1514 },
1515 runAction: () => {
1516 ran = true;
1517 return Effect.void;
1518 },
1519 }),
1520 ],
1521 });
1522
1523 const error = yield* Effect.flip(
1524 service.runAction({
1525 projectId: "p1" as ProjectId,
1526 repository: "acme/web",
1527 number: 1,
1528 action: "reopen",
1529 }),
1530 );
1531
1532 assert.strictEqual(error._tag, "PullRequestOperationError");
1533 assert.isFalse(ran);
1534 }),
1535);
1536
1537it.effect("publishes a merge for immediate settlement only after host confirmation", () =>
1538 Effect.scoped(
1539 Effect.gen(function* () {
1540 const mergedAt = "2026-09-03T02:00:00.000Z";
1541 let state: "open" | "merged" = "open";
1542 let confirmationFails = false;
1543 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1544 const service = yield* makeService({
1545 projects: [
1546 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
1547 ],
1548 providers: [
1549 fakeProvider(SourceControlProviderKind.make("github"), {
1550 getChangeRequestSummary: () =>
1551 confirmationFails
1552 ? Effect.fail(
1553 new PullRequestProviderError({
1554 provider: SourceControlProviderKind.make("github"),
1555 operation: "getChangeRequestSummary",
1556 reason: "failed",
1557 detail: "HTTP 504",
1558 }),
1559 )
1560 : Effect.succeed({ ...changeRequest(1, mergedAt), state }),
1561 }),
1562 ],
1563 });
1564 const merges = yield* service.subscribeMerges;
1565 const observedMerge = yield* Stream.runHead(merges).pipe(
1566 Effect.forkChild({ startImmediately: true }),
1567 );
1568
1569 // Queueing succeeds while the host still reports an open PR.
1570 yield* service.runAction({ ...reference, action: "merge" });
1571 const queuedRefresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes));
1572 confirmationFails = true;
1573 yield* service.runAction({ ...reference, action: "merge" });
1574 assert.isAbove(
1575 Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)),
1576 queuedRefresh,
1577 );
1578 confirmationFails = false;
1579 state = "merged";
1580 yield* TestClock.setTime(Date.parse(mergedAt));
1581 yield* service.runAction({
1582 ...reference,
1583 repository: " ACME/WEB ",
1584 action: "merge",
1585 mergeMethod: "merge",
1586 });
1587
1588 assert.deepStrictEqual(Option.getOrThrow(yield* Fiber.join(observedMerge)), {
1589 ...reference,
1590 mergedAt,
1591 });
1592 }),
1593 ),
1594);
1595
1596it.effect("refreshes every reader before a queued merge confirmation finishes", () =>
1597 Effect.scoped(
1598 Effect.gen(function* () {
1599 const confirmationStarted = yield* Deferred.make<void>();
1600 const confirm = yield* Deferred.make<void>();
1601 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1602 const service = yield* makeService({
1603 projects: [
1604 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
1605 ],
1606 providers: [
1607 fakeProvider(SourceControlProviderKind.make("github"), {
1608 getChangeRequestSummary: () =>
1609 Effect.gen(function* () {
1610 yield* Deferred.succeed(confirmationStarted, undefined);
1611 yield* Deferred.await(confirm);
1612 return changeRequest(1, "2026-09-16T00:00:00.000Z");
1613 }),
1614 }),
1615 ],
1616 });
1617 const merges = yield* service.subscribeMerges;
1618 const observedMerge = yield* Stream.runHead(merges).pipe(
1619 Effect.forkChild({ startImmediately: true }),
1620 );
1621 const readers = yield* Effect.forEach([0, 1], () =>
1622 Stream.runHead(service.subscribeRefreshes).pipe(
1623 Effect.forkChild({ startImmediately: true }),
1624 ),
1625 );
1626 const action = yield* service
1627 .runAction({ ...reference, action: "merge" })
1628 .pipe(Effect.forkChild({ startImmediately: true }));
1629 yield* Deferred.await(confirmationStarted);
1630 const revisions = yield* Effect.forEach(readers, (reader) =>
1631 Fiber.join(reader).pipe(Effect.map(Option.getOrThrow)),
1632 );
1633 assert.isAbove(revisions[0]!, 0);
1634 assert.strictEqual(revisions[0], revisions[1]);
1635 assert.isUndefined(action.pollUnsafe());
1636 yield* Deferred.succeed(confirm, undefined);
1637 yield* Fiber.join(action);
1638 assert.isUndefined(observedMerge.pollUnsafe());
1639 }),
1640 ),
1641);
1642
1643it.effect("runs branch deletion only when the host and viewer support it, never for a stack", () =>
1644 Effect.gen(function* () {
1645 let actions = 0;
1646 let supported = false;
1647 let allowed = true;
1648 const base = fakeProvider(SourceControlProviderKind.make("github"));
1649 const service = yield* makeService({
1650 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1651 providers: [
1652 {
1653 ...base,
1654 capabilities: {
1655 ...base.capabilities,
1656 actions: ["delete-branch"],
1657 stackActions: true,
1658 get deleteBranch() {
1659 return supported;
1660 },
1661 },
1662 getViewerPermissions: () =>
1663 Effect.succeed({
1664 actions: allowed ? ["delete-branch"] : [],
1665 comment: true,
1666 resolve: true,
1667 verdicts: [],
1668 requestReviewers: false,
1669 }),
1670 runAction: (input) =>
1671 Effect.sync(() => {
1672 assert.strictEqual(input.action, "delete-branch");
1673 actions++;
1674 }),
1675 },
1676 ],
1677 });
1678 const input = {
1679 projectId: "p1" as ProjectId,
1680 repository: "acme/web",
1681 number: 1,
1682 action: "delete-branch" as const,
1683 };
1684 assert.strictEqual(
1685 (yield* Effect.flip(service.runAction(input)))._tag,
1686 "PullRequestOperationError",
1687 );
1688 assert.strictEqual(actions, 0);
1689 supported = true;
1690 assert.strictEqual(
1691 (yield* Effect.flip(
1692 service.runAction({
1693 ...input,
1694 stackNumber: 2,
1695 expectedStackHeads: [{ number: 1, headSha: "abc123" }],
1696 }),
1697 ))._tag,
1698 "PullRequestOperationError",
1699 );
1700 assert.strictEqual(actions, 0);
1701 allowed = false;
1702 assert.strictEqual(
1703 (yield* Effect.flip(service.runAction(input)))._tag,
1704 "PullRequestOperationError",
1705 );
1706 assert.strictEqual(actions, 0);
1707 allowed = true;
1708 yield* service.runAction(input);
1709 assert.strictEqual(actions, 1);
1710 }),
1711);
1712
1713it.effect("keeps branch deletion on the credential that passed source repository permissions", () =>
1714 Effect.gen(function* () {
1715 let activeToken = "account-a";
1716 const requests: Array<{ operation: string; authorization: string | undefined }> = [];
1717 const credentials = Layer.succeed(
1718 GitHubCredentials.GitHubCredentials,
1719 GitHubCredentials.GitHubCredentials.of({
1720 get: (host) =>
1721 Effect.sync(() => ({
1722 host,
1723 token: Redacted.make(activeToken),
1724 source: "gh" as const,
1725 fingerprint: `${host}:${activeToken}`,
1726 })),
1727 invalidate: () => Effect.void,
1728 }),
1729 );
1730 const http = Layer.succeed(
1731 HttpClient.HttpClient,
1732 HttpClient.make((request) =>
1733 Effect.sync(() => {
1734 const operation = request.url.endsWith("/user")
1735 ? "user"
1736 : request.body._tag === "Uint8Array"
1737 ? new TextDecoder().decode(request.body.body)
1738 : "";
1739 requests.push({ operation, authorization: request.headers["authorization"] });
1740 let body;
1741 if (operation === "user") {
1742 body = { id: activeToken === "account-a" ? 101 : 202, login: activeToken };
1743 } else if (operation.includes("query PullRequestBranchToDelete")) {
1744 body = {
1745 data: {
1746 repository: {
1747 pullRequest: {
1748 state: "MERGED",
1749 headRefName: "feature",
1750 baseRefName: "main",
1751 headRefOid: "abc123",
1752 headRepository: { id: "FORK_REPO" },
1753 },
1754 },
1755 },
1756 };
1757 } else if (operation.includes("query PullRequestSourceBranch")) {
1758 body = {
1759 data: {
1760 node: {
1761 defaultBranchRef: { name: "main" },
1762 ref: { target: { oid: "abc123" } },
1763 },
1764 },
1765 };
1766 } else if (operation.includes("updateRefs(")) {
1767 body = { data: { updateRefs: {} } };
1768 } else {
1769 activeToken = "account-b";
1770 body = {
1771 data: {
1772 repository: {
1773 mergeCommitAllowed: true,
1774 squashMergeAllowed: true,
1775 rebaseMergeAllowed: true,
1776 viewerPermission: "READ",
1777 pullRequest: {
1778 viewerCanUpdate: false,
1779 viewerDidAuthor: false,
1780 headRepository: { viewerPermission: "WRITE" },
1781 },
1782 },
1783 },
1784 };
1785 }
1786 return HttpClientResponse.fromWeb(request, new Response(JSON.stringify(body)));
1787 }),
1788 ),
1789 );
1790 const provider = yield* GitHubPullRequestProvider.make.pipe(
1791 Effect.provide(
1792 GitHubPullRequestApi.layer.pipe(
1793 Layer.provide(
1794 GitHubApi.layer.pipe(
1795 Layer.provide(Layer.mergeAll(credentials, http)),
1796 Layer.provide(GitHubQuota.layer),
1797 Layer.provide(SourceControlRateLimit.layer),
1798 ),
1799 ),
1800 Layer.provide(TestSourceControlHost.layer()),
1801 Layer.provide(NodeServices.layer),
1802 ),
1803 ),
1804 );
1805 const service = yield* makeService({
1806 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1807 providers: [provider],
1808 });
1809 yield* service.runAction({
1810 projectId: "p1" as ProjectId,
1811 repository: "acme/web",
1812 number: 1,
1813 action: "delete-branch",
1814 });
1815 assert.strictEqual(activeToken, "account-b");
1816 assert.strictEqual(
1817 requests.filter(({ operation }) => operation.includes("updateRefs(")).length,
1818 1,
1819 );
1820 assert.deepStrictEqual(
1821 requests.map(({ authorization }) => authorization),
1822 [
1823 "Bearer account-a",
1824 "Bearer account-a",
1825 "Bearer account-a",
1826 "Bearer account-a",
1827 "Bearer account-a",
1828 ],
1829 );
1830 }),
1831);
1832
1833it.effect("refuses an action this viewer may not take, and says what access it takes", () =>
1834 Effect.gen(function* () {
1835 let ran: string | null = null;
1836 const service = yield* makeService({
1837 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1838 providers: [
1839 fakeProvider(SourceControlProviderKind.make("github"), {
1840 // The host merges; this account only reads it, and opened the change request — which
1841 // is every contributor to a repository they do not own.
1842 getViewerPermissions: () =>
1843 Effect.succeed({
1844 actions: ["ready", "draft", "close", "reopen"],
1845 comment: true,
1846 resolve: true,
1847 verdicts: ["comment", "approve", "request-changes"],
1848 requestReviewers: false,
1849 }),
1850 runAction: (input) => {
1851 ran = input.action;
1852 return Effect.void;
1853 },
1854 }),
1855 ],
1856 });
1857 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1858
1859 const error = yield* Effect.flip(service.runAction({ ...reference, action: "merge" }));
1860 assert.strictEqual(error._tag, "PullRequestOperationError");
1861 assert.include(error.message, "You need write access on this repository to merge.");
1862 assert.strictEqual(ran, null);
1863
1864 // What the author keeps whatever their access is still theirs to take.
1865 yield* service.runAction({ ...reference, action: "close" });
1866 assert.strictEqual(ran, "close");
1867 }),
1868);
1869
1870it.effect("gates arming a merge for later exactly as it gates merging now", () =>
1871 Effect.gen(function* () {
1872 let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null;
1873 const service = yield* makeService({
1874 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1875 providers: [
1876 fakeProvider(SourceControlProviderKind.make("github"), {
1877 capabilities: {
1878 diff: true,
1879 comment: true,
1880 actions: ["merge", "close", "enable-auto-merge", "disable-auto-merge"],
1881 mergeMethods: ["merge", "squash"],
1882 search: true,
1883 reactions: true,
1884 review: FULL_REVIEW,
1885 reviewers: FULL_REVIEWERS,
1886 },
1887 // This account may close the change request it opened, and nothing else here.
1888 getViewerPermissions: () =>
1889 Effect.succeed({
1890 actions: ["close"],
1891 comment: true,
1892 resolve: true,
1893 verdicts: ["comment", "approve", "request-changes"],
1894 requestReviewers: false,
1895 }),
1896 runAction: (input) => {
1897 ranWith = {
1898 action: input.action,
1899 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
1900 };
1901 return Effect.void;
1902 },
1903 }),
1904 ],
1905 });
1906 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1907
1908 const refused = yield* Effect.flip(
1909 service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }),
1910 );
1911 assert.strictEqual(refused._tag, "PullRequestOperationError");
1912 assert.include(refused.message, "merged for you once it is ready");
1913 assert.strictEqual(ranWith, null);
1914
1915 // The strategy is checked against the host for an armed merge too: a merge it performs
1916 // later is still a merge, and one it cannot spell must not be passed on.
1917 const wrongStrategy = yield* Effect.flip(
1918 service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "rebase" }),
1919 );
1920 assert.strictEqual(wrongStrategy._tag, "PullRequestOperationError");
1921 assert.strictEqual(ranWith, null);
1922 }),
1923);
1924
1925it.effect("hands the host the strategy an armed merge was asked for", () =>
1926 Effect.gen(function* () {
1927 let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null;
1928 const service = yield* makeService({
1929 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1930 providers: [
1931 fakeProvider(SourceControlProviderKind.make("github"), {
1932 capabilities: {
1933 diff: true,
1934 comment: true,
1935 actions: ["merge", "enable-auto-merge", "disable-auto-merge"],
1936 mergeMethods: ["merge", "squash"],
1937 search: true,
1938 reactions: true,
1939 review: FULL_REVIEW,
1940 reviewers: FULL_REVIEWERS,
1941 },
1942 getViewerPermissions: () =>
1943 Effect.succeed({
1944 actions: ["merge", "enable-auto-merge", "disable-auto-merge"],
1945 comment: true,
1946 resolve: true,
1947 verdicts: ["comment", "approve", "request-changes"],
1948 requestReviewers: true,
1949 }),
1950 runAction: (input) => {
1951 ranWith = {
1952 action: input.action,
1953 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
1954 };
1955 return Effect.void;
1956 },
1957 }),
1958 ],
1959 });
1960 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1961
1962 yield* service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" });
1963 assert.deepStrictEqual(ranWith, { action: "enable-auto-merge", mergeMethod: "squash" });
1964
1965 yield* service.runAction({ ...reference, action: "disable-auto-merge" });
1966 assert.deepStrictEqual(ranWith, { action: "disable-auto-merge" });
1967 }),
1968);
1969
1970it.effect("refuses an auto-merge the host never claimed, without asking it", () =>
1971 Effect.gen(function* () {
1972 let ran = false;
1973 const service = yield* makeService({
1974 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1975 providers: [
1976 // Bitbucket's shape: it merges, and has nothing that merges later on its own.
1977 fakeProvider(SourceControlProviderKind.make("github"), {
1978 runAction: () => {
1979 ran = true;
1980 return Effect.void;
1981 },
1982 }),
1983 ],
1984 });
1985
1986 const error = yield* Effect.flip(
1987 service.runAction({
1988 projectId: "p1" as ProjectId,
1989 repository: "acme/web",
1990 number: 1,
1991 action: "enable-auto-merge",
1992 }),
1993 );
1994
1995 assert.strictEqual(error._tag, "PullRequestOperationError");
1996 assert.isFalse(ran);
1997 }),
1998);
1999
2000it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () =>
2001 Effect.gen(function* () {
2002 const service = yield* makeService({
2003 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2004 providers: [
2005 fakeProvider(SourceControlProviderKind.make("github"), {
2006 getViewerPermissions: () =>
2007 Effect.succeed({
2008 actions: ["merge", "ready", "draft", "close", "reopen"],
2009 comment: true,
2010 resolve: false,
2011 verdicts: ["comment", "approve", "request-changes"],
2012 requestReviewers: true,
2013 }),
2014 setThreadResolution: () => Effect.die("must not be called"),
2015 }),
2016 ],
2017 });
2018
2019 const error = yield* Effect.flip(
2020 service.setThreadResolution({
2021 projectId: "p1" as ProjectId,
2022 repository: "acme/web",
2023 number: 1,
2024 threadId: "t1",
2025 resolved: true,
2026 }),
2027 );
2028
2029 assert.strictEqual(error._tag, "PullRequestOperationError");
2030 assert.include(error.message, "to resolve a review conversation.");
2031 }),
2032);
2033
2034it.effect("asks nobody what the viewer may do when the host cannot do it at all", () =>
2035 Effect.gen(function* () {
2036 let asked = false;
2037 const service = yield* makeService({
2038 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2039 providers: [
2040 fakeProvider(SourceControlProviderKind.make("github"), {
2041 capabilities: {
2042 diff: true,
2043 comment: true,
2044 actions: ["merge", "close"],
2045 mergeMethods: ["merge"],
2046 search: true,
2047 reactions: true,
2048 review: FULL_REVIEW,
2049 reviewers: FULL_REVIEWERS,
2050 },
2051 getViewerPermissions: () => {
2052 asked = true;
2053 return Effect.die("must not be called");
2054 },
2055 }),
2056 ],
2057 });
2058
2059 yield* Effect.flip(
2060 service.runAction({
2061 projectId: "p1" as ProjectId,
2062 repository: "acme/web",
2063 number: 1,
2064 action: "reopen",
2065 }),
2066 );
2067
2068 // The capability check costs nothing; the permission read is a request, so it comes second.
2069 assert.isFalse(asked);
2070 }),
2071);
2072
2073it.effect("refuses a comment on a host that cannot post one", () =>
2074 Effect.gen(function* () {
2075 let posted = false;
2076 const service = yield* makeService({
2077 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2078 providers: [
2079 fakeProvider(SourceControlProviderKind.make("github"), {
2080 capabilities: {
2081 diff: false,
2082 comment: false,
2083 actions: ["merge"],
2084 mergeMethods: ["merge"],
2085 search: true,
2086 reactions: true,
2087 review: FULL_REVIEW,
2088 reviewers: FULL_REVIEWERS,
2089 },
2090 comment: () => {
2091 posted = true;
2092 return Effect.void;
2093 },
2094 }),
2095 ],
2096 });
2097
2098 const error = yield* Effect.flip(
2099 service.comment({
2100 projectId: "p1" as ProjectId,
2101 repository: "acme/web",
2102 number: 1,
2103 body: "Looks good.",
2104 }),
2105 );
2106
2107 assert.strictEqual(error._tag, "PullRequestOperationError");
2108 assert.isFalse(posted);
2109 }),
2110);
2111
2112it.effect("keeps two hosts of one provider kind as two accounts", () =>
2113 Effect.gen(function* () {
2114 const viewerFor: Record<string, string> = { "/cloud": "bilal", "/enterprise": "b.hassan" };
2115 const service = yield* makeService({
2116 projects: [
2117 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
2118 project({
2119 id: "p2",
2120 title: "enterprise",
2121 workspaceRoot: "/enterprise",
2122 // The same path on a different host: neither the viewer nor the row may be shared.
2123 repository: "acme/web",
2124 host: "github.acme.dev",
2125 }),
2126 ],
2127 providers: [
2128 fakeProvider(SourceControlProviderKind.make("github"), {
2129 getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"),
2130 listChangeRequests: () =>
2131 Effect.succeed({
2132 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
2133 truncated: false,
2134 continues: true,
2135 }),
2136 }),
2137 ],
2138 });
2139
2140 const result = yield* service.list({ state: "open" });
2141
2142 // Both repositories survive de-duplication, each with its own account.
2143 assert.strictEqual(result.entries.length, 2);
2144 assert.deepStrictEqual(result.viewers, {
2145 "github.com": "bilal",
2146 "github.acme.dev": "b.hassan",
2147 });
2148 assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [
2149 "github.acme.dev",
2150 "github.com",
2151 ]);
2152 }),
2153);
2154
2155it.effect("reports repositories on a host that could not be read", () =>
2156 Effect.gen(function* () {
2157 const service = yield* makeService({
2158 projects: [
2159 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
2160 project({
2161 id: "p2",
2162 title: "enterprise",
2163 workspaceRoot: "/enterprise",
2164 repository: "acme/api",
2165 host: "github.acme.dev",
2166 }),
2167 ],
2168 providers: [
2169 fakeProvider(SourceControlProviderKind.make("github"), {
2170 getViewer: (input) =>
2171 input.cwd === "/cloud"
2172 ? Effect.succeed("bilal")
2173 : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")),
2174 listChangeRequests: () =>
2175 Effect.succeed({
2176 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
2177 truncated: false,
2178 continues: true,
2179 }),
2180 }),
2181 ],
2182 });
2183
2184 const result = yield* service.list({ state: "open" });
2185
2186 // The healthy host still lists, and the unreadable one is named rather than dropped.
2187 assert.strictEqual(result.entries.length, 1);
2188 assert.deepStrictEqual(
2189 result.errors.map((error) => error.projectId),
2190 ["p2"],
2191 );
2192 }),
2193);
2194
2195it.effect("stops new reads after a rate limit while leaving manual actions available", () =>
2196 Effect.gen(function* () {
2197 let listCalls = 0;
2198 let actionCalls = 0;
2199 const service = yield* makeService({
2200 projects: [
2201 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
2202 ],
2203 providers: [
2204 fakeProvider(SourceControlProviderKind.make("github"), {
2205 listChangeRequests: () =>
2206 Effect.sync(() => {
2207 listCalls += 1;
2208 }).pipe(
2209 Effect.andThen(
2210 Effect.fail(
2211 new PullRequestProviderError({
2212 provider: SourceControlProviderKind.make("github"),
2213 operation: "listChangeRequests",
2214 reason: "rate-limited",
2215 detail: "GitHub API rate limit exceeded.",
2216 }),
2217 ),
2218 ),
2219 ),
2220 runAction: () =>
2221 Effect.sync(() => {
2222 actionCalls += 1;
2223 }),
2224 }),
2225 ],
2226 });
2227
2228 const first = yield* service.list({ state: "open", involvement: "all" });
2229 const paused = yield* service.list({ state: "open", involvement: "authored" });
2230 yield* service.runAction({
2231 projectId: "p1" as ProjectId,
2232 repository: "acme/web",
2233 number: 1,
2234 action: "close",
2235 });
2236
2237 assert.strictEqual(listCalls, 1);
2238 assert.strictEqual(actionCalls, 1);
2239 assert.lengthOf(first.errors, 1);
2240 assert.lengthOf(paused.errors, 1);
2241 }),
2242);
2243
2244for (const [providerName, host] of [
2245 ["github", "github.com"],
2246 ["gitlab", "gitlab.com"],
2247 ["forgejo", "code.example.test"],
2248 ["bitbucket", "bitbucket.org"],
2249 ["azure-devops", "dev.azure.com"],
2250] as const) {
2251 const provider = SourceControlProviderKind.make(providerName);
2252 it.effect.each(["detail", "checks"] as const)(
2253 `shares fresh ${provider} %s reads and respects rate-limit resets`,
2254 (read) =>
2255 Effect.gen(function* () {
2256 let calls = 0;
2257 let limited = false;
2258 const repository = provider === "azure-devops" ? "web" : "acme/web";
2259 const service = yield* makeService({
2260 projects: [
2261 project({ id: "p1", title: "web", workspaceRoot: "/repo", repository, provider, host }),
2262 ],
2263 providers: [
2264 fakeProvider(provider, {
2265 [read === "detail" ? "getChangeRequest" : "getChangeRequestChecks"]: () =>
2266 Effect.gen(function* () {
2267 calls += 1;
2268 if (limited) {
2269 return yield* new PullRequestProviderError({
2270 provider,
2271 operation: "getChangeRequest",
2272 reason: "rate-limited",
2273 detail: "Retry after the reset.",
2274 retryAt: (yield* Clock.currentTimeMillis) + 120_000,
2275 });
2276 }
2277 return hostedChangeRequest("current details");
2278 }),
2279 }),
2280 ],
2281 });
2282 const reference = {
2283 projectId: "p1" as ProjectId,
2284 repository,
2285 number: 1,
2286 allowStale: false,
2287 };
2288 const request: Effect.Effect<unknown, PullRequestService.PullRequestError> =
2289 service[read](reference);
2290 yield* Effect.all([request, request], { concurrency: 2 });
2291 assert.strictEqual(calls, 1);
2292 yield* TestClock.adjust("61 seconds");
2293 limited = true;
2294 assert.strictEqual((yield* Effect.exit(request))._tag, "Failure");
2295 assert.strictEqual(calls, 2);
2296 yield* TestClock.adjust("45 seconds");
2297 assert.strictEqual((yield* Effect.exit(request))._tag, "Failure");
2298 assert.strictEqual(calls, 2);
2299 yield* TestClock.adjust("75 seconds");
2300 limited = false;
2301 const recovered = yield* service[read](reference);
2302 assert.strictEqual(recovered?.state, "open");
2303 assert.strictEqual(calls, 3);
2304 yield* service.invalidate({ reference });
2305 yield* service[read](reference);
2306 assert.strictEqual(calls, 4);
2307 }),
2308 );
2309}
2310
2311it.effect("does not fall back to full detail when checks are unsupported", () =>
2312 Effect.gen(function* () {
2313 const service = yield* makeService({
2314 projects: [
2315 project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }),
2316 ],
2317 providers: [
2318 fakeProvider(SourceControlProviderKind.make("github"), {
2319 getChangeRequest: () => Effect.die("Unexpected full detail read"),
2320 }),
2321 ],
2322 });
2323 assert.isNull(
2324 yield* service.checks({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }),
2325 );
2326 }),
2327);
2328
2329it.effect("uses a manual rate limit to pause later reads", () =>
2330 Effect.gen(function* () {
2331 let listCalls = 0;
2332 const service = yield* makeService({
2333 projects: [
2334 project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }),
2335 ],
2336 providers: [
2337 fakeProvider(SourceControlProviderKind.make("github"), {
2338 listChangeRequests: () =>
2339 Effect.sync(() => {
2340 listCalls += 1;
2341 return { items: [], truncated: false, continues: true };
2342 }),
2343 runAction: () =>
2344 Effect.fail(
2345 new PullRequestProviderError({
2346 provider: SourceControlProviderKind.make("github"),
2347 operation: "runAction",
2348 reason: "rate-limited",
2349 detail: "GitHub API rate limit exceeded.",
2350 }),
2351 ),
2352 }),
2353 ],
2354 });
2355
2356 yield* Effect.flip(
2357 service.runAction({
2358 projectId: "p1" as ProjectId,
2359 repository: "acme/web",
2360 number: 1,
2361 action: "close",
2362 }),
2363 );
2364 const error = yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
2365
2366 assert.strictEqual(listCalls, 0);
2367 assert.strictEqual(error._tag, "PullRequestOperationError");
2368 }),
2369);
2370
2371it.effect("flags a review request for the viewer but not on their own change request", () =>
2372 Effect.gen(function* () {
2373 const service = yield* makeService({
2374 projects: [
2375 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2376 ],
2377 providers: [
2378 fakeProvider(SourceControlProviderKind.make("github"), {
2379 listChangeRequests: () =>
2380 Effect.succeed({
2381 items: [
2382 { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewRequestLogins: ["Bilal"] },
2383 {
2384 ...changeRequest(2, "2026-07-02T00:00:00Z"),
2385 author: { login: "bilal", name: null, avatarUrl: null },
2386 reviewRequestLogins: ["bilal"],
2387 },
2388 ],
2389 truncated: false,
2390 continues: true,
2391 }),
2392 }),
2393 ],
2394 });
2395
2396 const result = yield* service.list({ state: "open" });
2397
2398 assert.deepStrictEqual(
2399 result.entries.map((entry) => entry.viewerReviewRequested),
2400 [true, false],
2401 );
2402 }),
2403);
2404
2405it.effect("refuses a repository that does not belong to the requested project", () =>
2406 Effect.gen(function* () {
2407 const service = yield* makeService({
2408 projects: [
2409 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
2410 ],
2411 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2412 });
2413
2414 const error = yield* service
2415 .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 })
2416 .pipe(Effect.flip);
2417
2418 assert.strictEqual(error._tag, "PullRequestOperationError");
2419 }),
2420);
2421
2422it.effect("caches stack membership separately from action details", () =>
2423 Effect.gen(function* () {
2424 const reads: Array<boolean> = [];
2425 const service = yield* makeService({
2426 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2427 providers: [
2428 fakeProvider(SourceControlProviderKind.make("github"), {
2429 getChangeRequestStack: (input) =>
2430 Effect.sync(() => {
2431 reads.push(input.includeDetails === true);
2432 return {
2433 id: "9",
2434 number: 3,
2435 url: "https://github.com/acme/web/stacks/3",
2436 base: "main",
2437 layers: [
2438 {
2439 number: 7,
2440 headBranch: "a",
2441 state: "open" as const,
2442 ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}),
2443 },
2444 ],
2445 };
2446 }),
2447 }),
2448 ],
2449 });
2450 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 };
2451 yield* service.stack(reference, { includeDetails: false });
2452 yield* service.stack(reference, { includeDetails: false });
2453 const detail = yield* service.stack(reference);
2454 yield* service.stack(reference);
2455 assert.deepStrictEqual(reads, [false, true]);
2456 assert.strictEqual(detail?.layers[0]?.headSha, "abc");
2457 yield* service.invalidate({ reference });
2458 yield* service.stack(reference, { includeDetails: false });
2459 yield* service.stack(reference);
2460 assert.deepStrictEqual(reads, [false, true, false, true]);
2461 }),
2462);
2463
2464it.effect("reads a host-native stack through the provider and null where it has none", () =>
2465 Effect.gen(function* () {
2466 const service = yield* makeService({
2467 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2468 providers: [
2469 fakeProvider(SourceControlProviderKind.make("github"), {
2470 getChangeRequestStack: () =>
2471 Effect.succeed({
2472 id: "9",
2473 number: 3,
2474 url: "https://github.com/acme/web/stacks/3",
2475 base: "main",
2476 layers: [
2477 { number: 7, headBranch: "a", state: "open" as const },
2478 { number: 8, headBranch: "b", state: "open" as const },
2479 ],
2480 }),
2481 }),
2482 ],
2483 });
2484
2485 const stack = yield* service.stack({
2486 projectId: "p1" as ProjectId,
2487 repository: "acme/web",
2488 number: 7,
2489 });
2490 assert.deepStrictEqual(
2491 stack?.layers.map((layer) => layer.number),
2492 [7, 8],
2493 );
2494
2495 const withoutStacks = yield* makeService({
2496 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2497 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2498 });
2499 assert.isNull(
2500 yield* withoutStacks.stack({
2501 projectId: "p1" as ProjectId,
2502 repository: "acme/web",
2503 number: 7,
2504 }),
2505 );
2506 }),
2507);
2508
2509it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () =>
2510 Effect.gen(function* () {
2511 for (const provider of ["forgejo", "unknown"] as const) {
2512 const seen: string[] = [];
2513 const viewers: Array<string | undefined> = [];
2514 const service = yield* makeService({
2515 projects: [
2516 project({
2517 id: "ssh",
2518 title: "ssh",
2519 workspaceRoot: "/ssh",
2520 repository: "team/repo",
2521 provider,
2522 host: "ssh.code.example",
2523 remoteUrl: "git@ssh.code.example:team/repo.git",
2524 }),
2525 ],
2526 providers: [
2527 fakeProvider(SourceControlProviderKind.make("forgejo"), {
2528 getViewer: (input) => {
2529 viewers.push(input.host);
2530 assert.strictEqual(input.host, "code.example:3000");
2531 return Effect.succeed("bilal");
2532 },
2533 listChangeRequests: (input) => {
2534 assert.strictEqual(input.host, "code.example:3000");
2535 return Effect.succeed({ items: [], truncated: false, continues: true });
2536 },
2537 getChangeRequest: (input) => {
2538 assert.strictEqual(input.host, "code.example:3000");
2539 return Effect.succeed({ ...hostedChangeRequest("Forgejo detail"), number: 42 });
2540 },
2541 getChangeRequestSummary: (input) =>
2542 Effect.sync(() => {
2543 seen.push(input.host);
2544 return changeRequest(42, "2026-07-02T00:00:00Z");
2545 }),
2546 }),
2547 ],
2548 resolveHandle: ({ context }) => {
2549 if (context?.requestedHost === undefined) {
2550 return Effect.succeed({ context: context!, provider: undefined as never });
2551 }
2552 assert.strictEqual(context.requestedHost, "code.example:3000");
2553 return Effect.succeed({
2554 context: {
2555 ...context,
2556 provider: {
2557 kind: SourceControlProviderKind.make("forgejo"),
2558 name: "Forgejo",
2559 baseUrl: "http://code.example:3000",
2560 },
2561 },
2562 provider: undefined as never,
2563 });
2564 },
2565 });
2566 yield* service.summary(
2567 {
2568 projectId: "ssh" as ProjectId,
2569 host: "code.example:3000",
2570 repository: "team/repo",
2571 number: 42,
2572 },
2573 { recoverTransientFailure: false },
2574 );
2575 assert.deepStrictEqual(seen, ["code.example:3000"]);
2576 const listed = yield* service.list({
2577 projectId: "ssh" as ProjectId,
2578 host: "code.example:3000",
2579 state: "open",
2580 });
2581 assert.strictEqual(listed.viewers["code.example:3000"], "bilal");
2582 const preview = yield* service.preview({
2583 projectId: "ssh" as ProjectId,
2584 host: "code.example:3000",
2585 repository: "team/repo",
2586 number: 42,
2587 });
2588 assert.strictEqual(preview.number, 42);
2589 const detail = yield* service.detail({
2590 projectId: "ssh" as ProjectId,
2591 host: "code.example:3000",
2592 repository: "team/repo",
2593 number: 42,
2594 });
2595 assert.strictEqual(detail.body, "Forgejo detail");
2596 assert.deepStrictEqual(viewers, ["code.example:3000"]);
2597 }
2598 }),
2599);
2600
2601it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () =>
2602 Effect.gen(function* () {
2603 const service = yield* makeService({
2604 projects: [
2605 project({
2606 id: "http",
2607 title: "http",
2608 workspaceRoot: "/http",
2609 repository: "team/repo",
2610 provider: SourceControlProviderKind.make("forgejo"),
2611 host: "code.example",
2612 remoteUrl: "http://code.example:4000/team/repo.git",
2613 }),
2614 ],
2615 providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))],
2616 });
2617 const failure = yield* service
2618 .summary(
2619 {
2620 projectId: "http" as ProjectId,
2621 host: "code.example:3000",
2622 repository: "team/repo",
2623 number: 42,
2624 },
2625 { recoverTransientFailure: false },
2626 )
2627 .pipe(Effect.flip);
2628 assert.strictEqual(failure._tag, "PullRequestUnavailableError");
2629 }),
2630);
2631
2632it.effect("resolves a project's repository identity when its shell has none cached", () =>
2633 Effect.gen(function* () {
2634 const resolved = project({
2635 id: "web",
2636 title: "web",
2637 workspaceRoot: "/web",
2638 repository: "acme/web",
2639 });
2640 const service = yield* makeService({
2641 projects: [{ ...resolved, repositoryIdentity: null }],
2642 providers: [
2643 fakeProvider(SourceControlProviderKind.make("github"), {
2644 getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")),
2645 }),
2646 ],
2647 resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null),
2648 });
2649
2650 const summary = yield* service.summary(
2651 { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 },
2652 { recoverTransientFailure: false },
2653 );
2654
2655 assert.strictEqual(summary.number, 7);
2656 }),
2657);
2658
2659it.effect("routes a hosted reference to another repository through a project on that host", () =>
2660 Effect.gen(function* () {
2661 const seen: Array<{ cwd: string; repository: string; host: string }> = [];
2662 const service = yield* makeService({
2663 projects: [
2664 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
2665 ],
2666 providers: [
2667 fakeProvider(SourceControlProviderKind.make("github"), {
2668 getChangeRequestSummary: (input) =>
2669 Effect.sync(() => {
2670 seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
2671 return changeRequest(7, "2026-07-02T00:00:00Z");
2672 }),
2673 }),
2674 ],
2675 });
2676
2677 const summary = yield* service.summary(
2678 { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 },
2679 { recoverTransientFailure: false },
2680 );
2681
2682 assert.strictEqual(summary.number, 7);
2683 assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]);
2684 }),
2685);
2686
2687it.effect("routes Azure reads and writes through the requested organization's checkout", () =>
2688 Effect.gen(function* () {
2689 const seen: string[] = [];
2690 const service = yield* makeService({
2691 projects: ["org-a", "org-b"].map((organization) =>
2692 project({
2693 id: organization,
2694 title: organization,
2695 workspaceRoot: `/${organization}`,
2696 repository: `${organization}/project/_git/web`,
2697 provider: SourceControlProviderKind.make("azure-devops"),
2698 host: "dev.azure.com",
2699 }),
2700 ),
2701 providers: [
2702 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
2703 getChangeRequestSummary: (input) =>
2704 Effect.sync(() => {
2705 seen.push(`read ${input.cwd} ${input.repository}`);
2706 return changeRequest(7, "2026-07-02T00:00:00Z");
2707 }),
2708 runAction: (input) =>
2709 Effect.sync(() => {
2710 seen.push(`write ${input.cwd} ${input.repository}`);
2711 }),
2712 }),
2713 ],
2714 });
2715 const reference = {
2716 projectId: "org-a" as ProjectId,
2717 host: "dev.azure.com",
2718 repository: "org-b/project/_git/web",
2719 number: 7,
2720 };
2721 yield* service.summary(reference, { recoverTransientFailure: false });
2722 yield* service.runAction({ ...reference, action: "merge" });
2723 assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]);
2724 }),
2725);
2726
2727it.effect.each([
2728 {
2729 host: "ssh.dev.azure.com",
2730 repository: "v3/org-b/project/web",
2731 remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web",
2732 },
2733 {
2734 host: "vs-ssh.visualstudio.com",
2735 repository: "v3/org-b/project/web",
2736 remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web",
2737 },
2738 {
2739 host: "org-b.visualstudio.com",
2740 repository: "DefaultCollection/project/_git/web",
2741 remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web",
2742 },
2743])("routes Azure URL reads and writes through a $host checkout", (checkout) =>
2744 Effect.gen(function* () {
2745 const seen: string[] = [];
2746 const target = project({
2747 id: "target",
2748 title: "target",
2749 workspaceRoot: "/target",
2750 provider: SourceControlProviderKind.make("azure-devops"),
2751 ...checkout,
2752 });
2753 const service = yield* makeService({
2754 projects: [
2755 ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>
2756 project({
2757 id: repository,
2758 title: repository,
2759 workspaceRoot: `/${repository}`,
2760 provider: SourceControlProviderKind.make("azure-devops"),
2761 host: "dev.azure.com",
2762 repository,
2763 }),
2764 ),
2765 target,
2766 ],
2767 providers: [
2768 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
2769 getChangeRequestSummary: (input) =>
2770 Effect.sync(() => {
2771 seen.push(`read ${input.cwd} ${input.repository}`);
2772 return changeRequest(7, "2026-07-02T00:00:00Z");
2773 }),
2774 runAction: (input) =>
2775 Effect.sync(() => {
2776 seen.push(`write ${input.cwd} ${input.repository}`);
2777 }),
2778 }),
2779 ],
2780 });
2781 const reference = {
2782 projectId: "org-a/project/_git/web" as ProjectId,
2783 host: "dev.azure.com",
2784 repository: "org-b/project/_git/web",
2785 number: 7,
2786 };
2787 yield* service.summary(reference, { recoverTransientFailure: false });
2788 yield* service.runAction({ ...reference, action: "merge" });
2789 assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]);
2790 }),
2791);
2792
2793it.effect("refuses Azure cross-organization reads and writes without its checkout", () =>
2794 Effect.gen(function* () {
2795 const service = yield* makeService({
2796 projects: [
2797 project({
2798 id: "org-a",
2799 title: "org-a",
2800 workspaceRoot: "/org-a",
2801 repository: "org-a/project/_git/web",
2802 provider: SourceControlProviderKind.make("azure-devops"),
2803 host: "dev.azure.com",
2804 }),
2805 ],
2806 providers: [
2807 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
2808 getChangeRequestSummary: () => Effect.die("must not read the wrong organization"),
2809 runAction: () => Effect.die("must not modify the wrong organization"),
2810 }),
2811 ],
2812 });
2813 const reference = {
2814 projectId: "org-a" as ProjectId,
2815 host: "dev.azure.com",
2816 repository: "org-b/project/_git/web",
2817 number: 7,
2818 };
2819 const readError = yield* Effect.flip(
2820 service.summary(reference, { recoverTransientFailure: false }),
2821 );
2822 const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" }));
2823 assert.strictEqual(readError._tag, "PullRequestUnavailableError");
2824 assert.strictEqual(writeError._tag, "PullRequestUnavailableError");
2825 }),
2826);
2827
2828it.effect.each([
2829 ["github.com", "github"],
2830 ["bitbucket.org", "bitbucket"],
2831 ["codeberg.org", "forgejo"],
2832] as const)("reads linked PR summaries and stacks on %s without a checkout", ([host, kind]) =>
2833 Effect.gen(function* () {
2834 const seen: Array<{ cwd: string; repository: string; host: string }> = [];
2835 const service = yield* makeService({
2836 projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })],
2837 providers: [
2838 fakeProvider(SourceControlProviderKind.make(kind), {
2839 getChangeRequestSummary: (input) =>
2840 Effect.sync(() => {
2841 seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
2842 return changeRequest(7, "2026-07-02T00:00:00Z");
2843 }),
2844 getChangeRequestStack: (input) =>
2845 Effect.sync(() => {
2846 seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
2847 return null;
2848 }),
2849 }),
2850 ],
2851 });
2852 const reference = {
2853 projectId: "inbox" as ProjectId,
2854 host,
2855 repository: "acme/api",
2856 number: 7,
2857 };
2858 const summary = yield* service.summary(reference, { recoverTransientFailure: false });
2859 const stack = yield* service.stack(reference, { includeDetails: false });
2860 assert.strictEqual(summary.title, "Change request 7");
2861 assert.strictEqual(summary.state, "open");
2862 assert.strictEqual(summary.projectId, reference.projectId);
2863 assert.strictEqual(stack, null);
2864 assert.deepStrictEqual(seen, [
2865 { cwd: "/home/alex", repository: "acme/api", host },
2866 { cwd: "/home/alex", repository: "acme/api", host },
2867 ]);
2868 }),
2869);
2870
2871it.effect("prefers a matching checkout over a non-repository project's directory", () =>
2872 Effect.gen(function* () {
2873 const seen: string[] = [];
2874 const service = yield* makeService({
2875 projects: [
2876 project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }),
2877 project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }),
2878 ],
2879 providers: [
2880 fakeProvider(SourceControlProviderKind.make("github"), {
2881 getChangeRequestSummary: (input) =>
2882 Effect.sync(() => {
2883 seen.push(input.cwd);
2884 return changeRequest(7, "2026-07-02T00:00:00Z");
2885 }),
2886 }),
2887 ],
2888 });
2889 yield* service.summary(
2890 {
2891 projectId: "inbox" as ProjectId,
2892 host: "github.com",
2893 repository: "acme/api",
2894 number: 7,
2895 },
2896 { recoverTransientFailure: false },
2897 );
2898 assert.deepStrictEqual(seen, ["/api"]);
2899 }),
2900);
2901
2902it.effect("requires an explicit host for a non-repository project's linked PR", () =>
2903 Effect.gen(function* () {
2904 const service = yield* makeService({
2905 projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })],
2906 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2907 });
2908 const error = yield* service
2909 .summary(
2910 {
2911 projectId: "inbox" as ProjectId,
2912 repository: "acme/api",
2913 number: 7,
2914 },
2915 { recoverTransientFailure: false },
2916 )
2917 .pipe(Effect.flip);
2918 assert.strictEqual(error._tag, "PullRequestUnavailableError");
2919 }),
2920);
2921
2922it.effect("refuses a public host reference without a selected project directory", () =>
2923 Effect.gen(function* () {
2924 const service = yield* makeService({
2925 projects: [],
2926 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2927 });
2928 const error = yield* service
2929 .summary(
2930 {
2931 projectId: "missing" as ProjectId,
2932 host: "github.com",
2933 repository: "acme/api",
2934 number: 7,
2935 },
2936 { recoverTransientFailure: false },
2937 )
2938 .pipe(Effect.flip);
2939 assert.strictEqual(error._tag, "PullRequestUnavailableError");
2940 }),
2941);
2942
2943it.effect(
2944 "requires a checkout for GitLab because its adapter does not explicitly target the host",
2945 () =>
2946 Effect.gen(function* () {
2947 const service = yield* makeService({
2948 projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })],
2949 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
2950 });
2951 const error = yield* service
2952 .summary(
2953 {
2954 projectId: "inbox" as ProjectId,
2955 host: "gitlab.com",
2956 repository: "acme/api",
2957 number: 7,
2958 },
2959 { recoverTransientFailure: false },
2960 )
2961 .pipe(Effect.flip);
2962 assert.strictEqual(error._tag, "PullRequestUnavailableError");
2963 }),
2964);
2965
2966it.effect("refuses a hosted reference when its provider is unavailable", () =>
2967 Effect.gen(function* () {
2968 const service = yield* makeService({
2969 projects: [
2970 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
2971 ],
2972 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2973 });
2974
2975 const error = yield* service
2976 .summary(
2977 {
2978 projectId: "frontend" as ProjectId,
2979 host: "gitlab.com",
2980 repository: "acme/api",
2981 number: 7,
2982 },
2983 { recoverTransientFailure: false },
2984 )
2985 .pipe(Effect.flip);
2986
2987 assert.strictEqual(error._tag, "PullRequestUnavailableError");
2988 }),
2989);
2990
2991it.effect("refuses a diff on a host that cannot produce one", () =>
2992 Effect.gen(function* () {
2993 const service = yield* makeService({
2994 projects: [
2995 project({
2996 id: "p1",
2997 title: "on azure",
2998 workspaceRoot: "/a",
2999 repository: "org/project",
3000 provider: SourceControlProviderKind.make("azure-devops"),
3001 }),
3002 ],
3003 providers: [
3004 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
3005 capabilities: {
3006 diff: false,
3007 comment: true,
3008 actions: ["merge", "close"],
3009 mergeMethods: ["merge"],
3010 search: true,
3011 reactions: true,
3012 review: FULL_REVIEW,
3013 reviewers: FULL_REVIEWERS,
3014 },
3015 getDiff: () => Effect.die("must not be called"),
3016 }),
3017 ],
3018 });
3019
3020 const error = yield* service
3021 .diff({ projectId: "p1" as ProjectId, repository: "org/project", number: 1 })
3022 .pipe(Effect.flip);
3023
3024 assert.strictEqual(error._tag, "PullRequestOperationError");
3025 }),
3026);
3027
3028it.effect("rejects an empty comment before reaching the host", () =>
3029 Effect.gen(function* () {
3030 const service = yield* makeService({
3031 projects: [
3032 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3033 ],
3034 providers: [
3035 fakeProvider(SourceControlProviderKind.make("github"), {
3036 comment: () => Effect.die("must not be called"),
3037 }),
3038 ],
3039 });
3040
3041 const error = yield* service
3042 .comment({
3043 projectId: "p1" as ProjectId,
3044 repository: "pingdotgg/t3code",
3045 number: 1,
3046 body: " ",
3047 })
3048 .pipe(Effect.flip);
3049
3050 assert.strictEqual(error._tag, "PullRequestOperationError");
3051 }),
3052);
3053
3054it.effect("refuses a verdict the host never claimed, without asking the provider", () =>
3055 Effect.gen(function* () {
3056 let submitted = false;
3057 const service = yield* makeService({
3058 projects: [
3059 project({
3060 id: "p1",
3061 title: "on gitlab",
3062 workspaceRoot: "/a",
3063 repository: "group/project",
3064 provider: SourceControlProviderKind.make("gitlab"),
3065 }),
3066 ],
3067 providers: [
3068 fakeProvider(SourceControlProviderKind.make("gitlab"), {
3069 capabilities: {
3070 diff: true,
3071 comment: true,
3072 actions: ["merge"],
3073 mergeMethods: ["merge"],
3074 search: true,
3075 reactions: true,
3076 // GitLab's shape: it approves, and has nothing that rejects.
3077 review: {
3078 inlineComment: true,
3079 reply: true,
3080 resolve: true,
3081 verdicts: ["comment", "approve"],
3082 },
3083 reviewers: FULL_REVIEWERS,
3084 },
3085 submitReview: () => {
3086 submitted = true;
3087 return Effect.void;
3088 },
3089 }),
3090 ],
3091 });
3092
3093 const error = yield* Effect.flip(
3094 service.submitReview({
3095 projectId: "p1" as ProjectId,
3096 repository: "group/project",
3097 number: 1,
3098 verdict: "request-changes",
3099 body: "no",
3100 comments: [],
3101 }),
3102 );
3103
3104 assert.strictEqual(error._tag, "PullRequestOperationError");
3105 assert.isFalse(submitted);
3106 }),
3107);
3108
3109it.effect("refuses line comments on a host that takes only a summary", () =>
3110 Effect.gen(function* () {
3111 const service = yield* makeService({
3112 projects: [
3113 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3114 ],
3115 providers: [
3116 fakeProvider(SourceControlProviderKind.make("github"), {
3117 capabilities: {
3118 diff: true,
3119 comment: true,
3120 actions: ["merge"],
3121 mergeMethods: ["merge"],
3122 search: true,
3123 reactions: true,
3124 review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] },
3125 reviewers: FULL_REVIEWERS,
3126 },
3127 submitReview: () => Effect.die("must not be called"),
3128 }),
3129 ],
3130 });
3131
3132 const error = yield* Effect.flip(
3133 service.submitReview({
3134 projectId: "p1" as ProjectId,
3135 repository: "pingdotgg/t3code",
3136 number: 1,
3137 verdict: "comment",
3138 body: "",
3139 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }],
3140 }),
3141 );
3142
3143 assert.strictEqual(error._tag, "PullRequestOperationError");
3144 }),
3145);
3146
3147it.effect(
3148 "refuses a review with neither a summary nor a comment, but lets an approval through",
3149 () =>
3150 Effect.gen(function* () {
3151 let approved = false;
3152 const service = yield* makeService({
3153 projects: [
3154 project({
3155 id: "p1",
3156 title: "t3code",
3157 workspaceRoot: "/a",
3158 repository: "pingdotgg/t3code",
3159 }),
3160 ],
3161 providers: [
3162 fakeProvider(SourceControlProviderKind.make("github"), {
3163 submitReview: () => {
3164 approved = true;
3165 return Effect.void;
3166 },
3167 }),
3168 ],
3169 });
3170 const reference = {
3171 projectId: "p1" as ProjectId,
3172 repository: "pingdotgg/t3code",
3173 number: 1,
3174 };
3175
3176 const error = yield* Effect.flip(
3177 service.submitReview({ ...reference, verdict: "comment", body: " ", comments: [] }),
3178 );
3179 assert.strictEqual(error._tag, "PullRequestOperationError");
3180
3181 // An approval is a verdict in itself, so it needs no words.
3182 yield* service.submitReview({ ...reference, verdict: "approve", body: "", comments: [] });
3183 assert.isTrue(approved);
3184 }),
3185);
3186
3187it.effect("refuses to resolve a conversation on a host that cannot", () =>
3188 Effect.gen(function* () {
3189 const service = yield* makeService({
3190 projects: [
3191 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3192 ],
3193 providers: [
3194 fakeProvider(SourceControlProviderKind.make("github"), {
3195 capabilities: {
3196 diff: true,
3197 comment: true,
3198 actions: ["merge"],
3199 mergeMethods: ["merge"],
3200 search: true,
3201 reactions: true,
3202 review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] },
3203 reviewers: FULL_REVIEWERS,
3204 },
3205 setThreadResolution: () => Effect.die("must not be called"),
3206 replyToThread: () => Effect.die("must not be called"),
3207 }),
3208 ],
3209 });
3210 const reference = {
3211 projectId: "p1" as ProjectId,
3212 repository: "pingdotgg/t3code",
3213 number: 1,
3214 };
3215
3216 const resolveError = yield* Effect.flip(
3217 service.setThreadResolution({ ...reference, threadId: "t1", resolved: true }),
3218 );
3219 const replyError = yield* Effect.flip(
3220 service.replyToThread({ ...reference, threadId: "t1", body: "hi" }),
3221 );
3222
3223 assert.strictEqual(resolveError._tag, "PullRequestOperationError");
3224 assert.strictEqual(replyError._tag, "PullRequestOperationError");
3225 }),
3226);
3227
3228it.effect("refuses to react on a host with no reactions", () =>
3229 Effect.gen(function* () {
3230 const service = yield* makeService({
3231 projects: [
3232 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3233 ],
3234 providers: [
3235 fakeProvider(SourceControlProviderKind.make("github"), {
3236 capabilities: {
3237 diff: true,
3238 comment: true,
3239 actions: ["merge"],
3240 mergeMethods: ["merge"],
3241 search: true,
3242 reactions: false,
3243 review: FULL_REVIEW,
3244 reviewers: FULL_REVIEWERS,
3245 },
3246 setReaction: () => Effect.die("must not be called"),
3247 }),
3248 ],
3249 });
3250
3251 const error = yield* Effect.flip(
3252 service.setReaction({
3253 projectId: "p1" as ProjectId,
3254 repository: "pingdotgg/t3code",
3255 number: 1,
3256 content: "heart",
3257 reacted: true,
3258 }),
3259 );
3260
3261 assert.strictEqual(error._tag, "PullRequestOperationError");
3262 }),
3263);
3264
3265it.effect("refuses to react on a host whose capabilities omit reactions entirely", () =>
3266 Effect.gen(function* () {
3267 const service = yield* makeService({
3268 projects: [
3269 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3270 ],
3271 providers: [
3272 fakeProvider(SourceControlProviderKind.make("github"), {
3273 capabilities: {
3274 diff: true,
3275 comment: true,
3276 actions: ["merge"],
3277 mergeMethods: ["merge"],
3278 search: true,
3279 review: FULL_REVIEW,
3280 reviewers: FULL_REVIEWERS,
3281 },
3282 setReaction: () => Effect.die("must not be called"),
3283 }),
3284 ],
3285 });
3286
3287 const error = yield* Effect.flip(
3288 service.setReaction({
3289 projectId: "p1" as ProjectId,
3290 repository: "pingdotgg/t3code",
3291 number: 1,
3292 content: "heart",
3293 reacted: true,
3294 }),
3295 );
3296
3297 assert.strictEqual(error._tag, "PullRequestOperationError");
3298 }),
3299);
3300
3301it.effect("passes a reaction through with its subject id on a host that has them", () =>
3302 Effect.gen(function* () {
3303 let received: {
3304 readonly subjectId: string | undefined;
3305 readonly content: string;
3306 readonly reacted: boolean;
3307 } | null = null;
3308 const service = yield* makeService({
3309 projects: [
3310 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3311 ],
3312 providers: [
3313 fakeProvider(SourceControlProviderKind.make("github"), {
3314 setReaction: (input) => {
3315 received = {
3316 subjectId: input.subjectId,
3317 content: input.content,
3318 reacted: input.reacted,
3319 };
3320 return Effect.void;
3321 },
3322 }),
3323 ],
3324 });
3325
3326 yield* service.setReaction({
3327 projectId: "p1" as ProjectId,
3328 repository: "pingdotgg/t3code",
3329 number: 1,
3330 subjectId: "IC_1",
3331 content: "heart",
3332 reacted: true,
3333 });
3334
3335 assert.deepStrictEqual(received, { subjectId: "IC_1", content: "heart", reacted: true });
3336 }),
3337);
3338
3339it.effect("invalidates the cached activity after reacting, like the other mutations", () =>
3340 Effect.gen(function* () {
3341 let activityCalls = 0;
3342 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3343 const service = yield* makeService({
3344 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3345 providers: [
3346 fakeProvider(SourceControlProviderKind.make("github"), {
3347 getChangeRequestActivity: () => {
3348 activityCalls += 1;
3349 return Effect.succeed({
3350 comments: [],
3351 commentCount: 0,
3352 commentsTruncated: false,
3353 reviewThreads: [],
3354 commits: [],
3355 });
3356 },
3357 }),
3358 ],
3359 });
3360
3361 yield* service.refreshAfterTurn(reference.projectId);
3362 const previousRefresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes));
3363 yield* service.activity(reference);
3364 assert.strictEqual(activityCalls, 1);
3365
3366 yield* service.setReaction({ ...reference, content: "heart", reacted: true });
3367 assert.isAbove(
3368 Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)),
3369 previousRefresh,
3370 );
3371 yield* service.activity(reference);
3372
3373 assert.strictEqual(activityCalls, 2);
3374 }),
3375);
3376
3377it.effect("refuses an empty reply before it reaches the host", () =>
3378 Effect.gen(function* () {
3379 const service = yield* makeService({
3380 projects: [
3381 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3382 ],
3383 providers: [
3384 fakeProvider(SourceControlProviderKind.make("github"), {
3385 replyToThread: () => Effect.die("must not be called"),
3386 }),
3387 ],
3388 });
3389
3390 const error = yield* Effect.flip(
3391 service.replyToThread({
3392 projectId: "p1" as ProjectId,
3393 repository: "pingdotgg/t3code",
3394 number: 1,
3395 threadId: "t1",
3396 body: " ",
3397 }),
3398 );
3399
3400 assert.strictEqual(error._tag, "PullRequestOperationError");
3401 }),
3402);
3403
3404it.effect("refuses a merge strategy the host does not offer", () =>
3405 Effect.gen(function* () {
3406 let ranWith: string | null = null;
3407 const service = yield* makeService({
3408 projects: [
3409 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3410 ],
3411 providers: [
3412 fakeProvider(SourceControlProviderKind.make("github"), {
3413 capabilities: {
3414 diff: true,
3415 comment: true,
3416 actions: ["merge"],
3417 // Azure DevOps's shape: it squashes as a completion option and has no rebase.
3418 mergeMethods: ["merge", "squash"],
3419 search: true,
3420 reactions: true,
3421 review: FULL_REVIEW,
3422 reviewers: FULL_REVIEWERS,
3423 },
3424 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
3425 runAction: (input) => {
3426 ranWith = input.mergeMethod ?? "merge";
3427 return Effect.void;
3428 },
3429 }),
3430 ],
3431 });
3432 const reference = {
3433 projectId: "p1" as ProjectId,
3434 repository: "pingdotgg/t3code",
3435 number: 1,
3436 };
3437
3438 // Every provider maps an unrecognised strategy to its own default, so letting this through
3439 // would merge with the wrong one rather than fail.
3440 const error = yield* Effect.flip(
3441 service.runAction({ ...reference, action: "merge", mergeMethod: "rebase" }),
3442 );
3443 assert.strictEqual(error._tag, "PullRequestOperationError");
3444 assert.strictEqual(ranWith, null);
3445
3446 yield* service.runAction({ ...reference, action: "merge", mergeMethod: "squash" });
3447 assert.strictEqual(ranWith, "squash");
3448 }),
3449);
3450
3451it.effect("hands the provider the host its repository lives on", () =>
3452 Effect.gen(function* () {
3453 const hosts: string[] = [];
3454 const service = yield* makeService({
3455 projects: [
3456 project({
3457 id: "p1",
3458 title: "enterprise",
3459 workspaceRoot: "/a",
3460 repository: "acme/web",
3461 host: "github.acme.dev",
3462 }),
3463 ],
3464 providers: [
3465 fakeProvider(SourceControlProviderKind.make("github"), {
3466 listChangeRequests: (input) => {
3467 hosts.push(input.host);
3468 return Effect.succeed({ items: [], truncated: false, continues: true });
3469 },
3470 }),
3471 ],
3472 });
3473
3474 yield* service.list({ state: "open" });
3475
3476 // The identity a project records is the path below its host, so the host has to travel
3477 // separately or a GitHub Enterprise repository is read off github.com instead.
3478 assert.deepStrictEqual(hosts, ["github.acme.dev"]);
3479 }),
3480);
3481
3482it.effect("asks every host the reader's search, rather than filtering what came back", () =>
3483 Effect.gen(function* () {
3484 const asked: Array<string | undefined> = [];
3485 const listing = (input: { readonly query?: string | undefined }) => {
3486 asked.push(input.query);
3487 return Effect.succeed({ items: [], truncated: false, continues: true });
3488 };
3489 const service = yield* makeService({
3490 projects: [
3491 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3492 project({
3493 id: "p2",
3494 title: "on gitlab",
3495 workspaceRoot: "/b",
3496 repository: "group/project",
3497 provider: SourceControlProviderKind.make("gitlab"),
3498 }),
3499 ],
3500 providers: [
3501 fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }),
3502 fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }),
3503 ],
3504 });
3505
3506 yield* service.list({ state: "open", query: "pull requests page" });
3507
3508 // A page holds one page per repository, so a search that stopped at the service could only
3509 // find what was already loaded.
3510 assert.deepStrictEqual(asked, ["pull requests page", "pull requests page"]);
3511 }),
3512);
3513
3514it.effect("asks for no search when the reader has typed nothing", () =>
3515 Effect.gen(function* () {
3516 const asked: Array<string | undefined> = [];
3517 const service = yield* makeService({
3518 projects: [
3519 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
3520 ],
3521 providers: [
3522 fakeProvider(SourceControlProviderKind.make("github"), {
3523 listChangeRequests: (input) => {
3524 asked.push(input.query);
3525 return Effect.succeed({ items: [], truncated: false, continues: true });
3526 },
3527 }),
3528 ],
3529 });
3530
3531 yield* service.list({ state: "open" });
3532
3533 assert.deepStrictEqual(asked, [undefined]);
3534 }),
3535);
3536
3537it.effect("asks another checkout who is signed in when the first one cannot answer", () =>
3538 Effect.gen(function* () {
3539 const asked: string[] = [];
3540 const service = yield* makeService({
3541 projects: [
3542 // One repository, checked out twice. The listing reads it once; the viewer lookup has
3543 // two places to ask.
3544 project({
3545 id: "p1",
3546 title: "t3code (stale worktree)",
3547 workspaceRoot: "/gone",
3548 repository: "pingdotgg/t3code",
3549 }),
3550 project({
3551 id: "p2",
3552 title: "t3code",
3553 workspaceRoot: "/healthy",
3554 repository: "pingdotgg/t3code",
3555 }),
3556 ],
3557 providers: [
3558 fakeProvider(SourceControlProviderKind.make("github"), {
3559 getViewer: (input) => {
3560 asked.push(input.cwd);
3561 return input.cwd === "/gone"
3562 ? Effect.fail(
3563 new PullRequestProviderError({
3564 provider: SourceControlProviderKind.make("github"),
3565 operation: "getViewer",
3566 reason: "failed",
3567 detail: "not a git repository",
3568 }),
3569 )
3570 : Effect.succeed("bilal");
3571 },
3572 listChangeRequests: () =>
3573 Effect.succeed({
3574 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
3575 truncated: false,
3576 continues: true,
3577 }),
3578 }),
3579 ],
3580 });
3581
3582 const result = yield* service.list({ state: "open" });
3583
3584 // De-duplicating the listing must not throw away the checkouts the fallback needs: the
3585 // host is readable, so it is read.
3586 assert.deepStrictEqual(asked, ["/gone", "/healthy"]);
3587 assert.strictEqual(result.entries.length, 1);
3588 assert.strictEqual(result.providers[0]?.configured, true);
3589 }),
3590);
3591
3592it.effect("refuses to ask for a review on a host that cannot, before any call is made", () =>
3593 Effect.gen(function* () {
3594 let asked = false;
3595 const service = yield* makeService({
3596 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3597 providers: [
3598 fakeProvider(SourceControlProviderKind.make("github"), {
3599 capabilities: {
3600 diff: true,
3601 comment: true,
3602 actions: ["merge"],
3603 mergeMethods: ["merge"],
3604 search: true,
3605 reactions: true,
3606 review: FULL_REVIEW,
3607 reviewers: { request: false, listCandidates: false },
3608 },
3609 getViewerPermissions: () => {
3610 asked = true;
3611 return Effect.die("must not be called");
3612 },
3613 setReviewerRequest: () => Effect.die("must not be called"),
3614 }),
3615 ],
3616 });
3617
3618 const error = yield* Effect.flip(
3619 service.requestReviewers({
3620 projectId: "p1" as ProjectId,
3621 repository: "acme/web",
3622 number: 1,
3623 reviewers: [{ id: "octocat", kind: "user" }],
3624 requested: true,
3625 }),
3626 );
3627
3628 assert.strictEqual(error._tag, "PullRequestOperationError");
3629 assert.include(error.message, "cannot ask somebody for a review.");
3630 assert.isFalse(asked);
3631 }),
3632);
3633
3634it.effect("refuses the candidate list on a host that has no such list to give", () =>
3635 Effect.gen(function* () {
3636 const service = yield* makeService({
3637 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3638 providers: [
3639 fakeProvider(SourceControlProviderKind.make("github"), {
3640 capabilities: {
3641 diff: false,
3642 comment: false,
3643 actions: ["merge"],
3644 mergeMethods: ["merge"],
3645 search: false,
3646 reactions: true,
3647 review: FULL_REVIEW,
3648 // Azure's shape: it takes a reviewer, and names nobody who could be one.
3649 reviewers: { request: true, listCandidates: false },
3650 },
3651 listReviewerCandidates: () => Effect.die("must not be called"),
3652 }),
3653 ],
3654 });
3655
3656 const error = yield* Effect.flip(
3657 service.reviewerCandidates({
3658 projectId: "p1" as ProjectId,
3659 repository: "acme/web",
3660 number: 1,
3661 }),
3662 );
3663
3664 assert.strictEqual(error._tag, "PullRequestOperationError");
3665 assert.include(error.message, "cannot say who may review a change request.");
3666 }),
3667);
3668
3669it.effect("refuses a review request this viewer may not make, and says what access it takes", () =>
3670 Effect.gen(function* () {
3671 let sent = false;
3672 const service = yield* makeService({
3673 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3674 providers: [
3675 fakeProvider(SourceControlProviderKind.make("github"), {
3676 // The host asks for reviews; this account only reads the repository.
3677 getViewerPermissions: () =>
3678 Effect.succeed({
3679 actions: ["ready", "draft", "close", "reopen"],
3680 comment: true,
3681 resolve: true,
3682 verdicts: ["comment", "approve", "request-changes"],
3683 requestReviewers: false,
3684 }),
3685 setReviewerRequest: () => {
3686 sent = true;
3687 return Effect.void;
3688 },
3689 }),
3690 ],
3691 });
3692
3693 const error = yield* Effect.flip(
3694 service.requestReviewers({
3695 projectId: "p1" as ProjectId,
3696 repository: "acme/web",
3697 number: 1,
3698 reviewers: [{ id: "octocat", kind: "user" }],
3699 requested: true,
3700 }),
3701 );
3702
3703 assert.strictEqual(error._tag, "PullRequestOperationError");
3704 assert.include(error.message, "You need write access on this repository to ask for a review.");
3705 assert.isFalse(sent);
3706 }),
3707);
3708
3709it.effect("keeps the menu from a viewer who may not ask, which is all it is for", () =>
3710 Effect.gen(function* () {
3711 const service = yield* makeService({
3712 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3713 providers: [
3714 fakeProvider(SourceControlProviderKind.make("github"), {
3715 getViewerPermissions: () =>
3716 Effect.succeed({
3717 actions: [],
3718 comment: true,
3719 resolve: false,
3720 verdicts: ["comment", "approve", "request-changes"],
3721 requestReviewers: false,
3722 }),
3723 listReviewerCandidates: () => Effect.die("must not be called"),
3724 }),
3725 ],
3726 });
3727
3728 const error = yield* Effect.flip(
3729 service.reviewerCandidates({
3730 projectId: "p1" as ProjectId,
3731 repository: "acme/web",
3732 number: 1,
3733 }),
3734 );
3735
3736 assert.include(error.message, "You need write access on this repository to ask for a review.");
3737 }),
3738);
3739
3740it.effect("hands the host's own candidate list back, and asks for it with the change request", () =>
3741 Effect.gen(function* () {
3742 let askedFor: number | null = null;
3743 const service = yield* makeService({
3744 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3745 providers: [
3746 fakeProvider(SourceControlProviderKind.make("github"), {
3747 listReviewerCandidates: (input) => {
3748 askedFor = input.number;
3749 return Effect.succeed({
3750 candidates: [
3751 {
3752 id: "octocat",
3753 kind: "user",
3754 login: "octocat",
3755 name: null,
3756 avatarUrl: null,
3757 isRequested: true,
3758 },
3759 ],
3760 truncated: false,
3761 continues: true,
3762 });
3763 },
3764 }),
3765 ],
3766 });
3767
3768 const list = yield* service.reviewerCandidates({
3769 projectId: "p1" as ProjectId,
3770 repository: "acme/web",
3771 number: 4,
3772 });
3773
3774 assert.strictEqual(askedFor, 4);
3775 assert.deepStrictEqual(
3776 list.candidates.map((candidate) => candidate.login),
3777 ["octocat"],
3778 );
3779 }),
3780);
3781
3782it.effect("refuses a label change on a host that has not said it takes one", () =>
3783 Effect.gen(function* () {
3784 let changed = false;
3785 const service = yield* makeService({
3786 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3787 providers: [
3788 fakeProvider(SourceControlProviderKind.make("github"), {
3789 // The method is there; the capability that would let it be called is not.
3790 setLabels: () => {
3791 changed = true;
3792 return Effect.void;
3793 },
3794 }),
3795 ],
3796 });
3797
3798 const error = yield* Effect.flip(
3799 service.setLabels({
3800 projectId: "p1" as ProjectId,
3801 repository: "acme/web",
3802 number: 1,
3803 labels: ["bug"],
3804 applied: true,
3805 }),
3806 );
3807
3808 assert.strictEqual(error._tag, "PullRequestOperationError");
3809 assert.include(error.message, "cannot change the labels");
3810 assert.isFalse(changed);
3811 }),
3812);
3813
3814it.effect("refuses a label change this viewer may not make, and says what access it takes", () =>
3815 Effect.gen(function* () {
3816 let changed = false;
3817 const service = yield* makeService({
3818 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3819 providers: [
3820 fakeProvider(SourceControlProviderKind.make("github"), {
3821 capabilities: {
3822 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
3823 labels: true,
3824 },
3825 getViewerPermissions: () =>
3826 Effect.succeed({
3827 actions: [],
3828 comment: true,
3829 resolve: false,
3830 verdicts: ["comment", "approve", "request-changes"],
3831 requestReviewers: false,
3832 labels: false,
3833 }),
3834 listLabelCandidates: () => Effect.die("must not be called"),
3835 setLabels: () => {
3836 changed = true;
3837 return Effect.void;
3838 },
3839 }),
3840 ],
3841 });
3842
3843 const listError = yield* Effect.flip(
3844 service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }),
3845 );
3846 assert.include(listError.message, "You need triage access on this repository");
3847
3848 const error = yield* Effect.flip(
3849 service.setLabels({
3850 projectId: "p1" as ProjectId,
3851 repository: "acme/web",
3852 number: 1,
3853 labels: ["bug"],
3854 applied: true,
3855 }),
3856 );
3857 assert.include(error.message, "You need triage access on this repository");
3858 assert.isFalse(changed);
3859 }),
3860);
3861
3862it.effect("hands a label change to the host, and reads the labels back for the menu", () =>
3863 Effect.gen(function* () {
3864 let received: { labels: ReadonlyArray<string>; applied: boolean } | null = null;
3865 const service = yield* makeService({
3866 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3867 providers: [
3868 fakeProvider(SourceControlProviderKind.make("github"), {
3869 capabilities: {
3870 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
3871 labels: true,
3872 },
3873 listLabelCandidates: () =>
3874 Effect.succeed({
3875 candidates: [{ name: "bug", color: null, description: null, isApplied: false }],
3876 truncated: false,
3877 }),
3878 setLabels: (input) => {
3879 received = { labels: input.labels, applied: input.applied };
3880 return Effect.void;
3881 },
3882 }),
3883 ],
3884 });
3885
3886 const list = yield* service.labelCandidates({
3887 projectId: "p1" as ProjectId,
3888 repository: "acme/web",
3889 number: 4,
3890 });
3891 assert.deepStrictEqual(
3892 list.candidates.map((label) => label.name),
3893 ["bug"],
3894 );
3895
3896 yield* service.setLabels({
3897 projectId: "p1" as ProjectId,
3898 repository: "acme/web",
3899 number: 4,
3900 labels: ["bug"],
3901 applied: false,
3902 });
3903 assert.deepStrictEqual(received, { labels: ["bug"], applied: false });
3904 }),
3905);
3906
3907it.effect("answers a repeated listing from cache, and concurrent readers share one request", () =>
3908 Effect.gen(function* () {
3909 let hostCalls = 0;
3910 const service = yield* makeService({
3911 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3912 providers: [
3913 fakeProvider(SourceControlProviderKind.make("github"), {
3914 listChangeRequests: () => {
3915 hostCalls += 1;
3916 return Effect.succeed({
3917 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
3918 truncated: false,
3919 continues: false,
3920 });
3921 },
3922 }),
3923 ],
3924 });
3925
3926 yield* Effect.all([service.list({ state: "open" }), service.list({ state: "open" })], {
3927 concurrency: "unbounded",
3928 });
3929 yield* service.list({ state: "open" });
3930 assert.strictEqual(hostCalls, 1);
3931
3932 // A different filter is a different answer, not a cache hit.
3933 yield* service.list({ state: "all" });
3934 assert.strictEqual(hostCalls, 2);
3935 }),
3936);
3937
3938it.effect.each(["list", "listStats"] as const)(
3939 "retries a replacement %s read that joins a canceled lookup",
3940 (operation) =>
3941 Effect.gen(function* () {
3942 const started = yield* Deferred.make<void>();
3943 const stopping = yield* Deferred.make<void>();
3944 const finishShutdown = yield* Deferred.make<void>();
3945 let hostCalls = 0;
3946 const lookup = Effect.gen(function* () {
3947 if (++hostCalls !== 1) return;
3948 yield* Deferred.succeed(started, undefined);
3949 return yield* Effect.never.pipe(
3950 Effect.onInterrupt(() =>
3951 Deferred.succeed(stopping, undefined).pipe(
3952 Effect.andThen(Deferred.await(finishShutdown)),
3953 ),
3954 ),
3955 );
3956 });
3957 const service = yield* makeService({
3958 projects: [
3959 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3960 ],
3961 providers: [
3962 fakeProvider(SourceControlProviderKind.make("github"), {
3963 listChangeRequests: () =>
3964 lookup.pipe(
3965 Effect.as({
3966 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
3967 truncated: false,
3968 continues: false,
3969 }),
3970 ),
3971 listChangeRequestStats: () =>
3972 lookup.pipe(
3973 Effect.as([{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]),
3974 ),
3975 }),
3976 ],
3977 });
3978 const read =
3979 operation === "list"
3980 ? service
3981 .list({ state: "open" })
3982 .pipe(Effect.map((result) => result.entries.map((entry) => entry.number)))
3983 : service
3984 .listStats({
3985 refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }],
3986 })
3987 .pipe(Effect.map((result) => result.stats.map((stat) => stat.number)));
3988 const original = yield* Effect.forkScoped(read);
3989 yield* Deferred.await(started);
3990 const cancellation = yield* Effect.forkScoped(Fiber.interrupt(original));
3991 yield* Deferred.await(stopping);
3992 // Join the old entry while its lookup is still finishing cancellation.
3993 const replacement = yield* Effect.forkScoped(read, { startImmediately: true });
3994 yield* Deferred.succeed(finishShutdown, undefined);
3995 yield* Fiber.join(cancellation);
3996 const canceled = yield* Fiber.await(original);
3997 assert.isTrue(Exit.isFailure(canceled));
3998 if (Exit.isFailure(canceled)) assert.isTrue(Cause.hasInterruptsOnly(canceled.cause));
3999 assert.deepStrictEqual(yield* Fiber.join(replacement), [1]);
4000 assert.strictEqual(hostCalls, 2);
4001 }),
4002);
4003
4004it.effect("shares one cold viewer lookup across distinct concurrent lists", () =>
4005 Effect.gen(function* () {
4006 let viewerCalls = 0;
4007 let listCalls = 0;
4008 const service = yield* makeService({
4009 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4010 providers: [
4011 fakeProvider(SourceControlProviderKind.make("github"), {
4012 getViewer: () =>
4013 Effect.sync(() => {
4014 viewerCalls += 1;
4015 }).pipe(Effect.andThen(Effect.yieldNow), Effect.as("bilal")),
4016 listChangeRequests: () =>
4017 Effect.sync(() => {
4018 listCalls += 1;
4019 return { items: [], truncated: false, continues: true };
4020 }),
4021 }),
4022 ],
4023 });
4024
4025 yield* Effect.forEach(
4026 ["all", "authored", "reviewing"],
4027 (involvement) =>
4028 service.list({
4029 state: "open",
4030 involvement: involvement as "all" | "authored" | "reviewing",
4031 }),
4032 { concurrency: "unbounded" },
4033 );
4034
4035 assert.strictEqual(viewerCalls, 1);
4036 assert.strictEqual(listCalls, 3);
4037 }),
4038);
4039
4040it.effect("uses five host reads for the normal indexed-repository page workflow", () =>
4041 Effect.gen(function* () {
4042 let viewerCalls = 0;
4043 let searchCalls = 0;
4044 let fallbackCalls = 0;
4045 let statsCalls = 0;
4046 const service = yield* makeService({
4047 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4048 providers: [
4049 fakeProvider(SourceControlProviderKind.make("github"), {
4050 getViewer: () =>
4051 Effect.sync(() => {
4052 viewerCalls += 1;
4053 return "bilal";
4054 }),
4055 listChangeRequestsAcross: (input) =>
4056 Effect.sync(() => {
4057 searchCalls += 1;
4058 return {
4059 items:
4060 input.involvement === "all"
4061 ? [batchedChangeRequest(1, "acme/web", "2026-07-02T00:00:00Z")]
4062 : [],
4063 truncated: false,
4064 };
4065 }),
4066 listChangeRequests: () =>
4067 Effect.sync(() => {
4068 fallbackCalls += 1;
4069 return { items: [], truncated: false, continues: true };
4070 }),
4071 listChangeRequestStats: () =>
4072 Effect.sync(() => {
4073 statsCalls += 1;
4074 return [{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }];
4075 }),
4076 }),
4077 ],
4078 });
4079
4080 const baseline = yield* service.list({ state: "open", involvement: "all" });
4081 yield* Effect.all(
4082 [
4083 service.list({ state: "open", involvement: "authored" }),
4084 service.list({ state: "open", involvement: "reviewing" }),
4085 ],
4086 { concurrency: "unbounded" },
4087 );
4088 yield* service.listStats({
4089 refs: baseline.entries.map(({ projectId, repository, number }) => ({
4090 projectId,
4091 repository,
4092 number,
4093 })),
4094 });
4095
4096 assert.deepStrictEqual(
4097 { viewerCalls, searchCalls, fallbackCalls, statsCalls },
4098 { viewerCalls: 1, searchCalls: 3, fallbackCalls: 0, statsCalls: 1 },
4099 );
4100 }),
4101);
4102
4103it.effect("returns the refreshed listing on the first read after its cache expires", () =>
4104 Effect.gen(function* () {
4105 let hostCalls = 0;
4106 const service = yield* makeService({
4107 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4108 providers: [
4109 fakeProvider(SourceControlProviderKind.make("github"), {
4110 listChangeRequests: () => {
4111 hostCalls += 1;
4112 return Effect.succeed({
4113 items: [changeRequest(hostCalls, "2026-07-02T00:00:00Z")],
4114 truncated: false,
4115 continues: false,
4116 });
4117 },
4118 }),
4119 ],
4120 });
4121
4122 const first = yield* service.list({ state: "open" });
4123 assert.deepStrictEqual(
4124 first.entries.map((entry) => entry.number),
4125 [1],
4126 );
4127
4128 yield* TestClock.adjust("31 seconds");
4129 const refreshed = yield* service.list({ state: "open" });
4130
4131 assert.strictEqual(hostCalls, 2);
4132 assert.deepStrictEqual(
4133 refreshed.entries.map((entry) => entry.number),
4134 [2],
4135 );
4136 }),
4137);
4138
4139it.effect("a listing narrowed to some projects is its own cache entry", () =>
4140 Effect.gen(function* () {
4141 const asked: ReadonlyArray<string>[] = [];
4142 const service = yield* makeService({
4143 projects: [
4144 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4145 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
4146 ],
4147 providers: [
4148 fakeProvider(SourceControlProviderKind.make("github"), {
4149 listChangeRequestsAcross: (input) => {
4150 asked.push(input.repositories);
4151 return Effect.succeed({
4152 items: input.repositories.map((repository, index) =>
4153 batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"),
4154 ),
4155 truncated: false,
4156 });
4157 },
4158 }),
4159 ],
4160 });
4161
4162 yield* service.list({ state: "open" });
4163 const narrowed = yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] });
4164
4165 // The narrowing is part of the key, so it reads its own scope instead of the wider answer.
4166 assert.deepStrictEqual(asked, [["acme/web", "acme/docs"], ["acme/docs"]]);
4167 assert.deepStrictEqual(
4168 narrowed.entries.map((entry) => entry.repository),
4169 ["acme/docs"],
4170 );
4171
4172 // Asking again with the same narrowing, ordered differently, is still the same answer.
4173 yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] });
4174 assert.strictEqual(asked.length, 2);
4175 }),
4176);
4177
4178it.effect(
4179 "keeps listing freshness tied to read start when filtered reads finish out of order",
4180 () =>
4181 Effect.gen(function* () {
4182 const olderStarted = yield* Deferred.make<void>();
4183 const releaseOlder = yield* Deferred.make<void>();
4184 let reads = 0;
4185 const updatedAt = "2026-07-02T00:00:00Z";
4186 const service = yield* makeService({
4187 projects: [
4188 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4189 ],
4190 providers: [
4191 fakeProvider(SourceControlProviderKind.make("github"), {
4192 listChangeRequests: ({ filters }) =>
4193 Effect.gen(function* () {
4194 reads += 1;
4195 const older = filters?.checks === "failing";
4196 if (older) {
4197 yield* Deferred.succeed(olderStarted, undefined);
4198 yield* Deferred.await(releaseOlder);
4199 }
4200 return {
4201 items: [
4202 {
4203 ...changeRequest(1, updatedAt),
4204 checksState: older ? ("failing" as const) : ("passing" as const),
4205 mergeability: older ? ("mergeable" as const) : ("conflicting" as const),
4206 },
4207 ],
4208 truncated: false,
4209 continues: false,
4210 };
4211 }),
4212 }),
4213 ],
4214 });
4215 const olderInput = { state: "open" as const, filters: { checks: "failing" as const } };
4216 const newerInput = { state: "open" as const, filters: { checks: "passing" as const } };
4217
4218 const olderRead = yield* service.list(olderInput).pipe(Effect.forkChild());
4219 yield* Deferred.await(olderStarted);
4220 yield* TestClock.adjust("1 second");
4221 const newer = yield* service.list(newerInput);
4222 yield* Deferred.succeed(releaseOlder, undefined);
4223 const older = yield* Fiber.join(olderRead);
4224
4225 assert.strictEqual(older.entries[0]?.checksState, "failing");
4226 assert.strictEqual(older.entries[0]?.mergeability, "mergeable");
4227 assert.strictEqual(newer.entries[0]?.checksState, "passing");
4228 assert.strictEqual(newer.entries[0]?.mergeability, "conflicting");
4229 assert.strictEqual(typeof older.entries[0]?.observedAt, "number");
4230 assert.strictEqual(typeof newer.entries[0]?.observedAt, "number");
4231 assert.isBelow(older.entries[0]!.observedAt!, newer.entries[0]!.observedAt!);
4232
4233 const cachedOlder = yield* service.list(olderInput);
4234 assert.strictEqual(cachedOlder.entries[0]?.observedAt, older.entries[0]?.observedAt);
4235 assert.strictEqual(reads, 2);
4236 }),
4237);
4238
4239it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () =>
4240 Effect.gen(function* () {
4241 const calls: string[] = [];
4242 const service = yield* makeService({
4243 projects: [
4244 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4245 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
4246 ],
4247 providers: [
4248 fakeProvider(SourceControlProviderKind.make("github"), {
4249 getChangeRequest: (input) =>
4250 Effect.sync(() => {
4251 calls.push(`${input.repository}/${input.number}`);
4252 return { ...hostedChangeRequest("body"), number: input.number };
4253 }),
4254 }),
4255 ],
4256 });
4257 const refs = [
4258 { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 },
4259 { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 },
4260 { projectId: "p2" as ProjectId, repository: "acme/docs", number: 3 },
4261 ];
4262 const readAll = Effect.forEach(refs, (ref) => service.summary({ ...ref, allowStale: false }));
4263 yield* readAll;
4264 yield* service.invalidate({ reference: { ...refs[0]!, host: "github.com" } });
4265 yield* readAll;
4266 assert.deepStrictEqual(calls, ["acme/web/1", "acme/web/2", "acme/docs/3", "acme/web/1"]);
4267 yield* service.comment({ ...refs[0]!, body: "hello" });
4268 yield* readAll;
4269 assert.deepStrictEqual(calls.slice(4), ["acme/web/1"]);
4270 yield* service.refreshAfterTurn("p1" as ProjectId);
4271 yield* readAll;
4272 assert.deepStrictEqual(calls.slice(5), ["acme/web/1", "acme/web/2"]);
4273 }),
4274);
4275
4276it.effect(
4277 "keeps matching PR numbers on different hosts separate and refreshes the serving project",
4278 () =>
4279 Effect.gen(function* () {
4280 const hosts: string[] = [];
4281 const service = yield* makeService({
4282 projects: [
4283 project({ id: "p1", title: "public", workspaceRoot: "/a", repository: "acme/web" }),
4284 project({
4285 id: "p2",
4286 title: "enterprise",
4287 workspaceRoot: "/b",
4288 repository: "acme/web",
4289 host: "enterprise.test",
4290 }),
4291 ],
4292 providers: [
4293 fakeProvider(SourceControlProviderKind.make("github"), {
4294 getChangeRequest: (input) =>
4295 Effect.sync(() => {
4296 hosts.push(input.host);
4297 return hostedChangeRequest("body");
4298 }),
4299 }),
4300 ],
4301 });
4302 const own = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
4303 const other = { ...own, host: "enterprise.test" };
4304 const readBoth = Effect.all([service.summary(own), service.summary(other)]);
4305 yield* readBoth;
4306 yield* service.invalidate({ reference: { ...own, host: "github.com" } });
4307 yield* readBoth;
4308 assert.deepStrictEqual(hosts, ["github.com", "enterprise.test", "github.com"]);
4309 yield* service.invalidate({ reference: own });
4310 yield* readBoth;
4311 assert.deepStrictEqual(hosts.slice(3), ["github.com"]);
4312 yield* service.refreshAfterTurn("p2" as ProjectId);
4313 yield* readBoth;
4314 assert.deepStrictEqual(hosts.slice(4), ["enterprise.test"]);
4315 }),
4316);
4317
4318it.effect("does not revive old summaries when project epochs are evicted", () =>
4319 Effect.gen(function* () {
4320 let title = "old";
4321 const service = yield* makeService({
4322 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4323 providers: [
4324 fakeProvider(SourceControlProviderKind.make("github"), {
4325 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }),
4326 }),
4327 ],
4328 });
4329 const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
4330 assert.strictEqual((yield* service.summary(ref))?.title, "old");
4331 title = "new";
4332 yield* service.refreshAfterTurn(ref.projectId);
4333 assert.strictEqual((yield* service.summary(ref))?.title, "new");
4334 for (let index = 0; index < 2048; index++)
4335 yield* service.refreshAfterTurn(`project-${index}` as ProjectId);
4336 assert.strictEqual((yield* service.summary(ref))?.title, "new");
4337 }),
4338);
4339
4340it.effect("explicit and turn invalidations make the next listing ask the host again", () =>
4341 Effect.gen(function* () {
4342 let hostCalls = 0;
4343 let viewerCalls = 0;
4344 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
4345 const service = yield* makeService({
4346 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4347 providers: [
4348 fakeProvider(SourceControlProviderKind.make("github"), {
4349 getViewer: () => {
4350 viewerCalls += 1;
4351 return Effect.succeed("bilal");
4352 },
4353 listChangeRequests: () => {
4354 hostCalls += 1;
4355 return Effect.succeed({ items: [], truncated: false, continues: false });
4356 },
4357 }),
4358 ],
4359 });
4360
4361 yield* service.list({ state: "open" });
4362 yield* service.invalidate({});
4363 yield* service.list({ state: "open" });
4364 assert.strictEqual(hostCalls, 2);
4365 assert.strictEqual(viewerCalls, 2);
4366
4367 // Forgetting one change request leaves the listings shared.
4368 yield* service.invalidate({ reference });
4369 yield* service.list({ state: "open" });
4370 assert.strictEqual(hostCalls, 2);
4371 yield* service.refreshAfterTurn("p1" as ProjectId);
4372 const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes));
4373 yield* service.list({ state: "open" });
4374 assert.isAbove(refresh, 0);
4375 assert.strictEqual(hostCalls, 3);
4376 }),
4377);
4378
4379it.effect("close and reopen notify subscribed readers after invalidating their cached state", () =>
4380 Effect.scoped(
4381 Effect.gen(function* () {
4382 let hostCalls = 0;
4383 let state: "open" | "closed" = "open";
4384 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
4385 const service = yield* makeService({
4386 projects: [
4387 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4388 ],
4389 providers: [
4390 fakeProvider(SourceControlProviderKind.make("github"), {
4391 getChangeRequestSummary: () =>
4392 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),
4393 runAction: (input) =>
4394 Effect.sync(() => {
4395 state = input.action === "close" ? "closed" : "open";
4396 }),
4397 listChangeRequests: () => {
4398 hostCalls += 1;
4399 return Effect.succeed({ items: [], truncated: false, continues: false });
4400 },
4401 }),
4402 ],
4403 });
4404
4405 yield* service.refreshAfterTurn(reference.projectId);
4406 yield* service.list({ state: "open" });
4407 assert.strictEqual((yield* service.summary(reference)).state, "open");
4408 for (const action of ["close", "reopen"] as const) {
4409 const refreshed = yield* service.subscribeRefreshes.pipe(
4410 Stream.drop(1),
4411 Stream.take(1),
4412 Stream.mapEffect(() =>
4413 Effect.gen(function* () {
4414 yield* service.list({ state: "open" });
4415 return yield* service.summary(reference);
4416 }),
4417 ),
4418 Stream.runHead,
4419 Effect.forkChild({ startImmediately: true }),
4420 );
4421 yield* service.runAction({ ...reference, action });
4422 assert.strictEqual(
4423 Option.getOrThrow(yield* Fiber.join(refreshed)).state,
4424 action === "close" ? "closed" : "open",
4425 );
4426 }
4427 assert.strictEqual(hostCalls, 3);
4428 }),
4429 ),
4430);
4431
4432it.effect("explicit invalidation refreshes origin readers after a routed host mutation", () =>
4433 Effect.scoped(
4434 Effect.gen(function* () {
4435 let state: "open" | "closed" = "open";
4436 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
4437 const service = yield* makeService({
4438 projects: [
4439 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4440 ],
4441 providers: [
4442 fakeProvider(SourceControlProviderKind.make("github"), {
4443 getChangeRequestSummary: () =>
4444 Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }),
4445 }),
4446 ],
4447 });
4448 yield* service.refreshAfterTurn(reference.projectId);
4449 let revision = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes));
4450 // The sync reactor invalidates before reading; it must not notify itself again.
4451 yield* service.invalidate({ reference });
4452 assert.strictEqual(
4453 Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)),
4454 revision,
4455 );
4456 assert.strictEqual((yield* service.summary(reference)).state, "open");
4457
4458 for (const nextState of ["closed", "open"] as const) {
4459 const refreshed = yield* service.subscribeRefreshes.pipe(
4460 Stream.drop(1),
4461 Stream.take(1),
4462 Stream.mapEffect((nextRevision) =>
4463 service
4464 .summary(reference)
4465 .pipe(Effect.map((summary) => ({ revision: nextRevision, state: summary.state }))),
4466 ),
4467 Stream.runHead,
4468 Effect.forkChild({ startImmediately: true }),
4469 );
4470 state = nextState;
4471 yield* service.invalidate({ reference }, { notifyReaders: true });
4472 const result = Option.getOrThrow(yield* Fiber.join(refreshed));
4473 assert.strictEqual(result.state, nextState);
4474 assert.isAbove(result.revision, revision);
4475 revision = result.revision;
4476 }
4477 }),
4478 ),
4479);
4480
4481it.effect("does not cache a failed listing", () =>
4482 Effect.gen(function* () {
4483 let hostCalls = 0;
4484 const service = yield* makeService({
4485 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4486 providers: [
4487 fakeProvider(SourceControlProviderKind.make("github"), {
4488 // The viewer lookup is what fails the whole listing rather than one repository.
4489 getViewer: () => {
4490 hostCalls += 1;
4491 return hostCalls === 1 ? Effect.fail(requestFailed) : Effect.succeed("bilal");
4492 },
4493 }),
4494 ],
4495 });
4496
4497 const error = yield* Effect.flip(service.list({ state: "open" }));
4498 assert.strictEqual(error._tag, "PullRequestOperationError");
4499 const second = yield* service.list({ state: "open" });
4500 assert.strictEqual(hostCalls, 2);
4501 assert.strictEqual(second.providers[0]?.configured, true);
4502 }),
4503);
4504
4505it.effect("reads a host's repositories in one search, and files the rows back under each", () =>
4506 Effect.gen(function* () {
4507 const asked: Array<ReadonlyArray<string>> = [];
4508 const separately: string[] = [];
4509 const service = yield* makeService({
4510 projects: [
4511 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
4512 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
4513 project({
4514 id: "p3",
4515 title: "on gitlab",
4516 workspaceRoot: "/c",
4517 repository: "group/project",
4518 provider: SourceControlProviderKind.make("gitlab"),
4519 }),
4520 ],
4521 providers: [
4522 fakeProvider(SourceControlProviderKind.make("github"), {
4523 listChangeRequests: ({ repository }) => {
4524 separately.push(repository);
4525 return Effect.succeed({ items: [], truncated: false, continues: true });
4526 },
4527 listChangeRequestsAcross: (input) => {
4528 asked.push(input.repositories);
4529 return Effect.succeed({
4530 items: [
4531 batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z"),
4532 batchedChangeRequest(2, "pingdotgg/t3code", "2026-07-02T00:00:00Z"),
4533 ],
4534 truncated: false,
4535 });
4536 },
4537 }),
4538 // A host with no search across repositories keeps being asked one at a time.
4539 fakeProvider(SourceControlProviderKind.make("gitlab"), {
4540 listChangeRequests: ({ repository }) => {
4541 separately.push(repository);
4542 return Effect.succeed({
4543 items: [changeRequest(3, "2026-07-01T00:00:00Z")],
4544 truncated: false,
4545 continues: true,
4546 });
4547 },
4548 }),
4549 ],
4550 });
4551
4552 const result = yield* service.list({ state: "open" });
4553
4554 assert.deepStrictEqual(asked, [["pingdotgg/t3code", "acme/web"]]);
4555 assert.deepStrictEqual(separately, ["group/project"]);
4556 // Ordered by update across every host, and each row under the project whose repository it
4557 // came from.
4558 assert.deepStrictEqual(
4559 result.entries.map((entry) => [entry.projectId, entry.number]),
4560 [
4561 ["p2", 1],
4562 ["p1", 2],
4563 ["p3", 3],
4564 ],
4565 );
4566 }),
4567);
4568it.effect("carries every repository of a slice on from the oldest row in it", () =>
4569 Effect.gen(function* () {
4570 const service = yield* makeService({
4571 projects: [
4572 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
4573 project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }),
4574 project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }),
4575 ],
4576 providers: [
4577 fakeProvider(SourceControlProviderKind.make("github"), {
4578 listChangeRequestsAcross: () =>
4579 Effect.succeed({
4580 items: [
4581 batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z"),
4582 batchedChangeRequest(2, "pingdotgg/t3code", "2026-07-02T00:00:00Z"),
4583 batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"),
4584 ],
4585 truncated: true,
4586 }),
4587 }),
4588 ],
4589 });
4590
4591 const result = yield* service.list({ state: "open" });
4592
4593 // The boundary is the oldest row of the whole slice, not of each repository: `acme/web` has
4594 // been read past its newest row, so only the rows sent at the boundary are named for it.
4595 // `acme/docs`, which the slice holds nothing of, is not believed on silence alone — it is
4596 // read on its own, and that read is what says whether it has anything at all.
4597 assert.isTrue(result.truncated);
4598 assert.deepStrictEqual(result.nextCursors, {
4599 "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|2",
4600 "github.com acme/web": "2026-07-02T00:00:00Z|2|3",
4601 });
4602 }),
4603);
4604it.effect("carries a slice on without sending the rows it already sent", () =>
4605 Effect.gen(function* () {
4606 const cursors: Array<unknown> = [];
4607 const service = yield* makeService({
4608 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4609 providers: [
4610 fakeProvider(SourceControlProviderKind.make("github"), {
4611 listChangeRequestsAcross: (input) => {
4612 cursors.push(input.cursor);
4613 return Effect.succeed({
4614 items: [
4615 batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"),
4616 batchedChangeRequest(4, "acme/web", "2026-07-02T00:00:00Z"),
4617 ],
4618 truncated: true,
4619 });
4620 },
4621 }),
4622 ],
4623 });
4624
4625 const result = yield* service.list({
4626 state: "open",
4627 cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|1|3" },
4628 });
4629
4630 // The boundary instant is asked for inclusively, so the row already sent at it comes back and
4631 // is dropped here — and stays named in the next cursor, which has not moved off that instant.
4632 assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 1 }]);
4633 assert.deepStrictEqual(
4634 result.entries.map((entry) => entry.number),
4635 [4],
4636 );
4637 assert.deepStrictEqual(result.nextCursors, {
4638 "github.com acme/web": "2026-07-02T00:00:00Z|2|3,3,4",
4639 });
4640 }),
4641);
4642it.effect("reads a workspace larger than one search in chunks, and merges them", () =>
4643 Effect.gen(function* () {
4644 const asked: Array<number> = [];
4645 const service = yield* makeService({
4646 projects: Array.from({ length: 101 }, (_, index) =>
4647 project({
4648 id: `p${index}`,
4649 title: `repo ${index}`,
4650 workspaceRoot: `/w${index}`,
4651 repository: `acme/repo${index}`,
4652 }),
4653 ),
4654 providers: [
4655 fakeProvider(SourceControlProviderKind.make("github"), {
4656 listChangeRequestsAcross: (input) => {
4657 asked.push(input.repositories.length);
4658 return Effect.succeed({
4659 items: input.repositories.map((repository, index) =>
4660 batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"),
4661 ),
4662 truncated: false,
4663 });
4664 },
4665 }),
4666 ],
4667 });
4668
4669 const result = yield* service.list({ state: "open" });
4670
4671 assert.deepStrictEqual(asked, [100, 1]);
4672 assert.strictEqual(result.entries.length, 101);
4673 }),
4674);
4675it.effect("asks on its own for a repository a search answered nothing for", () =>
4676 Effect.gen(function* () {
4677 const separately: string[] = [];
4678 const service = yield* makeService({
4679 projects: [
4680 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4681 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
4682 ],
4683 providers: [
4684 fakeProvider(SourceControlProviderKind.make("github"), {
4685 listChangeRequests: ({ repository }) => {
4686 separately.push(repository);
4687 return repository === "acme/docs"
4688 ? Effect.fail(requestFailed)
4689 : Effect.succeed({ items: [], truncated: false, continues: true });
4690 },
4691 listChangeRequestsAcross: () =>
4692 Effect.succeed({
4693 items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")],
4694 truncated: false,
4695 }),
4696 }),
4697 ],
4698 });
4699
4700 const result = yield* service.list({ state: "open" });
4701
4702 // The slice had room and still held nothing of `acme/docs`, which is what a repository GitHub
4703 // will not search looks like — so it is read the old way, and its failure is still reported
4704 // against its own project.
4705 assert.deepStrictEqual(separately, ["acme/docs"]);
4706 assert.deepStrictEqual(result.errors, [
4707 {
4708 projectId: "p2" as ProjectId,
4709 projectTitle: "docs",
4710 message: "acme/docs could not be read.",
4711 },
4712 ]);
4713 assert.deepStrictEqual(
4714 result.entries.map((entry) => entry.number),
4715 [1],
4716 );
4717 }),
4718);
4719it.effect("reads the repositories one at a time when the search itself fails", () =>
4720 Effect.gen(function* () {
4721 const separately: string[] = [];
4722 const service = yield* makeService({
4723 projects: [
4724 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4725 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
4726 ],
4727 providers: [
4728 fakeProvider(SourceControlProviderKind.make("github"), {
4729 listChangeRequests: ({ repository }) => {
4730 separately.push(repository);
4731 return Effect.succeed({
4732 items: [changeRequest(1, "2026-07-02T00:00:00Z")],
4733 truncated: false,
4734 continues: true,
4735 });
4736 },
4737 listChangeRequestsAcross: () => Effect.fail(requestFailed),
4738 }),
4739 ],
4740 });
4741
4742 const result = yield* service.list({ state: "open" });
4743
4744 // One failed question about two repositories is not two unreadable repositories.
4745 assert.deepStrictEqual(separately.toSorted(), ["acme/docs", "acme/web"]);
4746 assert.deepStrictEqual(result.errors, []);
4747 assert.strictEqual(result.entries.length, 2);
4748 }),
4749);
4750it.effect("fills in the line counts for the rows it is given", () =>
4751 Effect.gen(function* () {
4752 const asked: Array<unknown> = [];
4753 const service = yield* makeService({
4754 projects: [
4755 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4756 project({
4757 id: "p2",
4758 title: "on gitlab",
4759 workspaceRoot: "/b",
4760 repository: "group/project",
4761 provider: SourceControlProviderKind.make("gitlab"),
4762 }),
4763 ],
4764 providers: [
4765 fakeProvider(SourceControlProviderKind.make("github"), {
4766 listChangeRequestStats: (input) => {
4767 asked.push(input.changeRequests);
4768 return Effect.succeed([
4769 { repository: "acme/web", number: 1, additions: 12, deletions: 3 },
4770 ]);
4771 },
4772 }),
4773 // Its listing carries the counts already, so it has nothing to be asked.
4774 fakeProvider(SourceControlProviderKind.make("gitlab")),
4775 ],
4776 });
4777
4778 const result = yield* service.listStats({
4779 refs: [
4780 { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 },
4781 { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 },
4782 { projectId: "p2" as ProjectId, repository: "group/project", number: 3 },
4783 // Not the repository this project's remote points at, so it is dropped rather than asked.
4784 { projectId: "p1" as ProjectId, repository: "evil/repo", number: 4 },
4785 ],
4786 });
4787
4788 assert.deepStrictEqual(asked, [
4789 [
4790 { repository: "acme/web", number: 1 },
4791 { repository: "acme/web", number: 2 },
4792 ],
4793 ]);
4794 // Only the rows the host answered for; the other is left with whatever the listing had.
4795 assert.deepStrictEqual(result.stats, [
4796 {
4797 projectId: "p1" as ProjectId,
4798 repository: "acme/web",
4799 number: 1,
4800 additions: 12,
4801 deletions: 3,
4802 },
4803 ]);
4804 }),
4805);
4806it.effect(
4807 "reuses counts across overlapping pages until expiry, explicit invalidation, or a reference changes",
4808 () =>
4809 Effect.gen(function* () {
4810 const asked: number[][] = [];
4811 const ref = (number: number) => ({
4812 projectId: "p1" as ProjectId,
4813 repository: "acme/web",
4814 number,
4815 });
4816 const service = yield* makeService({
4817 projects: [
4818 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4819 ],
4820 providers: [
4821 fakeProvider(SourceControlProviderKind.make("github"), {
4822 listChangeRequestStats: (input) => {
4823 asked.push(input.changeRequests.map((ref) => ref.number));
4824 return Effect.succeed(
4825 input.changeRequests.map((ref) => ({ ...ref, additions: 12, deletions: 3 })),
4826 );
4827 },
4828 }),
4829 ],
4830 });
4831
4832 yield* service.listStats({ refs: [ref(1), ref(2)] });
4833 const overlapping = yield* service.listStats({ refs: [ref(2), ref(3)] });
4834 assert.deepStrictEqual(
4835 overlapping.stats.map((stat) => stat.number),
4836 [2, 3],
4837 );
4838 yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] });
4839 assert.deepStrictEqual(asked, [[1, 2], [3]]);
4840
4841 yield* service.invalidate({ reference: ref(2) });
4842 yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] });
4843 assert.deepStrictEqual(asked, [[1, 2], [3], [2]]);
4844
4845 yield* TestClock.adjust("61 seconds");
4846 yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] });
4847 assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3]]);
4848
4849 yield* service.refreshAfterTurn("p1" as ProjectId);
4850 yield* service.listStats({ refs: [ref(1)] });
4851 assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1]]);
4852
4853 yield* service.invalidate({});
4854 yield* service.listStats({ refs: [ref(1)] });
4855 assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1], [1]]);
4856 }),
4857);
4858
4859it.effect("reads the fresh diff when detail or summary discovers a changed revision", () =>
4860 Effect.gen(function* () {
4861 const summaryStarted = yield* Deferred.make<void>();
4862 const releaseSummary = yield* Deferred.make<void>();
4863 let revision = "2026-07-02T00:00:00Z";
4864 let patch = "old patch";
4865 let diffCalls = 0;
4866 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
4867 const service = yield* makeService({
4868 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4869 providers: [
4870 fakeProvider(SourceControlProviderKind.make("github"), {
4871 getChangeRequest: () =>
4872 Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })),
4873 getChangeRequestSummary: () =>
4874 Effect.gen(function* () {
4875 const result = changeRequest(1, revision);
4876 yield* Deferred.succeed(summaryStarted, undefined);
4877 yield* Deferred.await(releaseSummary);
4878 return result;
4879 }),
4880 getDiff: () =>
4881 Effect.sync(() => {
4882 diffCalls += 1;
4883 return { patch, truncated: false, nextCursor: null };
4884 }),
4885 }),
4886 ],
4887 });
4888
4889 const coldSummary = yield* service.summary(reference).pipe(Effect.forkChild());
4890 yield* Deferred.await(summaryStarted);
4891 yield* service.detail(reference);
4892 assert.strictEqual((yield* service.diff(reference)).patch, "old patch");
4893 revision = "2026-07-02T00:01:00Z";
4894 patch = "new patch";
4895 yield* TestClock.adjust("61 seconds");
4896 yield* service.detail(reference);
4897 yield* Effect.yieldNow;
4898 assert.strictEqual((yield* service.detail(reference)).updatedAt, revision);
4899 yield* Deferred.succeed(releaseSummary, undefined);
4900 yield* Fiber.join(coldSummary);
4901 yield* service.summary(reference, { recoverTransientFailure: false });
4902 assert.strictEqual((yield* service.diff(reference)).patch, "new patch");
4903 assert.strictEqual(diffCalls, 2);
4904
4905 revision = "2026-07-02T00:02:00Z";
4906 patch = "summary-discovered patch";
4907 yield* TestClock.adjust("61 seconds");
4908 yield* service.summary(reference, { recoverTransientFailure: false });
4909 assert.strictEqual((yield* service.diff(reference)).patch, patch);
4910 assert.strictEqual(diffCalls, 3);
4911 }),
4912);
4913
4914it.effect("keeps the rows when the line counts cannot be read", () =>
4915 Effect.gen(function* () {
4916 const service = yield* makeService({
4917 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4918 providers: [
4919 fakeProvider(SourceControlProviderKind.make("github"), {
4920 listChangeRequestStats: () => Effect.fail(requestFailed),
4921 }),
4922 ],
4923 });
4924
4925 const result = yield* service.listStats({
4926 refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }],
4927 });
4928
4929 assert.deepStrictEqual(result.stats, []);
4930 }),
4931);
4932
4933it.effect(
4934 "serves core detail without waiting for activity, and shares activity between clients",
4935 () =>
4936 Effect.gen(function* () {
4937 let coreCalls = 0;
4938 let activityCalls = 0;
4939 let statsCalls = 0;
4940 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
4941 const service = yield* makeService({
4942 projects: [
4943 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
4944 ],
4945 providers: [
4946 fakeProvider(SourceControlProviderKind.make("github"), {
4947 listChangeRequestStats: () => {
4948 statsCalls += 1;
4949 return Effect.succeed([]);
4950 },
4951 getChangeRequest: () => {
4952 coreCalls += 1;
4953 return Effect.succeed({
4954 ...changeRequest(1, "2026-07-02T00:00:00Z"),
4955 body: "Ready before the conversation",
4956 changedFiles: 2,
4957 mergedAt: null,
4958 closedAt: null,
4959 reviewers: [],
4960 checks: [],
4961 mergeCapabilities: { merge: true, squash: true, rebase: true },
4962 viewerPermissions: {
4963 actions: ["merge"],
4964 comment: true,
4965 resolve: true,
4966 verdicts: ["comment", "approve", "request-changes"],
4967 requestReviewers: true,
4968 },
4969 });
4970 },
4971 getChangeRequestActivity: () => {
4972 activityCalls += 1;
4973 return Effect.succeed({
4974 comments: [],
4975 commentCount: 0,
4976 commentsTruncated: true,
4977 reviewThreadsTruncated: true,
4978 reviewThreads: [],
4979 commits: [],
4980 });
4981 },
4982 }),
4983 ],
4984 });
4985
4986 const core = yield* service.detail(reference);
4987 assert.strictEqual(core.body, "Ready before the conversation");
4988 assert.strictEqual(coreCalls, 1);
4989 assert.strictEqual(activityCalls, 0);
4990
4991 const counts = yield* service.listStats({ refs: [reference] });
4992 assert.strictEqual(statsCalls, 0);
4993 assert.deepStrictEqual(counts.stats, [
4994 {
4995 ...reference,
4996 additions: core.additions,
4997 deletions: core.deletions,
4998 },
4999 ]);
5000
5001 const activities = yield* Effect.all(
5002 [service.activity(reference), service.activity(reference)],
5003 {
5004 concurrency: 2,
5005 },
5006 );
5007 assert.strictEqual(activityCalls, 1);
5008 assert.isTrue(activities.every((activity) => activity.reviewThreadsTruncated === true));
5009
5010 yield* service.invalidate({ reference });
5011 yield* service.activity(reference);
5012 assert.strictEqual(activityCalls, 2);
5013 }),
5014);
5015
5016it.effect("shares linked summaries and reuses them for display without asking the host again", () =>
5017 Effect.gen(function* () {
5018 let calls = 0;
5019 let failing = false;
5020 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5021 const service = yield* makeService({
5022 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5023 providers: [
5024 fakeProvider(SourceControlProviderKind.make("github"), {
5025 getChangeRequestSummary: () =>
5026 Effect.sync(() => {
5027 calls += 1;
5028 return failing;
5029 }).pipe(
5030 Effect.tap(() => Effect.yieldNow),
5031 Effect.flatMap((shouldFail) =>
5032 shouldFail
5033 ? Effect.fail(
5034 new PullRequestProviderError({
5035 provider: SourceControlProviderKind.make("github"),
5036 operation: "getChangeRequestSummary",
5037 reason: "failed",
5038 detail: "HTTP 504",
5039 }),
5040 )
5041 : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
5042 ),
5043 ),
5044 }),
5045 ],
5046 });
5047
5048 yield* Effect.all(
5049 [
5050 service.summary(reference, { recoverTransientFailure: false }),
5051 service.summary(reference, { recoverTransientFailure: false }),
5052 ],
5053 { concurrency: "unbounded" },
5054 );
5055 assert.strictEqual(calls, 1);
5056
5057 yield* TestClock.adjust("61 seconds");
5058 failing = true;
5059 const strict = yield* Effect.flip(service.summary({ ...reference, allowStale: false }));
5060 assert.strictEqual(strict._tag, "PullRequestOperationError");
5061
5062 const stale = yield* service.summary(reference);
5063 assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z");
5064 // Display reads keep the last title and state rather than asking the host again.
5065 assert.strictEqual(calls, 2);
5066
5067 yield* service.invalidate({ reference });
5068 const invalidated = yield* Effect.flip(service.summary(reference));
5069 assert.strictEqual(invalidated._tag, "PullRequestOperationError");
5070 }),
5071);
5072
5073it.effect("keeps routed reads separate when the GitHub account changes", () =>
5074 Effect.gen(function* () {
5075 for (const operation of ["summary", "detail", "checks", "diff", "filesViewed"] as const) {
5076 let failing = false;
5077 let calls = 0;
5078 const read = () =>
5079 Effect.suspend(() => {
5080 calls += 1;
5081 return failing
5082 ? Effect.fail(requestFailed)
5083 : Effect.succeed(hostedChangeRequest("account A content"));
5084 });
5085 const service = yield* makeService({
5086 projects: [
5087 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5088 ],
5089 providers: [
5090 fakeProvider(SourceControlProviderKind.make("github"), {
5091 capabilities: {
5092 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
5093 viewedFiles: "host",
5094 },
5095 getFilesViewed: () =>
5096 read().pipe(
5097 Effect.as({
5098 files: [{ path: "private.ts", state: "viewed" as const }],
5099 truncated: false,
5100 }),
5101 ),
5102 getChangeRequestSummary: read,
5103 getChangeRequestChecks: read,
5104 getChangeRequest: read,
5105 getDiff: () =>
5106 read().pipe(
5107 Effect.as({ patch: "private patch", truncated: false, nextCursor: null }),
5108 ),
5109 }),
5110 ],
5111 });
5112 const readOperation = (input: Parameters<typeof service.diff>[0]) =>
5113 // @effect-diagnostics-next-line unnecessaryEffectGen:off - the generator unifies the per-operation union of Effect types, which Effect.asVoid cannot infer through.
5114 Effect.gen(function* () {
5115 yield* service[operation](input);
5116 });
5117 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5118 yield* readOperation({ ...reference, expectedAccountId: "101" });
5119 failing = true;
5120
5121 for (const allowStale of [false, true]) {
5122 const error = yield* Effect.flip(
5123 readOperation({ ...reference, expectedAccountId: "202", allowStale }),
5124 );
5125 assert.strictEqual(error._tag, "PullRequestOperationError");
5126 }
5127 assert.strictEqual(calls, 3);
5128 }
5129 }),
5130);
5131
5132it.effect("isolates routed caches for two credentials belonging to the same account", () =>
5133 Effect.gen(function* () {
5134 for (const operation of [
5135 "summary",
5136 "detail",
5137 "checks",
5138 "diff",
5139 "preview",
5140 "filesViewed",
5141 ] as const) {
5142 let credential = "broad";
5143 let calls = 0;
5144 const read = () =>
5145 Effect.suspend(() => {
5146 calls += 1;
5147 return credential === "broad"
5148 ? Effect.succeed(hostedChangeRequest("private content"))
5149 : Effect.fail(requestFailed);
5150 });
5151 const service = yield* makeService({
5152 projects: [
5153 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5154 ],
5155 providers: [
5156 fakeProvider(SourceControlProviderKind.make("github"), {
5157 capabilities: {
5158 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
5159 viewedFiles: "host",
5160 },
5161 getFilesViewed: () =>
5162 read().pipe(
5163 Effect.as({
5164 files: [{ path: "private.ts", state: "viewed" as const }],
5165 truncated: false,
5166 }),
5167 ),
5168 withVerifiedCredential: (_, use) =>
5169 Effect.suspend(() =>
5170 use({
5171 accountId: "101",
5172 viewer: "octocat",
5173 credentialFingerprint: credential,
5174 }),
5175 ),
5176 getChangeRequest: read,
5177 getChangeRequestChecks: read,
5178 getChangeRequestSummary: read,
5179 getDiff: () =>
5180 read().pipe(
5181 Effect.as({ patch: "private patch", truncated: false, nextCursor: null }),
5182 ),
5183 getChangeRequestPreview: read,
5184 }),
5185 ],
5186 });
5187 const readOperation = (input: Parameters<typeof service.diff>[0]) =>
5188 // @effect-diagnostics-next-line unnecessaryEffectGen:off - the generator unifies the per-operation union of Effect types, which Effect.asVoid cannot infer through.
5189 Effect.gen(function* () {
5190 yield* service[operation](input);
5191 });
5192 const reference = {
5193 projectId: "p1" as ProjectId,
5194 repository: "acme/web",
5195 number: 1,
5196 host: "github.com",
5197 expectedAccountId: "101",
5198 };
5199 yield* service.withRoutingCredential(reference, readOperation(reference));
5200 credential = "restricted";
5201 for (const allowStale of [false, true]) {
5202 const error = yield* Effect.flip(
5203 service.withRoutingCredential(reference, readOperation({ ...reference, allowStale })),
5204 );
5205 assert.strictEqual(error._tag, "PullRequestOperationError");
5206 }
5207 assert.strictEqual(calls, 3);
5208 }
5209 }),
5210);
5211
5212it.effect("rejects mismatched routing credentials before use and preserves action errors", () =>
5213 Effect.gen(function* () {
5214 let operations = 0;
5215 const service = yield* makeService({
5216 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5217 providers: [
5218 fakeProvider(SourceControlProviderKind.make("github"), {
5219 getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }),
5220 withVerifiedCredential: (_, use) =>
5221 use({
5222 accountId: "101",
5223 viewer: "octocat",
5224 credentialFingerprint: "credential-a",
5225 }),
5226 }),
5227 ],
5228 });
5229 const reference = {
5230 projectId: "p1" as ProjectId,
5231 repository: "acme/web",
5232 number: 1,
5233 host: "github.com",
5234 expectedAccountId: "202",
5235 };
5236 const actionError = new PullRequestOperationError({
5237 operation: "runAction",
5238 detail: "ambiguous",
5239 });
5240 const operation = Effect.sync(() => {
5241 operations += 1;
5242 }).pipe(Effect.andThen(Effect.fail(actionError)));
5243 const rejected = yield* Effect.flip(service.withRoutingCredential(reference, operation));
5244 assert.strictEqual(rejected._tag, "PullRequestOperationError");
5245 if (rejected._tag === "PullRequestOperationError")
5246 assert.strictEqual(rejected.operation, "routeIdentity");
5247 assert.strictEqual(operations, 0);
5248 assert.strictEqual(
5249 yield* Effect.flip(
5250 service.withRoutingCredential({ ...reference, expectedAccountId: "101" }, operation),
5251 ),
5252 actionError,
5253 );
5254 assert.strictEqual(operations, 1);
5255 assert.deepStrictEqual(yield* service.routingIdentity({ host: "github.com" }), {
5256 accountId: "101",
5257 viewer: "octocat",
5258 host: "github.com",
5259 provider: "github",
5260 });
5261 }),
5262);
5263
5264it.effect("answers a known pull request immediately while the host refreshes", () =>
5265 Effect.gen(function* () {
5266 const gate = yield* Deferred.make<void>();
5267 let calls = 0;
5268 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5269 const service = yield* makeService({
5270 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5271 providers: [
5272 fakeProvider(SourceControlProviderKind.make("github"), {
5273 getChangeRequest: () =>
5274 Effect.gen(function* () {
5275 calls += 1;
5276 if (calls > 1) yield* Deferred.await(gate);
5277 return hostedChangeRequest("cached body", 4);
5278 }),
5279 }),
5280 ],
5281 });
5282
5283 const first = yield* service.detail(reference);
5284 assert.strictEqual(first.body, "cached body");
5285 assert.strictEqual(first.additions, 4);
5286
5287 yield* TestClock.adjust("61 seconds");
5288 const second = yield* service.detail(reference);
5289 assert.strictEqual(second.body, "cached body");
5290 assert.strictEqual(second.additions, 4);
5291 yield* Effect.yieldNow;
5292 assert.strictEqual(calls, 2);
5293 }),
5294);
5295
5296it.effect("does not ask the host again for a linked summary it already holds", () =>
5297 Effect.gen(function* () {
5298 let calls = 0;
5299 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5300 const service = yield* makeService({
5301 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5302 providers: [
5303 fakeProvider(SourceControlProviderKind.make("github"), {
5304 getChangeRequestSummary: () =>
5305 Effect.sync(() => {
5306 calls += 1;
5307 return changeRequest(1, "2026-07-02T00:00:00Z");
5308 }),
5309 }),
5310 ],
5311 });
5312
5313 const first = yield* service.summary(reference);
5314 assert.strictEqual(first.title, "Change request 1");
5315 yield* TestClock.adjust("61 seconds");
5316 const second = yield* service.summary(reference);
5317 assert.strictEqual(second.title, "Change request 1");
5318 assert.strictEqual(calls, 1);
5319 }),
5320);
5321
5322it.effect(
5323 "opening detail preserves enriched linked summaries and updates draft and diff fields",
5324 () =>
5325 Effect.gen(function* () {
5326 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5327 const service = yield* makeService({
5328 projects: [
5329 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5330 ],
5331 providers: [
5332 fakeProvider(SourceControlProviderKind.make("github"), {
5333 getChangeRequestSummary: () =>
5334 Effect.succeed({
5335 ...changeRequest(1, "2026-07-02T00:00:00Z"),
5336 isDraft: true,
5337 reviewDecision: "approved",
5338 checksState: "passing",
5339 }),
5340 getChangeRequest: () =>
5341 Effect.succeed({
5342 ...hostedChangeRequest("body", 14),
5343 deletions: 3,
5344 changedFiles: 5,
5345 mergeability: "conflicting",
5346 }),
5347 }),
5348 ],
5349 });
5350 yield* service.summary(reference);
5351 const detail = yield* service.detail(reference);
5352 const summary = yield* service.summary(reference);
5353 assert.strictEqual(summary.isDraft, false);
5354 assert.deepStrictEqual(summary.author, detail.author);
5355 assert.strictEqual(summary.additions, 14);
5356 assert.strictEqual(summary.deletions, 3);
5357 assert.strictEqual(summary.changedFiles, 5);
5358 assert.strictEqual(summary.mergeability, "conflicting");
5359 assert.strictEqual(summary.reviewDecision, "approved");
5360 assert.strictEqual(summary.checksState, "passing");
5361 }),
5362);
5363
5364it.effect("reuses an observed merged state for strict settlement reads", () =>
5365 Effect.gen(function* () {
5366 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5367 const service = yield* makeService({
5368 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5369 providers: [
5370 fakeProvider(SourceControlProviderKind.make("github"), {
5371 getChangeRequest: () =>
5372 Effect.succeed({
5373 ...hostedChangeRequest("merged body", 4),
5374 state: "merged",
5375 updatedAt: "2026-07-03T00:00:00Z",
5376 }),
5377 getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"),
5378 }),
5379 ],
5380 });
5381
5382 yield* service.detail(reference);
5383
5384 const summary = yield* service.summary(reference, { recoverTransientFailure: false });
5385 assert.strictEqual(summary.state, "merged");
5386 assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z");
5387 }),
5388);
5389
5390it.effect("announces state a detail read sees first or newly", () =>
5391 Effect.gen(function* () {
5392 let detail = {
5393 state: "open" as "open" | "closed" | "merged",
5394 updatedAt: "2026-07-02T00:00:00Z",
5395 };
5396 let summaryState: "open" | "merged" = "open";
5397 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5398 const service = yield* makeService({
5399 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5400 providers: [
5401 fakeProvider(SourceControlProviderKind.make("github"), {
5402 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),
5403 getChangeRequestSummary: () =>
5404 Effect.succeed({
5405 ...changeRequest(1, "2026-07-04T00:00:00Z"),
5406 state: summaryState,
5407 }),
5408 }),
5409 ],
5410 });
5411 const announced: Array<string> = [];
5412 yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) =>
5413 Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)),
5414 ).pipe(Effect.forkChild({ startImmediately: true }));
5415 const readDetail = Effect.gen(function* () {
5416 yield* service.invalidate({ reference });
5417 yield* service.detail(reference);
5418 yield* Effect.yieldNow;
5419 });
5420
5421 // First sight announces; the same state again, even from a fresh read, does not.
5422 yield* readDetail;
5423 yield* readDetail;
5424 assert.deepStrictEqual(announced, ["github.com/acme/web#1"]);
5425
5426 detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" };
5427 yield* readDetail;
5428 assert.strictEqual(announced.length, 2);
5429
5430 // A summary seeing the merge first does not hide it from the detail read after.
5431 summaryState = "merged";
5432 yield* service.summary(reference, { recoverTransientFailure: false });
5433 yield* Effect.yieldNow;
5434 assert.strictEqual(announced.length, 2);
5435 detail = { state: "merged", updatedAt: "2026-07-04T00:00:00Z" };
5436 yield* readDetail;
5437 assert.strictEqual(announced.length, 3);
5438
5439 // A detail read older than the merge cannot announce a reopen.
5440 detail = { state: "open", updatedAt: "2026-07-03T00:00:00Z" };
5441 yield* readDetail;
5442 assert.strictEqual(announced.length, 3);
5443 }),
5444);
5445
5446it.effect("announces a reported state without trusting it", () =>
5447 Effect.gen(function* () {
5448 let detail = { state: "open" as "open" | "closed", updatedAt: "2026-07-02T00:00:00Z" };
5449 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5450 const service = yield* makeService({
5451 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5452 providers: [
5453 fakeProvider(SourceControlProviderKind.make("github"), {
5454 getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }),
5455 }),
5456 ],
5457 });
5458 const announced: Array<string> = [];
5459 yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) =>
5460 Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)),
5461 ).pipe(Effect.forkChild({ startImmediately: true }));
5462 const note = (state: "open" | "closed" | "merged") =>
5463 service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow));
5464 const readDetail = Effect.gen(function* () {
5465 yield* service.invalidate({ reference });
5466 yield* service.detail(reference);
5467 yield* Effect.yieldNow;
5468 });
5469
5470 yield* readDetail;
5471 yield* note("closed");
5472 yield* note("closed");
5473 assert.strictEqual(announced.length, 2);
5474 yield* TestClock.adjust("5 minutes");
5475 yield* note("closed");
5476 assert.strictEqual(announced.length, 3);
5477
5478 yield* note("open");
5479 assert.strictEqual(announced.length, 4);
5480
5481 yield* note("merged");
5482 detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" };
5483 yield* readDetail;
5484 assert.strictEqual(announced.length, 6);
5485 }),
5486);
5487
5488it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () =>
5489 Effect.gen(function* () {
5490 const gate = yield* Deferred.make<void>();
5491 let detailCalls = 0;
5492 let summaryTitle = "old title";
5493 let summaryState: "open" | "merged" = "open";
5494 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5495 const service = yield* makeService({
5496 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5497 providers: [
5498 fakeProvider(SourceControlProviderKind.make("github"), {
5499 getChangeRequest: () =>
5500 Effect.gen(function* () {
5501 detailCalls += 1;
5502 if (detailCalls > 1) yield* Deferred.await(gate);
5503 return hostedChangeRequest("old body", 4);
5504 }),
5505 getChangeRequestSummary: () =>
5506 Effect.succeed({
5507 ...changeRequest(1, "2026-07-02T00:00:00Z"),
5508 title: summaryTitle,
5509 state: summaryState,
5510 }),
5511 }),
5512 ],
5513 });
5514
5515 const first = yield* service.detail(reference);
5516 assert.strictEqual(first.title, "Change request 1");
5517
5518 summaryTitle = "merged title";
5519 summaryState = "merged";
5520 yield* TestClock.adjust("61 seconds");
5521 const settled = yield* service.summary(reference, { recoverTransientFailure: false });
5522 assert.strictEqual(settled.title, "merged title");
5523 assert.strictEqual(settled.state, "merged");
5524
5525 yield* TestClock.adjust("16 seconds");
5526 const stale = yield* service.detail(reference);
5527 assert.strictEqual(stale.title, "Change request 1");
5528 yield* Effect.yieldNow;
5529
5530 const display = yield* service.summary(reference);
5531 assert.strictEqual(display.title, "merged title");
5532 assert.strictEqual(display.state, "merged");
5533 assert.strictEqual(detailCalls, 2);
5534
5535 summaryTitle = "updated after merge";
5536 yield* TestClock.adjust("61 seconds");
5537 assert.strictEqual((yield* service.summary(reference)).title, "merged title");
5538 const refreshed = yield* service.summary({ ...reference, allowStale: false });
5539 assert.strictEqual(refreshed.title, "updated after merge");
5540 assert.strictEqual(refreshed.state, "merged");
5541 }),
5542);
5543
5544it.effect("does not let a still-cached detail overwrite a fresher linked summary", () =>
5545 Effect.gen(function* () {
5546 let summaryTitle = "old title";
5547 let summaryState: "open" | "merged" = "open";
5548 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5549 const service = yield* makeService({
5550 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5551 providers: [
5552 fakeProvider(SourceControlProviderKind.make("github"), {
5553 getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)),
5554 getChangeRequestSummary: () =>
5555 Effect.succeed({
5556 ...changeRequest(1, "2026-07-02T00:00:00Z"),
5557 title: summaryTitle,
5558 state: summaryState,
5559 }),
5560 }),
5561 ],
5562 });
5563
5564 const first = yield* service.detail(reference);
5565 assert.strictEqual(first.title, "Change request 1");
5566
5567 summaryTitle = "merged title";
5568 summaryState = "merged";
5569 const settled = yield* service.summary(reference, { recoverTransientFailure: false });
5570 assert.strictEqual(settled.state, "merged");
5571
5572 const cached = yield* service.detail(reference);
5573 assert.strictEqual(cached.title, "Change request 1");
5574 yield* Effect.yieldNow;
5575
5576 const display = yield* service.summary(reference);
5577 assert.strictEqual(display.title, "merged title");
5578 assert.strictEqual(display.state, "merged");
5579 }),
5580);
5581
5582it.effect("tells the client when the host has no pull request under that number", () =>
5583 Effect.gen(function* () {
5584 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 };
5585 const service = yield* makeService({
5586 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5587 providers: [
5588 fakeProvider(SourceControlProviderKind.make("github"), {
5589 getChangeRequest: () =>
5590 Effect.fail(
5591 new PullRequestProviderError({
5592 provider: SourceControlProviderKind.make("github"),
5593 operation: "getChangeRequest",
5594 reason: "not-found",
5595 detail: "Pull request not found. Check the PR number or URL and try again.",
5596 }),
5597 ),
5598 }),
5599 ],
5600 });
5601
5602 const error = yield* Effect.flip(service.detail(reference));
5603
5604 assert.strictEqual(error._tag, "PullRequestOperationError");
5605 assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found");
5606 }),
5607);
5608
5609it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () =>
5610 Effect.gen(function* () {
5611 let failing = false;
5612 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5613 const service = yield* makeService({
5614 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5615 providers: [
5616 fakeProvider(SourceControlProviderKind.make("github"), {
5617 getChangeRequest: () =>
5618 failing
5619 ? Effect.fail(
5620 new PullRequestProviderError({
5621 provider: SourceControlProviderKind.make("github"),
5622 operation: "getChangeRequest",
5623 reason: "failed",
5624 detail: "spawn gh EAGAIN",
5625 }),
5626 )
5627 : Effect.succeed({
5628 ...changeRequest(1, "2026-07-02T00:00:00Z"),
5629 body: "last good body",
5630 changedFiles: 2,
5631 mergedAt: null,
5632 closedAt: null,
5633 reviewers: [],
5634 checks: [],
5635 mergeCapabilities: { merge: true, squash: true, rebase: true },
5636 viewerPermissions: {
5637 actions: ["merge"],
5638 comment: true,
5639 resolve: true,
5640 verdicts: ["comment", "approve", "request-changes"],
5641 requestReviewers: true,
5642 },
5643 }),
5644 }),
5645 ],
5646 });
5647
5648 yield* service.detail(reference);
5649 yield* TestClock.adjust("61 seconds");
5650 failing = true;
5651 const strict = yield* Effect.flip(service.detail({ ...reference, allowStale: false }));
5652 assert.strictEqual(strict._tag, "PullRequestOperationError");
5653 const stale = yield* service.detail(reference);
5654 assert.strictEqual(stale.body, "last good body");
5655
5656 yield* service.invalidate({ reference });
5657 const invalidated = yield* Effect.flip(service.detail(reference));
5658 assert.strictEqual(invalidated._tag, "PullRequestOperationError");
5659 }),
5660);
5661
5662it.effect("carries an armed auto-merge through to the detail, and silence as silence", () =>
5663 Effect.gen(function* () {
5664 const detailWith = (autoMergeEnabled: boolean | undefined) =>
5665 Effect.gen(function* () {
5666 const service = yield* makeService({
5667 projects: [
5668 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5669 ],
5670 providers: [
5671 fakeProvider(SourceControlProviderKind.make("github"), {
5672 getChangeRequest: () =>
5673 Effect.succeed({
5674 ...changeRequest(1, "2026-07-02T00:00:00Z"),
5675 body: "",
5676 changedFiles: 0,
5677 mergedAt: null,
5678 closedAt: null,
5679 reviewers: [],
5680 checks: [],
5681 mergeCapabilities: { merge: true, squash: true, rebase: true },
5682 viewerPermissions: {
5683 actions: ["merge"],
5684 comment: true,
5685 resolve: true,
5686 verdicts: ["comment", "approve", "request-changes"],
5687 requestReviewers: true,
5688 },
5689 ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }),
5690 }),
5691 }),
5692 ],
5693 });
5694 return yield* service.detail({
5695 projectId: "p1" as ProjectId,
5696 repository: "acme/web",
5697 number: 1,
5698 });
5699 });
5700
5701 assert.strictEqual((yield* detailWith(true)).autoMergeEnabled, true);
5702 assert.strictEqual((yield* detailWith(false)).autoMergeEnabled, false);
5703 // A host that says nothing leaves the field absent rather than claiming the merge is unarmed.
5704 assert.isUndefined((yield* detailWith(undefined)).autoMergeEnabled);
5705 }),
5706);
5707
5708it.effect("narrows the rows of a host that ignored the filters it was handed", () =>
5709 Effect.gen(function* () {
5710 const service = yield* makeService({
5711 projects: [
5712 project({
5713 id: "p1",
5714 title: "web",
5715 workspaceRoot: "/a",
5716 repository: "acme/web",
5717 provider: SourceControlProviderKind.make("gitlab"),
5718 }),
5719 ],
5720 providers: [
5721 // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and
5722 // sending it a draft filter it quietly ignores used to put drafts on a filtered page.
5723 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5724 listChangeRequests: () =>
5725 Effect.succeed({
5726 items: [
5727 { ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true },
5728 changeRequest(2, "2026-07-01T00:00:00Z"),
5729 ],
5730 truncated: false,
5731 continues: false,
5732 }),
5733 }),
5734 ],
5735 });
5736
5737 const result = yield* service.list({ state: "open", filters: { draft: "hide" } });
5738
5739 assert.deepStrictEqual(
5740 result.entries.map((entry) => entry.number),
5741 [2],
5742 );
5743 }),
5744);
5745
5746it.effect("keeps a row of a host that ignored the filters if any name of a label group holds", () =>
5747 Effect.gen(function* () {
5748 const sized = (number: number, updatedAt: string, ...names: ReadonlyArray<string>) => ({
5749 ...changeRequest(number, updatedAt),
5750 labels: names.map((name) => ({ name, color: null })),
5751 });
5752 const service = yield* makeService({
5753 projects: [
5754 project({
5755 id: "p1",
5756 title: "web",
5757 workspaceRoot: "/a",
5758 repository: "acme/web",
5759 provider: SourceControlProviderKind.make("gitlab"),
5760 }),
5761 ],
5762 providers: [
5763 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5764 listChangeRequests: () =>
5765 Effect.succeed({
5766 items: [
5767 sized(1, "2026-07-04T00:00:00Z", "size:S", "bug"),
5768 sized(2, "2026-07-03T00:00:00Z", "size:XS", "bug"),
5769 sized(3, "2026-07-02T00:00:00Z", "size:L", "bug"),
5770 sized(4, "2026-07-01T00:00:00Z", "size:S"),
5771 ],
5772 truncated: false,
5773 continues: false,
5774 }),
5775 }),
5776 ],
5777 });
5778
5779 // Either size satisfies the first group; the second group is its own question, so the row
5780 // carrying a size but no bug goes.
5781 const result = yield* service.list({
5782 state: "open",
5783 filters: { labels: [["size:S", "size:XS"], ["bug"]] },
5784 });
5785
5786 assert.deepStrictEqual(
5787 result.entries.map((entry) => entry.number),
5788 [1, 2],
5789 );
5790 }),
5791);
5792
5793it.effect('resolves an author filter of "me" to the viewer before narrowing a host\'s rows', () =>
5794 Effect.gen(function* () {
5795 const service = yield* makeService({
5796 projects: [
5797 project({
5798 id: "p1",
5799 title: "web",
5800 workspaceRoot: "/a",
5801 repository: "acme/web",
5802 provider: SourceControlProviderKind.make("gitlab"),
5803 }),
5804 ],
5805 providers: [
5806 // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved
5807 // locally too — the same helper both call sites lean on.
5808 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5809 listChangeRequests: () =>
5810 Effect.succeed({
5811 items: [
5812 changeRequest(1, "2026-07-02T00:00:00Z"),
5813 {
5814 ...changeRequest(2, "2026-07-01T00:00:00Z"),
5815 author: { login: "bilal", name: null, avatarUrl: null },
5816 },
5817 ],
5818 truncated: false,
5819 continues: false,
5820 }),
5821 }),
5822 ],
5823 });
5824
5825 const result = yield* service.list({ state: "open", filters: { author: "me" } });
5826
5827 assert.deepStrictEqual(
5828 result.entries.map((entry) => entry.number),
5829 [2],
5830 );
5831 }),
5832);
5833
5834it.effect.each([false, true])(
5835 "authorizes stack rebases and refreshes sibling layers (cross-host: %s)",
5836 (crossHost) =>
5837 Effect.gen(function* () {
5838 let taken = 0;
5839 let summaryReads = 0;
5840 let mutationFails = false;
5841 let stackRebase = true;
5842 let stackActions = true;
5843 const capabilities = {
5844 diff: true,
5845 comment: true,
5846 actions: ["update-branch"] as const,
5847 mergeMethods: ["merge"] as const,
5848 updateMethods: ["rebase"] as const,
5849 get stackActions() {
5850 return stackActions;
5851 },
5852 search: true,
5853 reactions: true,
5854 review: FULL_REVIEW,
5855 reviewers: FULL_REVIEWERS,
5856 };
5857 const service = yield* makeService({
5858 projects: [
5859 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
5860 project({
5861 id: "p2",
5862 title: "enterprise",
5863 workspaceRoot: "/b",
5864 repository: "acme/web",
5865 host: "enterprise.test",
5866 }),
5867 ],
5868 providers: [
5869 fakeProvider(SourceControlProviderKind.make("github"), {
5870 capabilities,
5871 getViewerPermissions: () =>
5872 Effect.succeed({
5873 actions: [],
5874 stackRebase,
5875 comment: true,
5876 resolve: false,
5877 verdicts: [],
5878 requestReviewers: false,
5879 }),
5880 getChangeRequestSummary: () =>
5881 Effect.sync(() => {
5882 summaryReads++;
5883 return changeRequest(8, "2026-07-01T00:00:00Z");
5884 }),
5885 runAction: () =>
5886 Effect.gen(function* () {
5887 taken++;
5888 if (mutationFails) return yield* requestFailed;
5889 }),
5890 }),
5891 ],
5892 });
5893 const input = {
5894 ...(crossHost ? { host: "enterprise.test" } : {}),
5895 projectId: "p1" as ProjectId,
5896 repository: "acme/web",
5897 number: 3,
5898 action: "update-branch" as const,
5899 updateMethod: "rebase" as const,
5900 stackNumber: 50,
5901 expectedStackHeads: [{ number: 3, headSha: "ccc" }],
5902 };
5903 yield* service.runAction(input);
5904 assert.strictEqual(taken, 1);
5905 const unrelated = { ...input, number: 8 };
5906 yield* service.summary(unrelated);
5907 assert.strictEqual(summaryReads, 1);
5908 stackRebase = false;
5909 assert.strictEqual(
5910 (yield* Effect.flip(service.runAction(input)))._tag,
5911 "PullRequestOperationError",
5912 );
5913 stackRebase = true;
5914 stackActions = false;
5915 assert.strictEqual(
5916 (yield* Effect.flip(service.runAction(input)))._tag,
5917 "PullRequestOperationError",
5918 );
5919 assert.strictEqual(taken, 1);
5920 yield* service.summary(unrelated);
5921 assert.strictEqual(summaryReads, 1);
5922 stackActions = true;
5923 mutationFails = true;
5924 yield* Effect.flip(service.runAction(input));
5925 assert.strictEqual(taken, 2);
5926 yield* service.summary(unrelated);
5927 assert.strictEqual(summaryReads, 2);
5928 }),
5929);
5930
5931it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>
5932 Effect.gen(function* () {
5933 let taken: string | null = null;
5934 const service = yield* makeService({
5935 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
5936 providers: [
5937 fakeProvider(SourceControlProviderKind.make("github"), {
5938 capabilities: {
5939 diff: true,
5940 comment: true,
5941 actions: ["merge", "close", "update-branch"],
5942 mergeMethods: ["merge"],
5943 // This host brings a stale branch up to date with a merge commit and nothing else.
5944 updateMethods: ["merge"],
5945 search: true,
5946 reactions: true,
5947 review: FULL_REVIEW,
5948 reviewers: FULL_REVIEWERS,
5949 },
5950 getViewerPermissions: () =>
5951 Effect.succeed({
5952 actions: ["close", "update-branch"],
5953 comment: true,
5954 resolve: true,
5955 verdicts: ["comment"],
5956 requestReviewers: false,
5957 updateMethods: ["merge"],
5958 }),
5959 runAction: (input) => {
5960 taken = input.updateMethod ?? "default";
5961 return Effect.void;
5962 },
5963 }),
5964 ],
5965 });
5966 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
5967
5968 // Asking for a rebase a host does not offer must fail rather than quietly merge instead.
5969 const error = yield* Effect.flip(
5970 service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }),
5971 );
5972 assert.strictEqual(error._tag, "PullRequestOperationError");
5973 assert.strictEqual(taken, null);
5974
5975 yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" });
5976 assert.strictEqual(taken, "merge");
5977 }),
5978);
5979
5980it.effect("refuses to merge a target branch into a source branch on a host that only rebases", () =>
5981 Effect.gen(function* () {
5982 let taken = 0;
5983 const service = yield* makeService({
5984 projects: [
5985 project({
5986 id: "p1",
5987 title: "on gitlab",
5988 workspaceRoot: "/a",
5989 repository: "group/project",
5990 provider: SourceControlProviderKind.make("gitlab"),
5991 }),
5992 ],
5993 providers: [
5994 fakeProvider(SourceControlProviderKind.make("gitlab"), {
5995 capabilities: {
5996 diff: true,
5997 comment: true,
5998 actions: ["merge", "close", "update-branch"],
5999 mergeMethods: ["merge"],
6000 // What GitLab declares: it replays the branch, and has no update that merges the
6001 // target back in.
6002 updateMethods: ["rebase"],
6003 search: true,
6004 reactions: true,
6005 review: FULL_REVIEW,
6006 reviewers: FULL_REVIEWERS,
6007 },
6008 getViewerPermissions: () =>
6009 Effect.succeed({
6010 actions: ["close", "update-branch"],
6011 comment: true,
6012 resolve: true,
6013 verdicts: ["comment"],
6014 requestReviewers: false,
6015 updateMethods: ["rebase"],
6016 }),
6017 runAction: () => {
6018 taken += 1;
6019 return Effect.void;
6020 },
6021 }),
6022 ],
6023 });
6024 const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 };
6025
6026 // A merge asked of a host that rebases must fail here rather than reach the provider, which
6027 // would rebase instead and report the wrong thing as done.
6028 const error = yield* Effect.flip(
6029 service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }),
6030 );
6031 assert.strictEqual(error._tag, "PullRequestOperationError");
6032 assert.strictEqual(taken, 0);
6033
6034 yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" });
6035 assert.strictEqual(taken, 1);
6036 }),
6037);
6038
6039it.effect("judges the review filter only on a host that summarises its reviews", () =>
6040 Effect.gen(function* () {
6041 const service = yield* makeService({
6042 projects: [
6043 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
6044 project({
6045 id: "p2",
6046 title: "on gitlab",
6047 workspaceRoot: "/b",
6048 repository: "group/project",
6049 provider: SourceControlProviderKind.make("gitlab"),
6050 }),
6051 ],
6052 providers: [
6053 // GitHub answers with the field on every row: null is "nobody has decided yet".
6054 fakeProvider(SourceControlProviderKind.make("github"), {
6055 listChangeRequests: () =>
6056 Effect.succeed({
6057 items: [
6058 { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewDecision: null },
6059 {
6060 ...changeRequest(2, "2026-07-02T00:00:00Z"),
6061 reviewDecision: "approved" as const,
6062 },
6063 ],
6064 truncated: false,
6065 continues: true,
6066 }),
6067 }),
6068 // GitLab never supplies the field, so its rows are not the filter's to judge.
6069 fakeProvider(SourceControlProviderKind.make("gitlab"), {
6070 listChangeRequests: () =>
6071 Effect.succeed({
6072 items: [changeRequest(3, "2026-07-02T00:00:00Z")],
6073 truncated: false,
6074 continues: true,
6075 }),
6076 }),
6077 ],
6078 });
6079
6080 const none = yield* service.list({ state: "open", filters: { review: "none" } });
6081 assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]);
6082
6083 const approved = yield* service.list({ state: "open", filters: { review: "approved" } });
6084 assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]);
6085 }),
6086);
6087
6088it.effect("sends only the words a rewrite carries", () =>
6089 Effect.gen(function* () {
6090 const received: Array<{ title?: string | undefined; body?: string | undefined }> = [];
6091 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
6092 const service = yield* makeService({
6093 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6094 providers: [
6095 fakeProvider(SourceControlProviderKind.make("github"), {
6096 updateChangeRequest: (input) => {
6097 received.push({ title: input.title, body: input.body });
6098 return Effect.void;
6099 },
6100 }),
6101 ],
6102 });
6103
6104 yield* service.update({ ...reference, title: "A better title" });
6105 yield* service.update({ ...reference, body: "" });
6106 yield* service.update({ ...reference, title: "Both", body: "at once" });
6107
6108 assert.deepStrictEqual(received, [
6109 { title: "A better title", body: undefined },
6110 { title: undefined, body: "" },
6111 { title: "Both", body: "at once" },
6112 ]);
6113 }),
6114);
6115
6116it.effect("refuses a rewrite that changes nothing, before any call is made", () =>
6117 Effect.gen(function* () {
6118 const service = yield* makeService({
6119 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6120 providers: [
6121 fakeProvider(SourceControlProviderKind.make("github"), {
6122 updateChangeRequest: () => Effect.die("must not be called"),
6123 }),
6124 ],
6125 });
6126
6127 const error = yield* Effect.flip(
6128 service.update({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }),
6129 );
6130
6131 assert.strictEqual(error._tag, "PullRequestOperationError");
6132 assert.include(error.message, "Nothing was changed.");
6133 }),
6134);
6135
6136it.effect("refuses to rewrite anything on a host that never claimed it", () =>
6137 Effect.gen(function* () {
6138 const service = yield* makeService({
6139 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6140 providers: [
6141 fakeProvider(SourceControlProviderKind.make("github"), {
6142 capabilities: {
6143 diff: true,
6144 comment: true,
6145 actions: ["merge"],
6146 mergeMethods: ["merge"],
6147 search: true,
6148 reactions: true,
6149 review: FULL_REVIEW,
6150 reviewers: FULL_REVIEWERS,
6151 },
6152 updateChangeRequest: () => Effect.die("must not be called"),
6153 updateComment: () => Effect.die("must not be called"),
6154 }),
6155 ],
6156 });
6157 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
6158
6159 const rewriteRefused = yield* Effect.flip(service.update({ ...reference, title: "New" }));
6160 const commentRefused = yield* Effect.flip(
6161 service.updateComment({
6162 ...reference,
6163 commentId: "IC_1",
6164 kind: "issue-comment",
6165 body: "New",
6166 }),
6167 );
6168
6169 assert.include(rewriteRefused.message, "cannot rewrite a change request.");
6170 assert.include(commentRefused.message, "cannot rewrite a comment.");
6171 }),
6172);
6173
6174it.effect("passes a rewritten remark through with the id and kind it arrived under", () =>
6175 Effect.gen(function* () {
6176 let received: { id: string; kind: string; body: string } | null = null;
6177 const service = yield* makeService({
6178 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6179 providers: [
6180 fakeProvider(SourceControlProviderKind.make("github"), {
6181 updateComment: (input) => {
6182 received = { id: input.commentId, kind: input.kind, body: input.body };
6183 return Effect.void;
6184 },
6185 }),
6186 ],
6187 });
6188
6189 yield* service.updateComment({
6190 projectId: "p1" as ProjectId,
6191 repository: "acme/web",
6192 number: 1,
6193 commentId: "PRRC_1",
6194 kind: "review-comment",
6195 body: "Second thoughts",
6196 });
6197
6198 assert.deepStrictEqual(received, {
6199 id: "PRRC_1",
6200 kind: "review-comment",
6201 body: "Second thoughts",
6202 });
6203 }),
6204);
6205
6206it.effect("refuses a remark rewritten into nothing but whitespace", () =>
6207 Effect.gen(function* () {
6208 const service = yield* makeService({
6209 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6210 providers: [
6211 fakeProvider(SourceControlProviderKind.make("github"), {
6212 updateComment: () => Effect.die("must not be called"),
6213 }),
6214 ],
6215 });
6216
6217 const error = yield* Effect.flip(
6218 service.updateComment({
6219 projectId: "p1" as ProjectId,
6220 repository: "acme/web",
6221 number: 1,
6222 commentId: "IC_1",
6223 kind: "issue-comment",
6224 body: " \n ",
6225 }),
6226 );
6227
6228 assert.include(error.message, "A comment cannot be empty.");
6229 }),
6230);
6231
6232it.effect("forgets the cached detail after a rewrite or terminal turn", () =>
6233 Effect.gen(function* () {
6234 let coreCalls = 0;
6235 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
6236 const service = yield* makeService({
6237 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6238 providers: [
6239 fakeProvider(SourceControlProviderKind.make("github"), {
6240 getChangeRequest: () => {
6241 coreCalls += 1;
6242 return Effect.succeed({
6243 ...changeRequest(1, "2026-07-02T00:00:00Z"),
6244 body: "",
6245 changedFiles: 0,
6246 mergedAt: null,
6247 closedAt: null,
6248 reviewers: [],
6249 checks: [],
6250 mergeCapabilities: { merge: true, squash: true, rebase: true },
6251 viewerPermissions: {
6252 actions: ["merge"],
6253 comment: true,
6254 resolve: true,
6255 verdicts: ["comment", "approve", "request-changes"],
6256 requestReviewers: true,
6257 },
6258 });
6259 },
6260 }),
6261 ],
6262 });
6263
6264 yield* service.detail(reference);
6265 yield* service.update({ ...reference, title: "Renamed" });
6266 yield* service.detail(reference);
6267 assert.strictEqual(coreCalls, 2);
6268
6269 yield* service.refreshAfterTurn("p1" as ProjectId);
6270 yield* service.detail(reference);
6271 assert.strictEqual(coreCalls, 3);
6272 }),
6273);
6274
6275it.effect("names the signed-in account in the detail, and says nothing where the host cannot", () =>
6276 Effect.gen(function* () {
6277 const detailFrom = (provider: PullRequestProviderApi) =>
6278 Effect.gen(function* () {
6279 const service = yield* makeService({
6280 projects: [
6281 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
6282 ],
6283 providers: [provider],
6284 });
6285 return yield* service.detail({
6286 projectId: "p1" as ProjectId,
6287 repository: "acme/web",
6288 number: 1,
6289 });
6290 });
6291 const readable = fakeProvider(SourceControlProviderKind.make("github"), {
6292 getChangeRequest: () =>
6293 Effect.succeed({
6294 ...changeRequest(1, "2026-07-02T00:00:00Z"),
6295 body: "",
6296 changedFiles: 0,
6297 mergedAt: null,
6298 closedAt: null,
6299 reviewers: [],
6300 checks: [],
6301 mergeCapabilities: { merge: true, squash: true, rebase: true },
6302 viewerPermissions: {
6303 actions: ["merge"],
6304 comment: true,
6305 resolve: true,
6306 verdicts: ["comment", "approve", "request-changes"],
6307 requestReviewers: true,
6308 },
6309 }),
6310 });
6311
6312 const named = yield* detailFrom(readable);
6313 const unnamed = yield* detailFrom({
6314 ...readable,
6315 getViewer: () =>
6316 Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")),
6317 });
6318
6319 assert.strictEqual(named.viewer, "bilal");
6320 assert.strictEqual(unnamed.viewer, undefined);
6321 }),
6322);
6323
6324it.effect("keeps the diff cached across a file being ticked off", () =>
6325 Effect.gen(function* () {
6326 let diffReads = 0;
6327 let viewedReads = 0;
6328 let state: "viewed" | "dismissed" = "viewed";
6329 const service = yield* makeService({
6330 projects: [
6331 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
6332 ],
6333 providers: [
6334 fakeProvider(SourceControlProviderKind.make("github"), {
6335 capabilities: {
6336 diff: true,
6337 comment: true,
6338 actions: ["merge"],
6339 mergeMethods: ["merge"],
6340 search: true,
6341 reactions: true,
6342 viewedFiles: "host",
6343 review: FULL_REVIEW,
6344 reviewers: FULL_REVIEWERS,
6345 },
6346 getDiff: () => {
6347 diffReads += 1;
6348 return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null });
6349 },
6350 getFilesViewed: () => {
6351 viewedReads += 1;
6352 return Effect.succeed({
6353 files: [{ path: "src/a.ts", state }],
6354 truncated: false,
6355 });
6356 },
6357 setFilesViewed: () => Effect.void,
6358 }),
6359 ],
6360 });
6361 const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 };
6362
6363 yield* service.diff(reference);
6364 yield* service.filesViewed(reference);
6365 yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] });
6366 yield* service.diff(reference);
6367 yield* service.filesViewed(reference);
6368
6369 // The press forgets only the reader's own ticks; cached diffs survive it.
6370 assert.strictEqual(diffReads, 1);
6371 assert.strictEqual(viewedReads, 2);
6372
6373 state = "dismissed";
6374 yield* service.invalidate({ reference, filesViewedOnly: true });
6375 yield* service.diff(reference);
6376 assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [
6377 { path: "src/a.ts", state: "dismissed" },
6378 ]);
6379 assert.strictEqual(diffReads, 1);
6380 assert.strictEqual(viewedReads, 3);
6381 }),
6382);
6383
6384it.effect("returns large diff slices intact without retaining them in either cache", () =>
6385 Effect.gen(function* () {
6386 let reads = 0;
6387 const patch = "\u{1f4bb}".repeat(140_000);
6388 const service = yield* makeService({
6389 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6390 providers: [
6391 fakeProvider(SourceControlProviderKind.make("github"), {
6392 getDiff: () =>
6393 Effect.sync(() => {
6394 reads += 1;
6395 return { patch, truncated: false, nextCursor: "2" };
6396 }),
6397 }),
6398 ],
6399 });
6400 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
6401 for (const input of [
6402 reference,
6403 { ...reference, cursor: "2" },
6404 { ...reference, commit: "a".repeat(40) },
6405 ]) {
6406 const before = reads;
6407 assert.deepStrictEqual(yield* service.diff(input), {
6408 patch,
6409 truncated: false,
6410 nextCursor: "2",
6411 });
6412 assert.deepStrictEqual(yield* service.diff(input), {
6413 patch,
6414 truncated: false,
6415 nextCursor: "2",
6416 });
6417 assert.strictEqual(reads, before + 2);
6418 }
6419 }),
6420);
6421
6422it.effect("caches a small replacement after releasing a large diff", () =>
6423 Effect.gen(function* () {
6424 let reads = 0;
6425 const largePatch = "x".repeat(300_000);
6426 const service = yield* makeService({
6427 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6428 providers: [
6429 fakeProvider(SourceControlProviderKind.make("github"), {
6430 getDiff: () =>
6431 Effect.sync(() => {
6432 reads += 1;
6433 return {
6434 patch: reads === 1 ? largePatch : "@@ small replacement",
6435 truncated: false,
6436 nextCursor: null,
6437 };
6438 }),
6439 }),
6440 ],
6441 });
6442 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
6443 assert.strictEqual((yield* service.diff(reference)).patch, largePatch);
6444 assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement");
6445 assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement");
6446 assert.strictEqual(reads, 2);
6447 }),
6448);
6449
6450it.effect("releases an expired diff nobody reads again", () =>
6451 Effect.gen(function* () {
6452 NodeV8.setFlagsFromString("--expose-gc");
6453 const collect = NodeVM.runInNewContext("gc") as () => void;
6454 const settle = Effect.promise(async () => {
6455 // A weak reference keeps its target for the rest of the job that created it.
6456 await new Promise((resolve) => setImmediate(resolve));
6457 collect();
6458 });
6459 const service = yield* makeService({
6460 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
6461 providers: [
6462 fakeProvider(SourceControlProviderKind.make("github"), {
6463 getDiff: () => Effect.sync(() => ({ patch: "@@", truncated: false, nextCursor: null })),
6464 }),
6465 ],
6466 });
6467 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
6468 const read = (input: Parameters<typeof service.diff>[0]) =>
6469 Effect.map(service.diff(input), (result) => new WeakRef(result));
6470 const page = yield* read(reference);
6471 const commit = yield* read({ ...reference, commit: "a".repeat(40) });
6472
6473 yield* TestClock.adjust("9 minutes");
6474 yield* settle;
6475 assert.isDefined(page.deref());
6476 assert.isDefined(commit.deref());
6477
6478 yield* TestClock.adjust("2 minutes");
6479 yield* settle;
6480 assert.isUndefined(page.deref());
6481 assert.isUndefined(commit.deref());
6482 }),
6483);
6484
6485const environmentViewedProvider = (
6486 revisions: Map<string, string>,
6487 asked: Array<ReadonlyArray<string>>,
6488 unreadable: ReadonlySet<string> = new Set(),
6489) =>
6490 fakeProvider(SourceControlProviderKind.make("gitlab"), {
6491 capabilities: {
6492 diff: true,
6493 comment: true,
6494 actions: ["merge"],
6495 mergeMethods: ["merge"],
6496 search: true,
6497 reactions: true,
6498 viewedFiles: "environment",
6499 review: FULL_REVIEW,
6500 reviewers: FULL_REVIEWERS,
6501 },
6502 getFilesViewed: () => Effect.die("the host keeps no marks of its own"),
6503 setFilesViewed: () => Effect.die("the host keeps no marks of its own"),
6504 // A merge confirms itself against the host before it says the change request landed.
6505 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
6506 getFileRevisions: (input) => {
6507 asked.push(input.paths);
6508 return Effect.succeed({
6509 // A path the host looked at and did not find is at the empty version, which is what a
6510 // file the change request deletes is at. One it could not look at is left out entirely.
6511 revisions: new Map(
6512 input.paths.flatMap((path) =>
6513 unreadable.has(path) ? [] : [[path, revisions.get(path) ?? ""] as const],
6514 ),
6515 ),
6516 });
6517 },
6518 });
6519
6520const environmentViewedService = (
6521 revisions: Map<string, string>,
6522 asked: Array<ReadonlyArray<string>>,
6523 unreadable: ReadonlySet<string> = new Set(),
6524) =>
6525 makeService({
6526 projects: [
6527 project({
6528 id: "p1",
6529 title: "on gitlab",
6530 workspaceRoot: "/a",
6531 repository: "group/project",
6532 provider: SourceControlProviderKind.make("gitlab"),
6533 }),
6534 ],
6535 providers: [environmentViewedProvider(revisions, asked, unreadable)],
6536 });
6537
6538const GITLAB_REFERENCE = {
6539 projectId: "p1" as ProjectId,
6540 repository: "group/project",
6541 number: 1,
6542};
6543
6544it.effect("tracks Forgejo viewed files through its diff and refuses truncated baselines", () =>
6545 Effect.gen(function* () {
6546 let alpha = "b485fe5";
6547 let truncated = false;
6548 let diffReads = 0;
6549 const provider = yield* ForgejoPullRequestProvider.make.pipe(
6550 Effect.provide(
6551 Layer.mock(ForgejoCli.ForgejoCli)({
6552 api: (input) => {
6553 assert.strictEqual(input.host, "forge.example:3000");
6554 const viewer = input.path === "user";
6555 if (!viewer) {
6556 assert.strictEqual(input.repository, "reviewer/project");
6557 assert.strictEqual(input.path, "repos/reviewer/project/pulls/1.diff");
6558 diffReads++;
6559 }
6560 return Effect.succeed({
6561 exitCode: ChildProcessSpawner.ExitCode(0),
6562 stdout: viewer
6563 ? JSON.stringify({ login: "reviewer" })
6564 : truncated
6565 ? `diff --git a/alpha.ts b/alpha.ts\nindex eb2d7c5..${alpha} 100644\n`
6566 : [
6567 "diff --git a/alpha.ts b/alpha.ts",
6568 `index eb2d7c5..${alpha} 100644`,
6569 "diff --git a/beta.ts b/beta.ts",
6570 "index b39e8b3..5851425 100644",
6571 'diff --git "a/caf\\303\\251 notes.txt" "b/caf\\303\\251 notes.txt"',
6572 "index ffd99ce..6dd9855 100644",
6573 "diff --git a/deleted.txt b/deleted.txt",
6574 "deleted file mode 100644",
6575 "index 233f5c6..0000000",
6576 "diff --git a/old.txt b/renamed.txt",
6577 "similarity index 100%",
6578 "rename from old.txt",
6579 "rename to renamed.txt",
6580 "",
6581 ].join("\n"),
6582 stderr: "",
6583 stdoutTruncated: !viewer && truncated,
6584 stderrTruncated: false,
6585 });
6586 },
6587 }),
6588 ),
6589 );
6590 const service = yield* makeService({
6591 projects: [
6592 project({
6593 id: "forgejo",
6594 title: "on forgejo",
6595 workspaceRoot: "/forgejo",
6596 repository: "reviewer/project",
6597 provider: SourceControlProviderKind.make("forgejo"),
6598 host: "forge.example:3000",
6599 remoteUrl: "http://forge.example:3000/reviewer/project.git",
6600 }),
6601 ],
6602 providers: [provider],
6603 });
6604 const reference = {
6605 projectId: "forgejo" as ProjectId,
6606 repository: "reviewer/project",
6607 host: "forge.example:3000",
6608 number: 1,
6609 };
6610 const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"];
6611 yield* service.setFilesViewed({
6612 ...reference,
6613 files: paths.map((path) => ({ path, viewed: true })),
6614 });
6615 assert.deepStrictEqual(
6616 new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])),
6617 new Map(paths.map((path) => [path, "viewed"])),
6618 );
6619 assert.strictEqual(diffReads, 1);
6620
6621 alpha = "aabbccd";
6622 yield* service.invalidate({ reference });
6623 assert.deepStrictEqual(
6624 new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])),
6625 new Map(paths.map((path) => [path, path === "alpha.ts" ? "dismissed" : "viewed"])),
6626 );
6627 yield* service.setFilesViewed({
6628 ...reference,
6629 files: [{ path: "beta.ts", viewed: false }],
6630 });
6631 assert.isFalse(
6632 (yield* service.filesViewed(reference)).files.some((file) => file.path === "beta.ts"),
6633 );
6634
6635 truncated = true;
6636 yield* service.invalidate({ reference });
6637 yield* service.setFilesViewed({
6638 ...reference,
6639 files: [{ path: "beta.ts", viewed: true }],
6640 });
6641 truncated = false;
6642 yield* service.invalidate({ reference });
6643 // A partial response must not stamp an empty revision and then dismiss the mark on recovery.
6644 assert.deepStrictEqual(
6645 (yield* service.filesViewed(reference)).files.find((file) => file.path === "beta.ts"),
6646 { path: "beta.ts", state: "viewed" },
6647 );
6648 }),
6649);
6650
6651it.effect("keeps hosted Forgejo marks with their repository instead of the serving checkout", () =>
6652 Effect.gen(function* () {
6653 const projects = [
6654 project({
6655 id: "p1",
6656 title: "first repository",
6657 workspaceRoot: "/first",
6658 repository: "reviewer/first",
6659 provider: SourceControlProviderKind.make("forgejo"),
6660 host: "forge.example",
6661 remoteUrl: "https://forge.example:3000/reviewer/first.git",
6662 }),
6663 ];
6664 const service = yield* makeService({
6665 projects,
6666 providers: [
6667 {
6668 ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []),
6669 kind: SourceControlProviderKind.make("forgejo"),
6670 },
6671 ],
6672 resolveHandle: ({ context }) =>
6673 Effect.succeed({
6674 context: {
6675 ...context!,
6676 provider: {
6677 kind: SourceControlProviderKind.make("forgejo"),
6678 name: "Forgejo",
6679 baseUrl: "https://forge.example:3000",
6680 },
6681 },
6682 provider: undefined as never,
6683 }),
6684 });
6685 const first = {
6686 projectId: "p1" as ProjectId,
6687 host: "forge.example:3000",
6688 repository: "reviewer/first",
6689 number: 1,
6690 };
6691 const second = { ...first, repository: "reviewer/second" };
6692 const files = [{ path: "same.ts", viewed: true }];
6693 yield* service.setFilesViewed({ ...first, files });
6694 assert.deepStrictEqual((yield* service.filesViewed(second)).files, []);
6695 yield* service.setFilesViewed({ ...second, files });
6696 yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] });
6697 assert.deepStrictEqual((yield* service.filesViewed(second)).files, [
6698 { path: "same.ts", state: "viewed" },
6699 ]);
6700
6701 projects.push(
6702 project({
6703 id: "p2",
6704 title: "second repository",
6705 workspaceRoot: "/second",
6706 repository: "reviewer/second",
6707 provider: SourceControlProviderKind.make("forgejo"),
6708 host: "forge.example",
6709 remoteUrl: "https://forge.example:3000/reviewer/second.git",
6710 }),
6711 );
6712 assert.deepStrictEqual(
6713 (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files,
6714 [{ path: "same.ts", state: "viewed" }],
6715 );
6716 projects.push(
6717 project({
6718 id: "ssh",
6719 title: "second repository over SSH",
6720 workspaceRoot: "/ssh",
6721 repository: "reviewer/second",
6722 provider: SourceControlProviderKind.make("forgejo"),
6723 host: "ssh.forge.example",
6724 remoteUrl: "git@ssh.forge.example:reviewer/second.git",
6725 }),
6726 );
6727 assert.deepStrictEqual(
6728 (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files,
6729 [{ path: "same.ts", state: "viewed" }],
6730 );
6731 }),
6732);
6733
6734it.effect("keeps viewed files itself for a host that keeps none of its own", () =>
6735 Effect.gen(function* () {
6736 const asked: Array<ReadonlyArray<string>> = [];
6737 const service = yield* environmentViewedService(
6738 new Map([
6739 ["src/a.ts", "blob-a"],
6740 ["src/b.ts", "blob-b"],
6741 ]),
6742 asked,
6743 );
6744
6745 // Nothing marked is nothing to ask the host about.
6746 const empty = yield* service.filesViewed(GITLAB_REFERENCE);
6747 assert.deepStrictEqual(empty, { files: [], truncated: false });
6748 assert.deepStrictEqual(asked, []);
6749
6750 yield* service.setFilesViewed({
6751 ...GITLAB_REFERENCE,
6752 files: [
6753 { path: "src/a.ts", viewed: true },
6754 { path: "src/b.ts", viewed: true },
6755 ],
6756 });
6757 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
6758
6759 assert.deepStrictEqual(
6760 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
6761 [
6762 { path: "src/a.ts", state: "viewed" },
6763 { path: "src/b.ts", state: "viewed" },
6764 ],
6765 );
6766 assert.strictEqual(marked.truncated, false);
6767 // The marked paths alone, so the cost follows how much has been read rather than PR size,
6768 // and the read after the press is answered from what the press already heard.
6769 assert.deepStrictEqual(
6770 asked.map((paths) => [...paths].toSorted()),
6771 [["src/a.ts", "src/b.ts"]],
6772 );
6773 }),
6774);
6775
6776it.effect("holds what a whole-change answer carried, so the next tick reads nothing", () =>
6777 Effect.gen(function* () {
6778 const asked: Array<ReadonlyArray<string>> = [];
6779 const head = new Map([
6780 ["src/a.ts", "blob-a"],
6781 ["src/b.ts", "blob-b"],
6782 ]);
6783 const service = yield* makeService({
6784 projects: [
6785 project({
6786 id: "p1",
6787 title: "on gitlab",
6788 workspaceRoot: "/a",
6789 repository: "group/project",
6790 provider: SourceControlProviderKind.make("gitlab"),
6791 }),
6792 ],
6793 providers: [
6794 {
6795 ...environmentViewedProvider(head, []),
6796 // A host with no per-file version reads the whole change to answer for one file, which
6797 // is what Bitbucket's patch is, and says as much.
6798 getFileRevisions: (input) => {
6799 asked.push(input.paths);
6800 return Effect.succeed({ revisions: head, complete: true });
6801 },
6802 },
6803 ],
6804 });
6805
6806 yield* service.setFilesViewed({
6807 ...GITLAB_REFERENCE,
6808 files: [{ path: "src/a.ts", viewed: true }],
6809 });
6810 // A path nothing has asked about before, which is what every tick after the first names. Its
6811 // version came back with the first answer, so there is nothing left to read it for.
6812 yield* service.setFilesViewed({
6813 ...GITLAB_REFERENCE,
6814 files: [{ path: "src/b.ts", viewed: true }],
6815 });
6816
6817 assert.deepStrictEqual(asked, [["src/a.ts"]]);
6818 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
6819 assert.deepStrictEqual(
6820 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
6821 [
6822 { path: "src/a.ts", state: "viewed" },
6823 { path: "src/b.ts", state: "viewed" },
6824 ],
6825 );
6826
6827 // Still only as fresh as the read it came from: past that window the press reads again rather
6828 // than stamping a mark with a version the head may have moved off.
6829 yield* TestClock.adjust("2 minutes");
6830 yield* service.setFilesViewed({
6831 ...GITLAB_REFERENCE,
6832 files: [{ path: "src/b.ts", viewed: true }],
6833 });
6834 assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]);
6835 }),
6836);
6837
6838it.effect("reads the marks without asking the host what the head has every time", () =>
6839 Effect.gen(function* () {
6840 const asked: Array<ReadonlyArray<string>> = [];
6841 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked);
6842
6843 yield* service.setFilesViewed({
6844 ...GITLAB_REFERENCE,
6845 files: [{ path: "src/a.ts", viewed: true }],
6846 });
6847 // Past the marks' own cache, so this read reaches the point where the host would be asked.
6848 yield* TestClock.adjust("20 seconds");
6849 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
6850
6851 assert.deepStrictEqual(marked.files, [{ path: "src/a.ts", state: "viewed" }]);
6852 assert.deepStrictEqual(asked, [["src/a.ts"]]);
6853 }),
6854);
6855
6856it.effect("answers the marks from what it last heard while it asks the host again", () =>
6857 Effect.gen(function* () {
6858 const asked: Array<ReadonlyArray<string>> = [];
6859 const revisions = new Map([["src/a.ts", "blob-a"]]);
6860 const service = yield* environmentViewedService(revisions, asked);
6861
6862 yield* service.setFilesViewed({
6863 ...GITLAB_REFERENCE,
6864 files: [{ path: "src/a.ts", viewed: true }],
6865 });
6866 revisions.set("src/a.ts", "blob-a-again");
6867 yield* TestClock.adjust("90 seconds");
6868 const held = yield* service.filesViewed(GITLAB_REFERENCE);
6869
6870 // The push is not in this answer, because waiting for the host is the thing being avoided.
6871 assert.deepStrictEqual(held.files, [{ path: "src/a.ts", state: "viewed" }]);
6872 assert.strictEqual(asked.length, 2);
6873
6874 yield* TestClock.adjust("20 seconds");
6875 const caught = yield* service.filesViewed(GITLAB_REFERENCE);
6876
6877 assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]);
6878 // The refresh behind the previous answer is the one that heard about the push.
6879 assert.strictEqual(asked.length, 2);
6880 }),
6881);
6882
6883it.effect("asks the host about a file it has not been asked about before", () =>
6884 Effect.gen(function* () {
6885 const asked: Array<ReadonlyArray<string>> = [];
6886 const service = yield* environmentViewedService(
6887 new Map([
6888 ["src/a.ts", "blob-a"],
6889 ["src/b.ts", "blob-b"],
6890 ]),
6891 asked,
6892 );
6893
6894 yield* service.setFilesViewed({
6895 ...GITLAB_REFERENCE,
6896 files: [{ path: "src/a.ts", viewed: true }],
6897 });
6898 yield* TestClock.adjust("20 seconds");
6899 yield* service.setFilesViewed({
6900 ...GITLAB_REFERENCE,
6901 files: [{ path: "src/b.ts", viewed: true }],
6902 });
6903 yield* TestClock.adjust("20 seconds");
6904 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
6905
6906 assert.deepStrictEqual(
6907 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
6908 [
6909 { path: "src/a.ts", state: "viewed" },
6910 { path: "src/b.ts", state: "viewed" },
6911 ],
6912 );
6913 // The second press paid for its own file; the read that follows was already covered.
6914 assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]);
6915 }),
6916);
6917
6918it.effect("does not let a press about one file keep another file's version alive", () =>
6919 Effect.gen(function* () {
6920 const asked: Array<ReadonlyArray<string>> = [];
6921 const revisions = new Map([
6922 ["src/a.ts", "blob-a"],
6923 ["src/b.ts", "blob-b"],
6924 ]);
6925 const service = yield* environmentViewedService(revisions, asked);
6926
6927 yield* service.setFilesViewed({
6928 ...GITLAB_REFERENCE,
6929 files: [{ path: "src/a.ts", viewed: true }],
6930 });
6931 yield* TestClock.adjust("40 seconds");
6932 // This press asks about its own file and carries the other one forward untouched. Counting
6933 // the whole scope as heard from would put the first file's version back inside the window it
6934 // had almost aged out of, and a reader working down a long diff renews it press after press.
6935 yield* service.setFilesViewed({
6936 ...GITLAB_REFERENCE,
6937 files: [{ path: "src/b.ts", viewed: true }],
6938 });
6939 assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]);
6940
6941 revisions.set("src/a.ts", "blob-a-again");
6942 yield* TestClock.adjust("30 seconds");
6943 yield* service.filesViewed(GITLAB_REFERENCE);
6944 assert.strictEqual(asked.length, 3);
6945
6946 yield* TestClock.adjust("20 seconds");
6947 const caught = yield* service.filesViewed(GITLAB_REFERENCE);
6948
6949 assert.deepStrictEqual(
6950 [...caught.files].toSorted((left, right) => left.path.localeCompare(right.path)),
6951 [
6952 { path: "src/a.ts", state: "dismissed" },
6953 { path: "src/b.ts", state: "viewed" },
6954 ],
6955 );
6956 }),
6957);
6958
6959it.effect("reports a file pushed to since it was cleared as changed", () =>
6960 Effect.gen(function* () {
6961 const revisions = new Map([
6962 ["src/a.ts", "blob-a"],
6963 ["src/b.ts", "blob-b"],
6964 ]);
6965 const service = yield* environmentViewedService(revisions, []);
6966
6967 yield* service.setFilesViewed({
6968 ...GITLAB_REFERENCE,
6969 files: [
6970 { path: "src/a.ts", viewed: true },
6971 { path: "src/b.ts", viewed: true },
6972 ],
6973 });
6974 revisions.set("src/a.ts", "blob-a-again");
6975 // A push is not something the marks can hear about, so the reader asks to be re-answered.
6976 yield* service.invalidate({ reference: GITLAB_REFERENCE });
6977 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
6978
6979 assert.deepStrictEqual(
6980 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
6981 [
6982 { path: "src/a.ts", state: "dismissed" },
6983 { path: "src/b.ts", state: "viewed" },
6984 ],
6985 );
6986 }),
6987);
6988
6989it.effect("clears a mark again when the file is put back", () =>
6990 Effect.gen(function* () {
6991 const asked: Array<ReadonlyArray<string>> = [];
6992 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked);
6993
6994 yield* service.setFilesViewed({
6995 ...GITLAB_REFERENCE,
6996 files: [{ path: "src/a.ts", viewed: true }],
6997 });
6998 yield* service.setFilesViewed({
6999 ...GITLAB_REFERENCE,
7000 files: [{ path: "src/a.ts", viewed: false }],
7001 });
7002 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
7003
7004 assert.deepStrictEqual(marked.files, []);
7005 // Unticking asks the host nothing: the row is going away whatever the head has.
7006 assert.deepStrictEqual(asked, [["src/a.ts"]]);
7007 }),
7008);
7009
7010it.effect("keeps a deleted file cleared, which the head has no version of at all", () =>
7011 Effect.gen(function* () {
7012 const service = yield* environmentViewedService(new Map(), []);
7013
7014 yield* service.setFilesViewed({
7015 ...GITLAB_REFERENCE,
7016 files: [{ path: "src/gone.ts", viewed: true }],
7017 });
7018 yield* service.invalidate({ reference: GITLAB_REFERENCE });
7019 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
7020
7021 assert.deepStrictEqual(marked.files, [{ path: "src/gone.ts", state: "viewed" }]);
7022 }),
7023);
7024
7025it.effect("leaves a mark alone when the host could not say what the head has of it", () =>
7026 Effect.gen(function* () {
7027 // A host answers for as much of a long change as it can read in one go. Reading the rest as
7028 // deleted would clear every file past the cut over a version nobody ever looked at.
7029 const revisions = new Map([
7030 ["src/a.ts", "blob-a"],
7031 ["src/past-the-cut.ts", "blob-b"],
7032 ]);
7033 const service = yield* environmentViewedService(
7034 revisions,
7035 [],
7036 new Set(["src/past-the-cut.ts"]),
7037 );
7038
7039 yield* service.setFilesViewed({
7040 ...GITLAB_REFERENCE,
7041 files: [
7042 { path: "src/a.ts", viewed: true },
7043 { path: "src/past-the-cut.ts", viewed: true },
7044 ],
7045 });
7046 revisions.set("src/a.ts", "blob-a-again");
7047 yield* service.invalidate({ reference: GITLAB_REFERENCE });
7048 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
7049
7050 assert.deepStrictEqual(
7051 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
7052 [
7053 { path: "src/a.ts", state: "dismissed" },
7054 { path: "src/past-the-cut.ts", state: "viewed" },
7055 ],
7056 );
7057 }),
7058);
7059
7060it.effect("keeps a file cleared that the press could not learn a version for", () =>
7061 Effect.gen(function* () {
7062 // The press is the only moment a mark is given something to be measured against, and a host
7063 // reading as much of a long change as it can manage does not always reach the file being
7064 // ticked. Storing the empty version there reads as the head having nothing of the file, so the
7065 // first read that does reach it reports the reader's own press back to them as work to do.
7066 const revisions = new Map([["src/past-the-cut.ts", "blob-b"]]);
7067 const unreadable = new Set(["src/past-the-cut.ts"]);
7068 const service = yield* environmentViewedService(revisions, [], unreadable);
7069
7070 yield* service.setFilesViewed({
7071 ...GITLAB_REFERENCE,
7072 files: [{ path: "src/past-the-cut.ts", viewed: true }],
7073 });
7074 unreadable.delete("src/past-the-cut.ts");
7075 yield* service.invalidate({ reference: GITLAB_REFERENCE });
7076
7077 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7078 { path: "src/past-the-cut.ts", state: "viewed" },
7079 ]);
7080 }),
7081);
7082
7083it.effect("keeps the version it last heard when a later read of the head stops short", () =>
7084 Effect.gen(function* () {
7085 const asked: Array<ReadonlyArray<string>> = [];
7086 const revisions = new Map([["src/a.ts", "blob-a"]]);
7087 const unreadable = new Set<string>();
7088 const service = yield* environmentViewedService(revisions, asked, unreadable);
7089
7090 yield* service.setFilesViewed({
7091 ...GITLAB_REFERENCE,
7092 files: [{ path: "src/a.ts", viewed: true }],
7093 });
7094 revisions.set("src/a.ts", "blob-a-again");
7095 yield* service.invalidate({ reference: GITLAB_REFERENCE });
7096 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7097 { path: "src/a.ts", state: "dismissed" },
7098 ]);
7099
7100 // The read behind the next answer has to stop before this file. Forgetting the version it was
7101 // last seen at would put the badge the reader has already been shown back to cleared, over an
7102 // answer that said nothing about the file either way.
7103 unreadable.add("src/a.ts");
7104 yield* TestClock.adjust("90 seconds");
7105 yield* service.filesViewed(GITLAB_REFERENCE);
7106 yield* TestClock.adjust("20 seconds");
7107
7108 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7109 { path: "src/a.ts", state: "dismissed" },
7110 ]);
7111 }),
7112);
7113
7114it.effect("re-asks what the head has of a marked file after a whole-workspace refresh", () =>
7115 Effect.gen(function* () {
7116 const revisions = new Map([["src/a.ts", "blob-a"]]);
7117 const service = yield* environmentViewedService(revisions, []);
7118
7119 yield* service.setFilesViewed({
7120 ...GITLAB_REFERENCE,
7121 files: [{ path: "src/a.ts", viewed: true }],
7122 });
7123 // A push nobody told this environment about. No single reference has moved, so the held
7124 // answer goes only because the refresh is the reader asking for all of it to be read again.
7125 revisions.set("src/a.ts", "blob-a-again");
7126 yield* service.invalidate({});
7127
7128 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7129 { path: "src/a.ts", state: "dismissed" },
7130 ]);
7131 }),
7132);
7133
7134it.effect("forgets what the head had of a marked file once a mutation moves the head", () =>
7135 Effect.gen(function* () {
7136 const revisions = new Map([["src/a.ts", "blob-a"]]);
7137 const service = yield* environmentViewedService(revisions, []);
7138
7139 yield* service.setFilesViewed({
7140 ...GITLAB_REFERENCE,
7141 files: [{ path: "src/a.ts", viewed: true }],
7142 });
7143 // Merging moves the head under the mark, and nobody asks for the refresh: the mutation is
7144 // the thing that knows, so it drops what it was holding rather than waiting to be told.
7145 revisions.set("src/a.ts", "blob-a-again");
7146 yield* service.runAction({ ...GITLAB_REFERENCE, action: "merge" });
7147
7148 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7149 { path: "src/a.ts", state: "dismissed" },
7150 ]);
7151 }),
7152);
7153
7154it.effect("still reports its own marks when the host will not say what the head has", () =>
7155 Effect.gen(function* () {
7156 let answering = true;
7157 const service = yield* makeService({
7158 projects: [
7159 project({
7160 id: "p1",
7161 title: "on gitlab",
7162 workspaceRoot: "/a",
7163 repository: "group/project",
7164 provider: SourceControlProviderKind.make("gitlab"),
7165 }),
7166 ],
7167 providers: [
7168 fakeProvider(SourceControlProviderKind.make("gitlab"), {
7169 capabilities: {
7170 diff: true,
7171 comment: true,
7172 actions: ["merge"],
7173 mergeMethods: ["merge"],
7174 search: true,
7175 reactions: true,
7176 viewedFiles: "environment",
7177 review: FULL_REVIEW,
7178 reviewers: FULL_REVIEWERS,
7179 },
7180 getFilesViewed: () => Effect.die("the host keeps no marks of its own"),
7181 setFilesViewed: () => Effect.die("the host keeps no marks of its own"),
7182 getFileRevisions: (input) =>
7183 answering
7184 ? Effect.succeed({
7185 revisions: new Map(input.paths.map((path) => [path, "blob-a"] as const)),
7186 })
7187 : Effect.fail(requestFailed),
7188 }),
7189 ],
7190 });
7191
7192 yield* service.setFilesViewed({
7193 ...GITLAB_REFERENCE,
7194 files: [{ path: "src/a.ts", viewed: true }],
7195 });
7196 answering = false;
7197 yield* service.invalidate({ reference: GITLAB_REFERENCE });
7198
7199 // The rows are this environment's own. A rate limit or a signed-out CLI costs them the
7200 // staleness they would have carried, not the reader's whole record of what they have read.
7201 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7202 { path: "src/a.ts", state: "viewed" },
7203 ]);
7204 }),
7205);
7206
7207it.effect("finishes two presses on one file in the order they were made", () =>
7208 Effect.gen(function* () {
7209 // A tick asks the host what it has of the file before it stores anything, and an untick asks
7210 // nothing at all, so the second press would otherwise land first and be overwritten by the
7211 // first one finishing behind it.
7212 const held = yield* Deferred.make<void>();
7213 const service = yield* makeService({
7214 projects: [
7215 project({
7216 id: "p1",
7217 title: "on gitlab",
7218 workspaceRoot: "/a",
7219 repository: "group/project",
7220 provider: SourceControlProviderKind.make("gitlab"),
7221 }),
7222 ],
7223 providers: [
7224 fakeProvider(SourceControlProviderKind.make("gitlab"), {
7225 capabilities: {
7226 diff: true,
7227 comment: true,
7228 actions: ["merge"],
7229 mergeMethods: ["merge"],
7230 search: true,
7231 reactions: true,
7232 viewedFiles: "environment",
7233 review: FULL_REVIEW,
7234 reviewers: FULL_REVIEWERS,
7235 },
7236 getFilesViewed: () => Effect.die("the host keeps no marks of its own"),
7237 setFilesViewed: () => Effect.die("the host keeps no marks of its own"),
7238 getFileRevisions: (input) =>
7239 Deferred.await(held).pipe(
7240 Effect.as({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }),
7241 ),
7242 }),
7243 ],
7244 });
7245
7246 const tick = service
7247 .setFilesViewed({
7248 ...GITLAB_REFERENCE,
7249 files: [{ path: "src/a.ts", viewed: true }],
7250 })
7251 .pipe(Effect.runFork);
7252 // Far enough for the tick to be waiting on the host rather than still on its way there.
7253 yield* TestClock.adjust("1 second");
7254 const untick = service
7255 .setFilesViewed({
7256 ...GITLAB_REFERENCE,
7257 files: [{ path: "src/a.ts", viewed: false }],
7258 })
7259 .pipe(Effect.runFork);
7260 yield* TestClock.adjust("1 second");
7261 yield* Deferred.succeed(held, undefined);
7262 yield* Fiber.join(tick);
7263 yield* Fiber.join(untick);
7264
7265 // The untick came second and stands: the file is open again.
7266 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
7267 assert.deepStrictEqual(marked.files, []);
7268 }),
7269);
7270
7271/** Azure, whose selector is a bare repository name, backed by this environment's own marks. */
7272const azureViewedService = (projects: ReadonlyArray<OrchestrationProjectShell>) =>
7273 makeService({
7274 projects,
7275 providers: [
7276 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
7277 capabilities: {
7278 diff: true,
7279 comment: true,
7280 actions: ["merge"],
7281 mergeMethods: ["merge"],
7282 search: true,
7283 reactions: true,
7284 viewedFiles: "environment",
7285 review: FULL_REVIEW,
7286 reviewers: FULL_REVIEWERS,
7287 },
7288 getFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"),
7289 setFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"),
7290 getFileRevisions: (input) =>
7291 Effect.succeed({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }),
7292 }),
7293 ],
7294 });
7295
7296const AZURE_PAIR = [
7297 project({
7298 id: "p1",
7299 title: "platform web",
7300 workspaceRoot: "/a",
7301 repository: "acme/platform/_git/web",
7302 provider: SourceControlProviderKind.make("azure-devops"),
7303 host: "dev.azure.com",
7304 }),
7305 project({
7306 id: "p2",
7307 title: "other web",
7308 workspaceRoot: "/b",
7309 repository: "acme/other/_git/web",
7310 provider: SourceControlProviderKind.make("azure-devops"),
7311 host: "dev.azure.com",
7312 }),
7313];
7314
7315const AZURE_PLATFORM = { projectId: "p1" as ProjectId, repository: "web", number: 1 };
7316const AZURE_OTHER = { projectId: "p2" as ProjectId, repository: "web", number: 1 };
7317
7318it.effect("keeps the marks of two Azure repositories of the same name apart", () =>
7319 Effect.gen(function* () {
7320 // Azure addresses a repository by its bare name, which is unique inside one of its projects
7321 // and not across an organisation. Two `web` repositories would otherwise share one row.
7322 const service = yield* azureViewedService(AZURE_PAIR);
7323
7324 yield* service.setFilesViewed({
7325 ...AZURE_PLATFORM,
7326 files: [{ path: "src/a.ts", viewed: true }],
7327 });
7328
7329 assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [
7330 { path: "src/a.ts", state: "viewed" },
7331 ]);
7332 assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []);
7333 }),
7334);
7335
7336it.effect("keeps environment marks apart from another change request's", () =>
7337 Effect.gen(function* () {
7338 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []);
7339
7340 yield* service.setFilesViewed({
7341 ...GITLAB_REFERENCE,
7342 files: [{ path: "src/a.ts", viewed: true }],
7343 });
7344 const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 });
7345
7346 assert.deepStrictEqual(other.files, []);
7347 }),
7348);
7349
7350it.effect("bounds the paths one change request's held revisions carry", () =>
7351 Effect.gen(function* () {
7352 // The cache's count bounds how many change requests are held, not what any one of them holds:
7353 // a reader ticking a wide change request renews the same entry on every press and adds a path
7354 // to it each time. A press carries at most half the cap, so going one past it takes three.
7355 const asked: Array<ReadonlyArray<string>> = [];
7356 const service = yield* environmentViewedService(new Map(), asked);
7357 const batch = (prefix: string, count: number) =>
7358 Array.from({ length: count }, (_, index) => ({
7359 path: `${prefix}/${String(index).padStart(4, "0")}.ts`,
7360 viewed: true,
7361 }));
7362 const press = (prefix: string, count: number) =>
7363 service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) });
7364
7365 yield* press("a", MAX_FILE_REVISION_PATHS / 2);
7366 yield* press("b", MAX_FILE_REVISION_PATHS / 2);
7367 yield* press("c", 1);
7368 const pressed = asked.length;
7369
7370 // The marks a read carries come first by path, so this one covers the earliest batch, which
7371 // is where the paths asked about longest ago are. Held short of them the entry no longer
7372 // answers the read, and the host is asked rather than the reader being told a version that
7373 // nothing holds any more.
7374 yield* service.filesViewed(GITLAB_REFERENCE);
7375
7376 assert.strictEqual(asked.length, pressed + 1);
7377 assert.ok(asked.at(-1)?.includes("a/0000.ts"));
7378 }),
7379);
7380
7381it.effect("keeps the marked paths when a whole-change answer is wider than the cap", () =>
7382 Effect.gen(function* () {
7383 // A host with no per-file version answers with the whole change, which on a wide review
7384 // carries more paths than one entry holds. What the trim reaches has to be the paths the
7385 // answer threw in rather than the ones the reader ticked: a mark stored with no baseline
7386 // reports viewed however far the head moves off it.
7387 const head = new Map<string, string>(
7388 Array.from(
7389 { length: MAX_FILE_REVISION_PATHS + 178 },
7390 (_, index) =>
7391 [`src/f${String(index).padStart(4, "0")}.ts`, `blob-${String(index)}`] as const,
7392 ),
7393 );
7394 const service = yield* makeService({
7395 projects: [
7396 project({
7397 id: "p1",
7398 title: "on gitlab",
7399 workspaceRoot: "/a",
7400 repository: "group/project",
7401 provider: SourceControlProviderKind.make("gitlab"),
7402 }),
7403 ],
7404 providers: [
7405 {
7406 ...environmentViewedProvider(head, []),
7407 getFileRevisions: () => Effect.succeed({ revisions: head, complete: true }),
7408 },
7409 ],
7410 });
7411 const ticked = ["src/f0000.ts", "src/f0500.ts"];
7412
7413 yield* service.setFilesViewed({
7414 ...GITLAB_REFERENCE,
7415 files: ticked.map((path) => ({ path, viewed: true })),
7416 });
7417 for (const path of ticked) head.set(path, "blob-moved");
7418 // Past the stale window, so the read is answered by the host rather than from what the press
7419 // heard.
7420 yield* TestClock.adjust("11 minutes");
7421 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
7422
7423 assert.deepStrictEqual(
7424 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
7425 [
7426 { path: "src/f0000.ts", state: "dismissed" },
7427 { path: "src/f0500.ts", state: "dismissed" },
7428 ],
7429 );
7430 }),
7431);
7432
7433it.effect("keeps the change request being ticked through, not the one pressed first", () =>
7434 Effect.gen(function* () {
7435 // Ordered by insertion alone a hit does not renew its entry, so the review a reader is
7436 // working down is the first thing dropped once a cache's worth of other change requests have
7437 // been pressed, and the next press on it pays a host read for a version already held.
7438 const asked: Array<ReadonlyArray<string>> = [];
7439 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked);
7440 const press = (number: number) =>
7441 service.setFilesViewed({
7442 ...GITLAB_REFERENCE,
7443 number,
7444 files: [{ path: "src/a.ts", viewed: true }],
7445 });
7446
7447 yield* press(1);
7448 // A cache's worth of other change requests, with the open one pressed in between each.
7449 for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) {
7450 yield* press(2 + filled);
7451 yield* press(1);
7452 }
7453
7454 assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY);
7455 }),
7456);
7457
7458/** The environment-backed fixture with its own answer to who the reader is. */
7459const environmentViewedServiceWithViewer = (
7460 revisions: Map<string, string>,
7461 getViewer: PullRequestProviderApi["getViewer"],
7462) =>
7463 makeService({
7464 projects: [
7465 project({
7466 id: "p1",
7467 title: "on gitlab",
7468 workspaceRoot: "/a",
7469 repository: "group/project",
7470 provider: SourceControlProviderKind.make("gitlab"),
7471 }),
7472 ],
7473 providers: [{ ...environmentViewedProvider(revisions, []), getViewer }],
7474 });
7475
7476it.effect("keeps one reader's marks on a host that names nobody", () =>
7477 Effect.gen(function* () {
7478 const service = yield* environmentViewedServiceWithViewer(
7479 new Map([["src/a.ts", "blob-a"]]),
7480 () => Effect.succeed(""),
7481 );
7482
7483 yield* service.setFilesViewed({
7484 ...GITLAB_REFERENCE,
7485 files: [{ path: "src/a.ts", viewed: true }],
7486 });
7487
7488 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7489 { path: "src/a.ts", state: "viewed" },
7490 ]);
7491 }),
7492);
7493
7494it.effect("puts a listing and a press for one host on a single viewer lookup", () =>
7495 Effect.gen(function* () {
7496 let viewerLookups = 0;
7497 const service = yield* makeService({
7498 projects: [
7499 project({
7500 id: "p1",
7501 title: "on gitlab",
7502 workspaceRoot: "/a",
7503 repository: "group/project",
7504 provider: SourceControlProviderKind.make("gitlab"),
7505 }),
7506 ],
7507 providers: [
7508 {
7509 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
7510 getViewer: () =>
7511 Effect.gen(function* () {
7512 viewerLookups += 1;
7513 // Suspends before answering, as a subprocess would, so both callers are in flight
7514 // at once rather than the second finding the first has already answered.
7515 yield* Effect.yieldNow;
7516 return "bilal";
7517 }),
7518 },
7519 ],
7520 });
7521
7522 // What a cold page load does: read the listing and the reader's own marks at the same time.
7523 // Nothing about which of them asked is in the lookup's key, so they wait on one CLI between
7524 // them rather than starting one each.
7525 yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], {
7526 concurrency: 2,
7527 });
7528
7529 assert.strictEqual(viewerLookups, 1);
7530 }),
7531);
7532
7533it.effect("carries a bounded number of its own marks and says it held more", () =>
7534 Effect.gen(function* () {
7535 const asked: Array<ReadonlyArray<string>> = [];
7536 const paths = Array.from(
7537 { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 },
7538 (_, at) => `src/f${String(at).padStart(4, "0")}.ts`,
7539 );
7540 const service = yield* environmentViewedService(
7541 new Map(paths.map((path) => [path, "blob"] as const)),
7542 asked,
7543 );
7544
7545 yield* service.setFilesViewed({
7546 ...GITLAB_REFERENCE,
7547 files: paths.map((path) => ({ path, viewed: true })),
7548 });
7549 const read = yield* service.filesViewed(GITLAB_REFERENCE);
7550
7551 // Every mark read is a path held in a set and a map for as long as the caller holds the read,
7552 // per scope it is holding, so the rows are bounded rather than however many a reader has ever
7553 // ticked. The reader is told the count is short rather than shown a quietly clipped list.
7554 assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS);
7555 assert.strictEqual(read.truncated, true);
7556 }),
7557);
7558
7559it.effect("records a press while the host is backing off", () =>
7560 Effect.gen(function* () {
7561 let viewerLookups = 0;
7562 const service = yield* makeService({
7563 projects: [
7564 project({
7565 id: "p1",
7566 title: "on gitlab",
7567 workspaceRoot: "/a",
7568 repository: "group/project",
7569 provider: SourceControlProviderKind.make("gitlab"),
7570 }),
7571 ],
7572 providers: [
7573 {
7574 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
7575 getViewer: () =>
7576 Effect.sync(() => {
7577 viewerLookups += 1;
7578 return "bilal";
7579 }),
7580 // Backing off for the hour, so the pause outlives the ten minutes who is signed in is
7581 // held for and the second press has to ask again while it is on.
7582 getFileRevisions: () =>
7583 Effect.fail(
7584 new PullRequestProviderError({
7585 provider: SourceControlProviderKind.make("gitlab"),
7586 operation: "getFileRevisions",
7587 reason: "rate-limited",
7588 detail: "API rate limit exceeded.",
7589 retryAt: 60 * 60 * 1_000,
7590 }),
7591 ),
7592 },
7593 ],
7594 });
7595
7596 yield* service.setFilesViewed({
7597 ...GITLAB_REFERENCE,
7598 files: [{ path: "src/a.ts", viewed: true }],
7599 });
7600 yield* TestClock.adjust("11 minutes");
7601 yield* service.setFilesViewed({
7602 ...GITLAB_REFERENCE,
7603 files: [{ path: "src/b.ts", viewed: true }],
7604 });
7605
7606 // Nothing about a host holding its reads off says the reader did not press these, and these
7607 // rows are this environment's own. They keep no baseline, because none was read, so they hold
7608 // until they are pressed again rather than reporting a staleness nobody looked up.
7609 assert.deepStrictEqual(
7610 [...(yield* service.filesViewed(GITLAB_REFERENCE)).files].toSorted((left, right) =>
7611 left.path.localeCompare(right.path),
7612 ),
7613 [
7614 { path: "src/a.ts", state: "viewed" },
7615 { path: "src/b.ts", state: "viewed" },
7616 ],
7617 );
7618 assert.strictEqual(viewerLookups, 2);
7619 }),
7620);
7621
7622it.effect("asks who is reading through a pause only for the press that is waiting on it", () =>
7623 Effect.gen(function* () {
7624 let viewerLookups = 0;
7625 const service = yield* makeService({
7626 projects: [
7627 project({
7628 id: "p1",
7629 title: "on gitlab",
7630 workspaceRoot: "/a",
7631 repository: "group/project",
7632 provider: SourceControlProviderKind.make("gitlab"),
7633 }),
7634 ],
7635 providers: [
7636 {
7637 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
7638 getViewer: () =>
7639 Effect.sync(() => {
7640 viewerLookups += 1;
7641 return "bilal";
7642 }),
7643 listChangeRequests: () =>
7644 Effect.succeed({ items: [], truncated: false, continues: true }),
7645 // Backing off for the hour, so the pause outlives the ten minutes who is signed in is
7646 // held for.
7647 getFileRevisions: () =>
7648 Effect.fail(
7649 new PullRequestProviderError({
7650 provider: SourceControlProviderKind.make("gitlab"),
7651 operation: "getFileRevisions",
7652 reason: "rate-limited",
7653 detail: "API rate limit exceeded.",
7654 retryAt: 60 * 60 * 1_000,
7655 }),
7656 ),
7657 },
7658 ],
7659 });
7660
7661 yield* service.setFilesViewed({
7662 ...GITLAB_REFERENCE,
7663 files: [{ path: "src/a.ts", viewed: true }],
7664 });
7665 assert.strictEqual(viewerLookups, 1);
7666 yield* TestClock.adjust("11 minutes");
7667
7668 // A listing is not the reader waiting on this lookup, and a failed one is held nowhere, so
7669 // letting it through would spawn the host's CLI on every refresh for as long as the pause
7670 // lasts and re-extend it each time.
7671 const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
7672 assert.strictEqual(listed._tag, "PullRequestOperationError");
7673 assert.strictEqual(viewerLookups, 1);
7674
7675 // The press is bounded by what the reader does, and its rows are keyed by who they are, so
7676 // it is asked rather than refused.
7677 yield* service.setFilesViewed({
7678 ...GITLAB_REFERENCE,
7679 files: [{ path: "src/b.ts", viewed: true }],
7680 });
7681 assert.strictEqual(viewerLookups, 2);
7682 }),
7683);
7684
7685it.effect("does not ask a paused host who is reading again after the ask failed", () =>
7686 Effect.gen(function* () {
7687 let viewerLookups = 0;
7688 const service = yield* makeService({
7689 projects: [
7690 project({
7691 id: "p1",
7692 title: "on gitlab",
7693 workspaceRoot: "/a",
7694 repository: "group/project",
7695 provider: SourceControlProviderKind.make("gitlab"),
7696 }),
7697 ],
7698 providers: [
7699 {
7700 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
7701 getViewer: () =>
7702 Effect.suspend(() => {
7703 viewerLookups += 1;
7704 return Effect.fail(
7705 new PullRequestProviderError({
7706 provider: SourceControlProviderKind.make("gitlab"),
7707 operation: "getViewer",
7708 reason: "failed",
7709 detail: "glab exited with status 1",
7710 }),
7711 );
7712 }),
7713 listChangeRequests: () =>
7714 Effect.succeed({ items: [], truncated: false, continues: true }),
7715 runAction: () =>
7716 Effect.fail(
7717 new PullRequestProviderError({
7718 provider: SourceControlProviderKind.make("gitlab"),
7719 operation: "runAction",
7720 reason: "rate-limited",
7721 detail: "API rate limit exceeded.",
7722 retryAt: 60 * 60 * 1_000,
7723 }),
7724 ),
7725 },
7726 ],
7727 });
7728
7729 yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE));
7730 assert.strictEqual(viewerLookups, 1);
7731 yield* Effect.flip(service.runAction({ ...GITLAB_REFERENCE, action: "merge" }));
7732
7733 // A failed lookup is held nowhere, so a background read let through the pause would spawn the
7734 // host's CLI on every refresh for as long as the pause lasted, and re-extend it each time.
7735 yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
7736 yield* TestClock.adjust("11 minutes");
7737 yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
7738 assert.strictEqual(viewerLookups, 1);
7739 }),
7740);
7741
7742it.effect("refuses the marks when the host could not be asked who is reading", () =>
7743 Effect.gen(function* () {
7744 let answering = true;
7745 const service = yield* environmentViewedServiceWithViewer(
7746 new Map([["src/a.ts", "blob-a"]]),
7747 () =>
7748 answering
7749 ? Effect.succeed("bilal")
7750 : Effect.fail(
7751 new PullRequestProviderError({
7752 provider: SourceControlProviderKind.make("gitlab"),
7753 operation: "getViewer",
7754 reason: "failed",
7755 detail: "glab exited with status 1",
7756 }),
7757 ),
7758 );
7759
7760 yield* service.setFilesViewed({
7761 ...GITLAB_REFERENCE,
7762 files: [{ path: "src/a.ts", viewed: true }],
7763 });
7764 // Who is signed in is held for ten minutes, so the lookup has to come round again before a
7765 // failing CLI can reach the read at all.
7766 answering = false;
7767 yield* TestClock.adjust("11 minutes");
7768
7769 // Answering these from the unnamed reader's rows would show the reader none of their own
7770 // ticks, and file the next press where the recovered CLI will never look for it again.
7771 const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE));
7772 const write = yield* Effect.flip(
7773 service.setFilesViewed({
7774 ...GITLAB_REFERENCE,
7775 files: [{ path: "src/b.ts", viewed: true }],
7776 }),
7777 );
7778 assert.strictEqual(read._tag, "PullRequestOperationError");
7779 assert.strictEqual(write._tag, "PullRequestOperationError");
7780
7781 answering = true;
7782 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
7783 { path: "src/a.ts", state: "viewed" },
7784 ]);
7785 }),
7786);
7787
7788it.effect("refuses to track viewed files on a host that does not", () =>
7789 Effect.gen(function* () {
7790 const service = yield* makeService({
7791 projects: [
7792 project({
7793 id: "p1",
7794 title: "on gitlab",
7795 workspaceRoot: "/a",
7796 repository: "group/project",
7797 provider: SourceControlProviderKind.make("gitlab"),
7798 }),
7799 ],
7800 providers: [
7801 fakeProvider(SourceControlProviderKind.make("gitlab"), {
7802 getFilesViewed: () => Effect.die("must not be called"),
7803 setFilesViewed: () => Effect.die("must not be called"),
7804 }),
7805 ],
7806 });
7807 const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 };
7808
7809 const read = yield* Effect.flip(service.filesViewed(reference));
7810 const write = yield* Effect.flip(
7811 service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }),
7812 );
7813
7814 assert.strictEqual(read._tag, "PullRequestOperationError");
7815 assert.strictEqual(write._tag, "PullRequestOperationError");
7816 }),
7817);
7818
7819it.effect("keeps Azure continuation cursors separate for repositories with the same name", () =>
7820 Effect.gen(function* () {
7821 const seen: string[] = [];
7822 const service = yield* makeService({
7823 projects: ["org-a", "org-b"].map((organization) =>
7824 project({
7825 id: organization,
7826 title: organization,
7827 workspaceRoot: `/${organization}`,
7828 repository: `${organization}/project/_git/web`,
7829 provider: SourceControlProviderKind.make("azure-devops"),
7830 host: "dev.azure.com",
7831 }),
7832 ),
7833 providers: [
7834 fakeProvider(SourceControlProviderKind.make("azure-devops"), {
7835 listChangeRequests: (input) =>
7836 Effect.sync(() => {
7837 seen.push(input.cwd);
7838 return {
7839 items: [changeRequest(7, "2026-07-02T00:00:00Z")],
7840 truncated: true,
7841 continues: true,
7842 };
7843 }),
7844 }),
7845 ],
7846 });
7847 const first = yield* service.list({ state: "open" });
7848 assert.lengthOf(Object.keys(first.nextCursors), 2);
7849 const key = Object.keys(first.nextCursors).find((key) => key.includes("org-b"))!;
7850 seen.length = 0;
7851 yield* service.list({ state: "open", cursors: { [key]: first.nextCursors[key]! } });
7852 assert.deepStrictEqual(seen, ["/org-b"]);
7853 }),
7854);
7855
7856it.effect.each([
7857 { action: "merge", environmentValue: true, projectValue: false, expected: false },
7858 { action: "merge", environmentValue: false, projectValue: true, expected: true },
7859 { action: "merge", environmentValue: true, projectValue: undefined, expected: true },
7860 { action: "merge", environmentValue: false, projectValue: undefined, expected: false },
7861 { action: "enable-auto-merge", environmentValue: true, projectValue: false, expected: false },
7862 { action: "enable-auto-merge", environmentValue: false, projectValue: true, expected: true },
7863 { action: "enable-auto-merge", environmentValue: true, projectValue: undefined, expected: true },
7864] as const)(
7865 "resolves agent credits for $action with environment=$environmentValue and project=$projectValue",
7866 ({ action, environmentValue, projectValue, expected }) =>
7867 Effect.gen(function* () {
7868 const calls: boolean[] = [];
7869 const service = yield* makeService({
7870 projects: [
7871 project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }),
7872 ],
7873 settings: {
7874 ...DEFAULT_SERVER_SETTINGS,
7875 removeAgentCreditsOnMerge: environmentValue,
7876 projectSettingsOverrides:
7877 projectValue === undefined
7878 ? {}
7879 : {
7880 ["p1" as ProjectId]: { removeAgentCreditsOnMerge: projectValue },
7881 },
7882 },
7883 providers: [
7884 fakeProvider(SourceControlProviderKind.make("github"), {
7885 capabilities: {
7886 ...fakeProvider(SourceControlProviderKind.make("github")).capabilities,
7887 actions: ["merge", "enable-auto-merge"],
7888 },
7889 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
7890 getViewerPermissions: () =>
7891 Effect.succeed({
7892 actions: ["merge", "enable-auto-merge"],
7893 comment: true,
7894 resolve: true,
7895 verdicts: ["comment"],
7896 requestReviewers: false,
7897 }),
7898 runAction: (input) =>
7899 Effect.sync(() => {
7900 calls.push(input.removeAgentCreditsOnMerge === true);
7901 }),
7902 }),
7903 ],
7904 });
7905 yield* service.runAction({
7906 projectId: "p1" as ProjectId,
7907 repository: "acme/web",
7908 number: 1,
7909 action,
7910 });
7911 assert.deepStrictEqual(calls, [expected]);
7912 }),
7913);
7914
7915it.effect(
7916 "configured work PR reads use live Mergebot, and unsupported routes never query GitHub",
7917 () =>
7918 Effect.gen(function* () {
7919 let value: import("@t3tools/contracts").MergebotPullRequestDetails | null = {
7920 number: 42,
7921 headSha: "head",
7922 lifecycle: "open",
7923 queueStatus: "waiting_ci",
7924 position: 1,
7925 mergeEtaAt: null,
7926 title: "Ship change",
7927 body: null,
7928 url: "https://mergebot.example/work/app/pull/42",
7929 createdAt: "2026-10-14T10:00:00Z",
7930 updatedAt: "2026-10-14T10:01:00Z",
7931 author: { login: "clover", name: null, avatarUrl: null },
7932 additions: null,
7933 deletions: null,
7934 changedFilesCount: null,
7935 labels: [],
7936 comments: [],
7937 observedAt: 1791972060000,
7938 draft: false,
7939 baseBranch: "main",
7940 headBranch: "feature",
7941 ciStatus: "pending",
7942 hasConflicts: null,
7943 checks: [],
7944 };
7945 const service = yield* makeService({
7946 projects: [
7947 project({ id: "work", title: "Work", workspaceRoot: "/work", repository: "work/app" }),
7948 ],
7949 providers: [
7950 fakeProvider(SourceControlProviderKind.make("github"), {
7951 getViewer: () => Effect.die("Work reads must not query GitHub"),
7952 getChangeRequestSummary: () => Effect.die("Work reads must not query GitHub"),
7953 getChangeRequestPreview: () => Effect.die("Work reads must not query GitHub"),
7954 getChangeRequestChecks: () => Effect.die("Work reads must not query GitHub"),
7955 getChangeRequestWatchFingerprint: () => Effect.die("Work reads must not query GitHub"),
7956 listChangeRequests: () => Effect.die("Work reads must not query GitHub"),
7957 }),
7958 ],
7959 mergebot: {
7960 configuration: { origin: "https://mergebot.example", repositories: ["work/app"] },
7961 read: () => Effect.succeed(null),
7962 watchPullRequestStatus: () => Stream.empty,
7963 watchDeployments: () => Stream.empty,
7964 openPullRequest: () => Effect.die("unused"),
7965 setAutoMerge: () => Effect.die("unused"),
7966 updatePullRequest: () => Effect.die("unused"),
7967 deployProduction: () => Effect.die("unused"),
7968 watchQueue: () => Stream.empty,
7969 readPullRequest: () => Effect.sync(() => value),
7970 findPullRequest: () => Effect.succeed(null),
7971 streamChanges: Stream.empty,
7972 },
7973 });
7974 const reference = { projectId: "work" as ProjectId, repository: "work/app", number: 42 };
7975 const first = yield* service.summary(reference);
7976 assert.strictEqual(first.title, "Ship change");
7977 assert.isUndefined(first.additions);
7978 assert.isUndefined(first.mergedAt);
7979 assert.strictEqual((yield* service.preview(reference)).createdAt, "2026-10-14T10:00:00Z");
7980 assert.deepStrictEqual((yield* service.listStats({ refs: [reference] })).stats, []);
7981 assert.deepStrictEqual(
7982 (yield* service.list({ state: "open" })).errors.map((error) => error.projectId),
7983 [reference.projectId],
7984 );
7985 assert.include((yield* Effect.flip(service.detail(reference))).message, "Mergebot");
7986 assert.include((yield* Effect.flip(service.activity(reference))).message, "Mergebot");
7987 assert.include((yield* Effect.flip(service.stack(reference))).message, "Mergebot");
7988 const fingerprint = yield* service.watchFingerprint(reference);
7989 value = {
7990 ...value!,
7991 title: "Changed live",
7992 ciStatus: "failed",
7993 checks: [
7994 {
7995 id: "check:2",
7996 name: "Tests",
7997 workflowName: "CI",
7998 status: "completed",
7999 conclusion: "failure",
8000 url: null,
8001 startedAt: null,
8002 completedAt: "2026-10-14T10:02:00Z",
8003 },
8004 ],
8005 };
8006 assert.strictEqual((yield* service.summary(reference)).title, "Changed live");
8007 assert.strictEqual((yield* service.checks(reference))?.checks[0]?.status, "failure");
8008 assert.notDeepEqual(yield* service.watchFingerprint(reference), fingerprint);
8009 value = null;
8010 assert.include((yield* Effect.flip(service.summary(reference))).message, "unavailable");
8011 }).pipe(Effect.scoped),
8012);