1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { afterEach, assert, expect, it, vi } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Clock from "effect/Clock";
6import * as Deferred from "effect/Deferred";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10import * as TestClock from "effect/testing/TestClock";
11import * as Redacted from "effect/Redacted";
12
13import { HttpClient, HttpClientResponse } from "effect/http";
14
15import * as GitHubApi from "./GitHubApi.ts";
16import * as GitHubCredentials from "./GitHubCredentials.ts";
17import * as GitHubQuota from "./GitHubQuota.ts";
18import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
19import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
20import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
21import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
22import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
23
24const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
25
26const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
27 data: {
28 repository: {
29 mergeCommitAllowed: true,
30 squashMergeAllowed: false,
31 rebaseMergeAllowed: true,
32 viewerPermission: "WRITE",
33 pullRequest: {
34 number: 7,
35 title: "Pull request 7",
36 url: "https://github.com/acme/web/pull/7",
37 headRefName: "feature",
38 baseRefName: "main",
39 headRefOid: "abc123",
40 state: "OPEN",
41 createdAt: "2026-07-01T00:00:00Z",
42 updatedAt: "2026-07-02T00:00:00Z",
43 viewerCanUpdate: true,
44 viewerDidAuthor: false,
45 viewerCanUpdateBranch: true,
46 baseRef: { compare: { behindBy: 2 } },
47 reviewRequests: { nodes: [] },
48 labels: { nodes: [] },
49 commits: { nodes: [] },
50 ...pullRequest,
51 },
52 },
53 },
54});
55
56/** One request the CLI made, as the transport received it. */
57type ApiCall =
58 | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput)
59 | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput);
60
61type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>;
62
63const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>();
64const defaultCredential = (host: string) =>
65 Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` });
66const mockedCredential = vi.fn(defaultCredential);
67const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() =>
68 Effect.succeed(output('{"data":{}}')),
69);
70
71/**
72 * A transport that answers from the mocks above but spends the real GraphQL quota from each
73 * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove.
74 */
75const mockApi = Layer.effect(
76 GitHubApi.GitHubApi,
77 Effect.gen(function* () {
78 const quota = yield* GitHubQuota.GitHubQuota;
79 return GitHubApi.GitHubApi.of({
80 graphql: (input) =>
81 SourceControlRateLimit.Interactive.pipe(
82 Effect.flatMap((interactive) =>
83 quota.admit(input.host, "graphql", {
84 allowReserve: input.allowReserve ?? interactive,
85 }),
86 ),
87 Effect.andThen(() =>
88 (input.query.includes("query PullRequestStackMemberships")
89 ? mockedStackMemberships
90 : mockedExecute)({ kind: "graphql", ...input }),
91 ),
92 Effect.tap((answer) => quota.observe(input.host, answer.headers)),
93 Effect.map((answer) => answer.body),
94 ),
95 rest: (input) => mockedExecute({ kind: "rest", ...input }),
96 credential: (host) => mockedCredential(host),
97 });
98 }),
99);
100
101const layer = it.layer(
102 GitHubPullRequestApi.layer.pipe(
103 Layer.provideMerge(mockApi),
104 Layer.provideMerge(GitHubQuota.layer),
105 Layer.provide(NodeCrypto.layer),
106 Layer.provide(TestSourceControlHost.layer()),
107 Layer.provide(NodeServices.layer),
108 ),
109);
110
111/** A successful answer; GraphQL reads take its body. */
112function output(
113 body: string,
114 truncated = false,
115 invalidUtf8 = false,
116 headers: Readonly<Record<string, string>> = {},
117): GitHubApi.GitHubRestResponse {
118 return { status: 200, headers, body, truncated, invalidUtf8 };
119}
120
121/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */
122function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") {
123 return {
124 "x-ratelimit-resource": "graphql",
125 "x-ratelimit-limit": "5000",
126 "x-ratelimit-remaining": String(remaining),
127 "x-ratelimit-reset": String(Date.parse(resetAt) / 1000),
128 };
129}
130
131/**
132 * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most
133 * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state.
134 */
135function rows(
136 count: number,
137 firstNumber: number,
138 overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}),
139): ReadonlyArray<Record<string, unknown>> {
140 return Array.from({ length: count }, (_, index) => {
141 const number = firstNumber + index;
142 const { reviewRequests, checks, ...rest } = overrides(number) as {
143 reviewRequests?: ReadonlyArray<Record<string, string>>;
144 checks?: string;
145 } & Record<string, unknown>;
146 return {
147 number,
148 title: `Pull request ${number}`,
149 url: `https://github.com/acme/web/pull/${number}`,
150 headRefName: "feat/page",
151 baseRefName: "main",
152 createdAt: "2026-07-01T00:00:00Z",
153 updatedAt: "2026-07-02T00:00:00Z",
154 repository: { nameWithOwner: "acme/web" },
155 ...(reviewRequests === undefined
156 ? {}
157 : {
158 reviewRequests: {
159 nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })),
160 },
161 }),
162 ...(checks === undefined
163 ? {}
164 : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }),
165 ...rest,
166 };
167 });
168}
169
170/** A page of the searched listing. */
171function pullRequests(
172 count: number,
173 firstNumber: number,
174 overrides?: (number: number) => Readonly<Record<string, unknown>>,
175): string {
176 return encodeJson({
177 data: {
178 search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) },
179 },
180 });
181}
182
183/** A page of a repository's own list, which is what the search-free fallback reads. */
184function listedPullRequests(
185 count: number,
186 firstNumber: number,
187 overrides?: (number: number) => Readonly<Record<string, unknown>>,
188): string {
189 return encodeJson({
190 data: {
191 repository: {
192 pullRequests: {
193 pageInfo: { hasNextPage: false },
194 nodes: rows(count, firstNumber, overrides),
195 },
196 },
197 },
198 });
199}
200
201/** A search that found nothing, which is also what GitHub says for a repository it does not index. */
202const emptySearch = () => output(pullRequests(0, 1));
203const emptyList = () => output(listedPullRequests(0, 1));
204
205function pullRequestFiles(count: number, firstIndex: number): string {
206 return encodeJson(
207 Array.from({ length: count }, (_, index) => ({
208 filename: `src/file${firstIndex + index}.ts`,
209 status: "modified",
210 patch: "@@ -1 +1 @@\n-old\n+new",
211 })),
212 );
213}
214
215/** One thread's comments as the GraphQL read returns them, cursor and all. */
216function threadComments(
217 ids: ReadonlyArray<string>,
218 endCursor: string | null,
219 totalCount = ids.length,
220) {
221 return {
222 totalCount,
223 pageInfo: { hasNextPage: endCursor !== null, endCursor },
224 nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })),
225 };
226}
227
228function thread(id: string, ...commentIds: ReadonlyArray<string>) {
229 return {
230 id,
231 path: "src/a.ts",
232 line: 1,
233 diffSide: "RIGHT",
234 isResolved: false,
235 isOutdated: false,
236 comments: threadComments(commentIds, null),
237 };
238}
239
240function reviewThreadsPage(
241 nodes: ReadonlyArray<Record<string, unknown>>,
242 endCursor: string | null,
243): string {
244 return encodeJson({
245 data: {
246 repository: {
247 pullRequest: {
248 reviewThreads: {
249 totalCount: nodes.length,
250 pageInfo: { hasNextPage: endCursor !== null, endCursor },
251 nodes,
252 },
253 },
254 },
255 },
256 });
257}
258
259function threadCommentsPage(
260 ids: ReadonlyArray<string>,
261 endCursor: string | null,
262 totalCount: number,
263 pullRequestId = "PR_7",
264): string {
265 return encodeJson({
266 data: {
267 repository: { pullRequest: { id: "PR_7" } },
268 node: {
269 pullRequest: { id: pullRequestId },
270 comments: threadComments(ids, endCursor, totalCount),
271 },
272 },
273 });
274}
275
276/** What GitHub answers for a pull request it will not serve a whole diff for. */
277const diffRefused = new GitHubApi.GitHubApiResponseError({
278 host: "github.com",
279 operation: "getPullRequestDiff",
280 status: 406,
281});
282
283/** The nth request the CLI made. */
284function callAt(index: number): ApiCall {
285 const call = mockedExecute.mock.calls[index];
286 assert.isDefined(call);
287 return call[0];
288}
289
290/** The GraphQL variables of the nth request. */
291function varsAt(index: number): Readonly<Record<string, unknown>> {
292 const call = callAt(index);
293 assert.strictEqual(call.kind, "graphql");
294 return call.kind === "graphql" ? (call.variables ?? {}) : {};
295}
296
297/** The GraphQL document of the nth request. */
298function queryAt(index: number): string {
299 const call = callAt(index);
300 return call.kind === "graphql" ? call.query : "";
301}
302
303/** The REST path of the nth request. */
304function pathAt(index: number): string {
305 const call = callAt(index);
306 return call.kind === "rest" ? call.path : "";
307}
308
309/** The search the nth request carried, or undefined for a read that did not search. */
310function searchOfCall(index: number): string | undefined {
311 const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined;
312 return typeof q === "string" ? q : undefined;
313}
314
315/** One row as a search answers it, which is the listing's row one connection deeper. */
316function searchItem(number: number, repository: string, updatedAt: string) {
317 return {
318 number,
319 title: `Pull request ${number}`,
320 url: `https://github.com/${repository}/pull/${number}`,
321 author: { login: "octocat", avatarUrl: "https://avatars/octocat" },
322 headRefName: "feat/page",
323 baseRefName: "main",
324 state: "OPEN",
325 isDraft: false,
326 mergeable: "MERGEABLE",
327 createdAt: "2026-07-01T00:00:00Z",
328 updatedAt,
329 repository: { nameWithOwner: repository },
330 reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] },
331 labels: { nodes: [{ name: "bug", color: "ff0000" }] },
332 };
333}
334
335function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) {
336 return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } }));
337}
338
339const searchQueryOfCall = searchOfCall;
340
341/**
342 * Answers every request with the body of the first route whose needle its GraphQL document or
343 * REST path contains, and anything unrouted with an empty object, which a mutation never reads.
344 */
345function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void {
346 mockedExecute.mockImplementation((call) => {
347 const target = call.kind === "graphql" ? call.query : call.path;
348 const match = routes.find(([needle]) => target.includes(needle));
349 return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1])));
350 });
351}
352
353/** The variables of every GraphQL request whose document contains `needle`, in order. */
354function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> {
355 return mockedExecute.mock.calls.flatMap(([call]) =>
356 call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [],
357 );
358}
359
360/** Every REST request whose path contains `needle`, in order. */
361function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> {
362 return mockedExecute.mock.calls.flatMap(([call]) =>
363 call.kind === "rest" && call.path.includes(needle) ? [call] : [],
364 );
365}
366
367/**
368 * The check that a client-given node hangs off the pull request it names. Routed before the node
369 * id lookup, whose document it contains.
370 */
371const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)";
372const subjectScope = (subjectId: string, pullRequestId: string) => ({
373 data: {
374 repository: { pullRequest: { id: pullRequestId } },
375 node: { id: subjectId, pullRequest: { id: pullRequestId } },
376 },
377});
378
379/** One page of a head commit's check contexts, the read a rollup past one page is walked by. */
380const checkContextsPage = (
381 nodes: ReadonlyArray<Record<string, unknown>>,
382 endCursor: string | null,
383 headRefOid = "abc123",
384) =>
385 encodeJson({
386 data: {
387 repository: {
388 pullRequest: {
389 headRefOid,
390 commits: {
391 nodes: [
392 {
393 commit: {
394 statusCheckRollup: {
395 contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } },
396 },
397 },
398 },
399 ],
400 },
401 },
402 },
403 },
404 });
405
406/** A pull request's base and head, as the REST read a file expansion starts from answers them. */
407const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } });
408
409/** A cross-repository pull request whose head waits on a maintainer to run its workflows. */
410const crossRepositoryDetail = (headRefOid = "abc123") =>
411 coreResponse({
412 headRefName: "feat/page",
413 headRefOid,
414 isCrossRepository: true,
415 headRepositoryOwner: { login: "octocat" },
416 });
417
418/** Open pull requests on one head branch, every one of them from the same fork head. */
419const heads = (numbers: ReadonlyArray<number>) => ({
420 data: {
421 repository: {
422 pullRequests: {
423 pageInfo: { hasNextPage: false, endCursor: null },
424 nodes: numbers.map((number) => ({
425 number,
426 headRefOid: "abc123",
427 isCrossRepository: true,
428 headRepositoryOwner: { login: "octocat" },
429 })),
430 },
431 },
432 },
433});
434
435const workflowRuns = (ids: ReadonlyArray<number>) => ({
436 workflow_runs: ids.map((id) => ({
437 id,
438 name: id === 10 ? "build" : `run ${id}`,
439 html_url: `https://example.com/${id}`,
440 })),
441});
442
443/** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */
444function workflowApprovalRoutes(
445 detail: () => unknown,
446 headsAnswer: unknown,
447 runsAnswer: unknown,
448): void {
449 mockedExecute.mockImplementation((call) =>
450 Effect.sync(() =>
451 output(
452 call.kind === "rest"
453 ? call.path.endsWith("/approve")
454 ? ""
455 : encodeJson(runsAnswer)
456 : call.query.includes("headRefName: $head")
457 ? encodeJson(headsAnswer)
458 : encodeJson(detail()),
459 ),
460 ),
461 );
462}
463
464/** The pull request node id lookup, which the layer caches for every test after the first. */
465const NODE_ID_QUERY = "pullRequest(number: $number) { id }";
466const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } });
467
468afterEach(() => {
469 mockedExecute.mockReset();
470 mockedStackMemberships.mockReset();
471 mockedCredential.mockReset();
472 mockedCredential.mockImplementation(defaultCredential);
473});
474
475it.effect(
476 "keeps a verified credential through an auth switch and separates token fingerprints",
477 () =>
478 Effect.gen(function* () {
479 let activeToken = "broad-credential";
480 const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> =
481 [];
482 const credentials = Layer.succeed(
483 GitHubCredentials.GitHubCredentials,
484 GitHubCredentials.GitHubCredentials.of({
485 get: (host) =>
486 Effect.sync(() => ({
487 host,
488 token: Redacted.make(activeToken),
489 source: "gh" as const,
490 fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`,
491 })),
492 invalidate: () => Effect.void,
493 }),
494 );
495 const http = Layer.succeed(
496 HttpClient.HttpClient,
497 HttpClient.make((request) =>
498 Effect.sync(() => {
499 requests.push({ url: request.url, authorization: request.headers["authorization"] });
500 const body = request.url.endsWith("/user")
501 ? { id: 123, login: "same-account" }
502 : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } };
503 return HttpClientResponse.fromWeb(request, new Response(encodeJson(body)));
504 }),
505 ),
506 );
507 const cli = yield* GitHubPullRequestApi.make.pipe(
508 Effect.provide(
509 GitHubApi.layer.pipe(
510 Layer.provide(Layer.mergeAll(credentials, http)),
511 Layer.provide(GitHubQuota.layer),
512 Layer.provide(SourceControlRateLimit.layer),
513 Layer.merge(TestSourceControlHost.layer()),
514 Layer.provideMerge(NodeServices.layer),
515 ),
516 ),
517 );
518 const input = { cwd: "/repo", host: "github.com" };
519 const first = yield* cli.withVerifiedCredential(input, (identity) =>
520 Effect.gen(function* () {
521 activeToken = "restricted-credential";
522 expect(yield* cli.getViewerLogin(input)).toBe("same-account");
523 yield* cli.commentOnPullRequest({
524 ...input,
525 repository: "owner/repo",
526 number: 1,
527 body: "comment",
528 });
529 return identity;
530 }),
531 );
532 const second = yield* cli.withVerifiedCredential(input, Effect.succeed);
533 expect(first.accountId).toBe(second.accountId);
534 expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint);
535 expect(encodeJson([first, second])).not.toContain("broad-credential");
536 expect(encodeJson([first, second])).not.toContain("restricted-credential");
537 // The comment was written under the credential the page verified, not the switched one.
538 expect(
539 requests
540 .filter((request) => request.url.endsWith("/graphql"))
541 .map((request) => request.authorization),
542 ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]);
543 expect(
544 requests
545 .filter((request) => request.url.endsWith("/user"))
546 .map((request) => request.authorization),
547 ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]);
548 expect(yield* cli.getRoutingIdentity(input)).toEqual({
549 accountId: "123",
550 viewer: "same-account",
551 });
552 }),
553);
554
555layer("GitHubPullRequestApi.layer", (it) => {
556 it.effect("holds background previews once an answer reports the reserve, until it resets", () =>
557 Effect.gen(function* () {
558 const now = yield* Clock.currentTimeMillis;
559 const quota = yield* GitHubQuota.GitHubQuota;
560 const resetAt = "2099-08-13T14:00:00Z";
561 yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt));
562 // GitHub's count, not ours: the answer below reports the reserve has been reached.
563 mockedExecute.mockReturnValue(
564 Effect.succeed(
565 output(
566 encodeJson({
567 data: {
568 repository: {
569 pullRequest: {
570 number: 7,
571 title: "Fast previews",
572 url: "https://preview-budget.example/acme/web/pull/7",
573 state: "OPEN",
574 isDraft: false,
575 createdAt: "2026-07-01T00:00:00Z",
576 author: null,
577 },
578 },
579 },
580 }),
581 false,
582 false,
583 graphqlQuota(499, resetAt),
584 ),
585 ),
586 );
587 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
588 const input = {
589 cwd: "/w",
590 repository: "acme/web",
591 host: "preview-budget.example",
592 number: 7,
593 };
594 yield* cli.getPullRequestPreview(input);
595 const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 }));
596 expect(held._tag).toBe("SourceControlRateLimitPausedError");
597 expect(mockedExecute).toHaveBeenCalledTimes(1);
598 yield* TestClock.setTime(Date.parse(resetAt));
599 yield* cli.getPullRequestPreview(input);
600 expect(mockedExecute).toHaveBeenCalledTimes(2);
601 yield* TestClock.setTime(now);
602 }),
603 );
604
605 it.effect("loads the complete hover card with one GraphQL request", () =>
606 Effect.gen(function* () {
607 mockedExecute.mockReturnValueOnce(
608 Effect.succeed(
609 output(
610 encodeJson({
611 data: {
612 repository: {
613 pullRequest: {
614 number: 7,
615 title: "Fast previews",
616 url: "https://github.example/acme/web/pull/7",
617 state: "MERGED",
618 isDraft: false,
619 createdAt: "2026-07-01T00:00:00Z",
620 author: {
621 login: "octocat",
622 name: "Octo Cat",
623 avatarUrl: "https://github.example/avatar.png",
624 },
625 },
626 },
627 },
628 }),
629 ),
630 ),
631 );
632 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
633 const preview = yield* cli.getPullRequestPreview({
634 cwd: "/w",
635 repository: "acme/web",
636 host: "github.example",
637 number: 7,
638 });
639 expect(preview).toEqual({
640 number: 7,
641 title: "Fast previews",
642 url: "https://github.example/acme/web/pull/7",
643 state: "merged",
644 isDraft: false,
645 createdAt: "2026-07-01T00:00:00Z",
646 author: {
647 login: "octocat",
648 name: "Octo Cat",
649 avatarUrl: "https://github.example/avatar.png",
650 },
651 });
652 expect(mockedExecute).toHaveBeenCalledTimes(1);
653 expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" });
654 }),
655 );
656
657 it.effect("coalesces concurrent identity verification for the same host and credential", () =>
658 Effect.gen(function* () {
659 mockedExecute.mockImplementation(() =>
660 Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))),
661 );
662 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
663 const results = yield* Effect.all(
664 Array.from({ length: 4 }, () =>
665 cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }),
666 ),
667 { concurrency: 4 },
668 );
669 expect(results).toEqual(
670 Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })),
671 );
672 expect(mockedExecute).toHaveBeenCalledTimes(1);
673 expect(pathAt(0)).toBe("user");
674 }),
675 );
676
677 it.effect(
678 "lets another identity reader continue when the first verification is interrupted",
679 () =>
680 Effect.gen(function* () {
681 const firstStarted = yield* Deferred.make<void>();
682 let verifications = 0;
683 mockedExecute.mockImplementation(() =>
684 Effect.gen(function* () {
685 if (++verifications === 1) {
686 yield* Deferred.succeed(firstStarted, undefined);
687 return yield* Effect.never;
688 }
689 return output('{"id":123,"login":"viewer"}');
690 }),
691 );
692 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
693 const input = { cwd: "/w", host: "github.identity-cancel.test" };
694 const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
695 yield* Deferred.await(firstStarted);
696 // The second reader is queued on the same credential's lock behind the first.
697 const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild);
698 yield* Effect.yieldNow;
699 yield* Fiber.interrupt(first);
700 expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" });
701 expect(verifications).toBe(2);
702 }),
703 );
704
705 it.effect("reads linked pull requests on one host together, filed back by position", () =>
706 Effect.gen(function* () {
707 const node = (number: number) => ({
708 number,
709 title: `Pull request ${number}`,
710 url: `https://github.com/acme/web/pull/${number}`,
711 author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null },
712 baseRefName: "main",
713 headRefName: `feat/${number}`,
714 state: "OPEN",
715 isDraft: false,
716 mergeable: "MERGEABLE",
717 reviewDecision: null,
718 latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] },
719 additions: 12,
720 deletions: 3,
721 changedFiles: 2,
722 updatedAt: "2026-08-24T12:34:56.000Z",
723 mergedAt: null,
724 closedAt: null,
725 commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
726 });
727 mockedExecute.mockReturnValueOnce(
728 Effect.succeed(
729 output(
730 encodeJson({
731 data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } },
732 }),
733 ),
734 ),
735 );
736 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
737
738 const reads = yield* Effect.forEach(
739 [7, 8],
740 (number) =>
741 cli.getPullRequestSummary({
742 cwd: "/w",
743 repository: "acme/web",
744 host: "github.com",
745 number,
746 }),
747 { concurrency: "unbounded" },
748 ).pipe(Effect.forkChild);
749 yield* TestClock.adjust("10 millis");
750 const [seven, eight] = yield* Fiber.join(reads);
751
752 assert.deepStrictEqual(
753 {
754 number: seven?.number,
755 state: seven?.state,
756 headBranch: seven?.headBranch,
757 author: seven?.author?.login,
758 changedFiles: seven?.changedFiles,
759 reviewDecision: seven?.reviewDecision,
760 checksState: seven?.checksState,
761 mergeability: seven?.mergeability,
762 },
763 {
764 number: 7,
765 state: "open",
766 headBranch: "feat/7",
767 author: "octocat",
768 changedFiles: 2,
769 reviewDecision: "approved",
770 checksState: "passing",
771 mergeability: "mergeable",
772 },
773 );
774 assert.strictEqual(eight?.headBranch, "feat/8");
775 expect(mockedExecute).toHaveBeenCalledOnce();
776 expect(queryAt(0)).toContain(
777 "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)",
778 );
779 expect(varsAt(0)).toMatchObject({
780 s0_owner: "acme",
781 s0_name: "web",
782 s0_number: 7,
783 s1_number: 8,
784 });
785 }),
786 );
787
788 it.effect("fingerprints watched pull requests on one host in one read", () =>
789 Effect.gen(function* () {
790 const node = (comments: number) => ({
791 state: "OPEN",
792 mergeable: "MERGEABLE",
793 headRefOid: "abc123",
794 comments: { totalCount: comments, nodes: [] },
795 reviews: { totalCount: 0, nodes: [] },
796 reviewThreads: { totalCount: 0 },
797 commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
798 });
799 mockedExecute.mockReturnValueOnce(
800 Effect.succeed(
801 output(
802 JSON.stringify({
803 data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } },
804 }),
805 ),
806 ),
807 );
808 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
809
810 const reads = yield* Effect.forEach(
811 [7, 8],
812 (number) =>
813 cli.getPullRequestWatchFingerprint({
814 cwd: "/w",
815 repository: "acme/web",
816 host: "github.com",
817 number,
818 }),
819 { concurrency: "unbounded" },
820 ).pipe(Effect.forkChild);
821 yield* TestClock.adjust("10 millis");
822 const [seven, eight] = yield* Fiber.join(reads);
823
824 expect(seven?.remarks.startsWith("1 ")).toBe(true);
825 // GitHub had no answer for #8, so its watch reads it in full.
826 expect(eight).toBeNull();
827 expect(mockedExecute).toHaveBeenCalledOnce();
828 expect(queryAt(0)).toContain(
829 "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)",
830 );
831 expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 });
832 }),
833 );
834
835 it.effect("reads a pull request the batch said nothing about on its own", () =>
836 Effect.gen(function* () {
837 mockedExecute
838 .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}')))
839 .mockReturnValueOnce(
840 Effect.succeed(
841 output(
842 encodeJson({
843 data: {
844 s0: {
845 pullRequest: {
846 number: 7,
847 title: "Reuse the summary",
848 url: "https://github.com/acme/web/pull/7",
849 author: { login: "octocat", name: "Octo Cat" },
850 baseRefName: "main",
851 headRefName: "feat/summary",
852 state: "OPEN",
853 isDraft: false,
854 mergeable: "MERGEABLE",
855 reviewDecision: "APPROVED",
856 additions: 12,
857 deletions: 3,
858 changedFiles: 2,
859 updatedAt: "2026-08-24T12:34:56.000Z",
860 commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
861 },
862 },
863 },
864 }),
865 ),
866 ),
867 );
868 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
869
870 const read = yield* cli
871 .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 })
872 .pipe(Effect.forkChild);
873 yield* TestClock.adjust("10 millis");
874 const summary = yield* Fiber.join(read);
875
876 assert.strictEqual(summary.headBranch, "feat/summary");
877 assert.strictEqual(summary.checksState, "passing");
878 assert.strictEqual(mockedExecute.mock.calls.length, 2);
879 expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 });
880 }),
881 );
882
883 it.effect("reads the stack a pull request is in through the stacks preview, on its host", () =>
884 Effect.gen(function* () {
885 mockedExecute.mockReturnValueOnce(
886 Effect.succeed(
887 output(
888 encodeJson([
889 {
890 id: 42,
891 number: 3,
892 url: "https://api.github.com/repos/acme/web/stacks/3",
893 base: { ref: "main" },
894 pull_requests: [
895 {
896 number: 6,
897 head: { ref: "feat/one" },
898 state: "closed",
899 merged_at: "2026-09-02",
900 },
901 { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null },
902 ],
903 },
904 ]),
905 ),
906 ),
907 );
908 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
909
910 const stack = yield* cli.getPullRequestStack({
911 cwd: "/w",
912 repository: "acme/web",
913 host: "ghe.example.com",
914 number: 7,
915 });
916
917 assert.deepStrictEqual(stack, {
918 id: "42",
919 number: 3,
920 url: "https://api.github.com/repos/acme/web/stacks/3",
921 base: "main",
922 layers: [
923 { number: 6, headBranch: "feat/one", state: "merged" },
924 { number: 7, headBranch: "feat/two", state: "open" },
925 ],
926 });
927 expect(callAt(0)).toMatchObject({
928 kind: "rest",
929 host: "ghe.example.com",
930 path: "repos/acme/web/stacks?pull_request=7",
931 });
932 }),
933 );
934
935 it.effect("fetches layer titles only when the caller asks for stack details", () =>
936 Effect.gen(function* () {
937 const minimal = {
938 url: "https://api.github.com/repos/acme/web/stacks/3",
939 number: 3,
940 base: { ref: "main" },
941 pull_requests: [
942 { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null },
943 ],
944 };
945 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal]))));
946 mockedExecute.mockReturnValueOnce(
947 Effect.succeed(
948 output(
949 encodeJson({
950 ...minimal,
951 pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }],
952 }),
953 ),
954 ),
955 );
956 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
957 const stack = yield* cli.getPullRequestStack({
958 cwd: "/w",
959 repository: "acme/web",
960 host: "github.com",
961 number: 7,
962 includeDetails: true,
963 });
964 expect(stack?.layers[0]).toMatchObject({
965 title: "Second layer",
966 headSha: "abc123",
967 isDraft: false,
968 });
969 expect(pathAt(1)).toBe("repos/acme/web/stacks/3");
970 }),
971 );
972
973 it.effect("reads an empty stacks listing as not stacked", () =>
974 Effect.gen(function* () {
975 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
976 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
977
978 const stack = yield* cli.getPullRequestStack({
979 cwd: "/w",
980 repository: "acme/web",
981 host: "github.com",
982 number: 7,
983 });
984
985 assert.isNull(stack);
986 }),
987 );
988
989 it.effect("reads a host that refuses the stacks preview as not stacked", () =>
990 Effect.gen(function* () {
991 mockedExecute.mockReturnValueOnce(
992 Effect.fail(
993 new GitHubApi.GitHubApiNotFoundError({
994 host: "github.com",
995 operation: "getPullRequestStack",
996 }),
997 ),
998 );
999 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1000
1001 const stack = yield* cli.getPullRequestStack({
1002 cwd: "/w",
1003 repository: "acme/web",
1004 host: "github.com",
1005 number: 7,
1006 });
1007
1008 assert.isNull(stack);
1009 }),
1010 );
1011
1012 it.effect("does not read a refused credential as an unstacked pull request", () =>
1013 Effect.gen(function* () {
1014 mockedExecute.mockReturnValueOnce(
1015 Effect.fail(
1016 new GitHubApi.GitHubApiAuthenticationError({
1017 host: "github.com",
1018 operation: "getPullRequestStack",
1019 }),
1020 ),
1021 );
1022 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1023
1024 const error = yield* Effect.flip(
1025 cli.getPullRequestStack({
1026 cwd: "/w",
1027 repository: "acme/web",
1028 host: "github.com",
1029 number: 7,
1030 }),
1031 );
1032
1033 assert.strictEqual(error._tag, "GitHubApiAuthenticationError");
1034 }),
1035 );
1036
1037 it.effect("preserves transient stack failures instead of reporting no stack", () =>
1038 Effect.gen(function* () {
1039 const failure = new GitHubApi.GitHubApiResponseError({
1040 host: "github.com",
1041 operation: "getPullRequestStack",
1042 status: 503,
1043 });
1044 mockedExecute.mockReturnValueOnce(Effect.fail(failure));
1045 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1046 const error = yield* Effect.flip(
1047 cli.getPullRequestStack({
1048 cwd: "/w",
1049 repository: "acme/web",
1050 host: "github.com",
1051 number: 7,
1052 }),
1053 );
1054 assert.strictEqual(error, failure);
1055 }),
1056 );
1057
1058 it.effect("reports a stacks answer it cannot read against the stack read", () =>
1059 Effect.gen(function* () {
1060 mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]')));
1061 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1062
1063 const error = yield* Effect.flip(
1064 cli.getPullRequestStack({
1065 cwd: "/w",
1066 repository: "acme/web",
1067 host: "github.com",
1068 number: 7,
1069 }),
1070 );
1071
1072 assert.strictEqual(error._tag, "GitHubPullRequestReadError");
1073 if (error._tag !== "GitHubPullRequestReadError") return;
1074 assert.strictEqual(error.operation, "getPullRequestStack");
1075 }),
1076 );
1077
1078 it.effect("asks for one row more than the page, to probe for a next page", () =>
1079 Effect.gen(function* () {
1080 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
1081 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1082
1083 const batch = yield* cli.listPullRequests({
1084 cwd: "/w",
1085 repository: "acme/web",
1086 host: "github.com",
1087 state: "open",
1088 involvement: "all",
1089 viewer: "bilal",
1090 limit: 10,
1091 });
1092
1093 assert.strictEqual(batch.items.length, 3);
1094 assert.isFalse(batch.truncated);
1095 expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" });
1096 expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web");
1097 expect(queryAt(0)).toContain("first: 11");
1098 }),
1099 );
1100
1101 it.effect("reports truncation from the extra row, counted before decoding", () =>
1102 Effect.gen(function* () {
1103 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1))));
1104 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1105
1106 const batch = yield* cli.listPullRequests({
1107 cwd: "/w",
1108 repository: "acme/web",
1109 host: "github.com",
1110 state: "open",
1111 involvement: "all",
1112 viewer: "bilal",
1113 limit: 10,
1114 });
1115
1116 assert.strictEqual(batch.items.length, 10);
1117 assert.isTrue(batch.truncated);
1118 }),
1119 );
1120
1121 it.effect("reports truncation when GitHub repeats a cursor before the page is full", () =>
1122 Effect.gen(function* () {
1123 // Two pages of 100 that hand back the same cursor, for a page of 250.
1124 const repeating = output(
1125 encodeJson({
1126 data: {
1127 search: {
1128 pageInfo: { hasNextPage: true, endCursor: "same" },
1129 nodes: rows(100, 1),
1130 },
1131 },
1132 }),
1133 );
1134 mockedExecute.mockReturnValue(Effect.succeed(repeating));
1135 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1136
1137 const batch = yield* cli.listPullRequests({
1138 cwd: "/w",
1139 repository: "acme/web",
1140 host: "github.com",
1141 state: "open",
1142 involvement: "all",
1143 viewer: "bilal",
1144 limit: 250,
1145 });
1146
1147 assert.strictEqual(mockedExecute.mock.calls.length, 2);
1148 assert.isTrue(batch.truncated);
1149 }),
1150 );
1151
1152 it.effect("excludes merged pull requests from the Closed tab", () =>
1153 Effect.gen(function* () {
1154 mockedExecute.mockImplementation((call) =>
1155 Effect.succeed(
1156 call.kind === "graphql" && call.query.includes("pullRequests(")
1157 ? emptyList()
1158 : emptySearch(),
1159 ),
1160 );
1161 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1162
1163 yield* cli.listPullRequests({
1164 cwd: "/w",
1165 repository: "acme/web",
1166 host: "github.com",
1167 state: "closed",
1168 involvement: "all",
1169 viewer: "bilal",
1170 limit: 10,
1171 });
1172
1173 // A closed pull request may also be a merged one, so the tab narrows through search.
1174 expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web");
1175 }),
1176 );
1177
1178 it.effect("narrows to the author on the authored tab", () =>
1179 Effect.gen(function* () {
1180 mockedExecute.mockImplementation((call) =>
1181 Effect.succeed(
1182 call.kind === "graphql" && call.query.includes("pullRequests(")
1183 ? emptyList()
1184 : emptySearch(),
1185 ),
1186 );
1187 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1188
1189 yield* cli.listPullRequests({
1190 cwd: "/w",
1191 repository: "acme/web",
1192 host: "github.com",
1193 state: "open",
1194 involvement: "authored",
1195 viewer: "bilal",
1196 limit: 10,
1197 });
1198
1199 expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web");
1200 }),
1201 );
1202
1203 it.effect("narrows through search on the reviewing tab", () =>
1204 Effect.gen(function* () {
1205 mockedExecute.mockImplementation((call) =>
1206 Effect.succeed(
1207 call.kind === "graphql" && call.query.includes("pullRequests(")
1208 ? emptyList()
1209 : emptySearch(),
1210 ),
1211 );
1212 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1213
1214 yield* cli.listPullRequests({
1215 cwd: "/w",
1216 repository: "acme/web",
1217 host: "github.com",
1218 state: "open",
1219 involvement: "reviewing",
1220 viewer: "bilal",
1221 limit: 10,
1222 });
1223
1224 expect(searchOfCall(0)).toBe(
1225 "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web",
1226 );
1227 }),
1228 );
1229
1230 it.effect("carries every repository and every qualifier into one search", () =>
1231 Effect.gen(function* () {
1232 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));
1233 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1234
1235 yield* cli.searchPullRequests({
1236 cwd: "/w",
1237 host: "github.com",
1238 repositories: ["acme/web", "pingdotgg/t3code"],
1239 state: "closed",
1240 involvement: "reviewing",
1241 viewer: "bilal",
1242 limit: 10,
1243 query: "pull requests page",
1244 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 },
1245 });
1246
1247 // One request for both repositories, carrying everything the per-repository read expresses
1248 // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the
1249 // order the page reads in.
1250 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1251 assert.strictEqual(
1252 searchQueryOfCall(0),
1253 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' +
1254 "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code",
1255 );
1256 }),
1257 );
1258
1259 it.effect("narrows a search to the author, and to merged on the merged tab", () =>
1260 Effect.gen(function* () {
1261 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));
1262 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1263
1264 yield* cli.searchPullRequests({
1265 cwd: "/w",
1266 host: "github.com",
1267 repositories: ["acme/web"],
1268 state: "merged",
1269 involvement: "authored",
1270 viewer: "bilal",
1271 limit: 10,
1272 });
1273
1274 assert.strictEqual(
1275 searchQueryOfCall(0),
1276 "is:pr is:merged author:bilal sort:updated-desc repo:acme/web",
1277 );
1278 }),
1279 );
1280
1281 it.effect("keeps a searched-for qualifier inside the phrase", () =>
1282 Effect.gen(function* () {
1283 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));
1284 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1285
1286 yield* cli.searchPullRequests({
1287 cwd: "/w",
1288 host: "github.com",
1289 repositories: ["acme/web"],
1290 state: "open",
1291 involvement: "all",
1292 viewer: "bilal",
1293 limit: 10,
1294 query: 'x" is:merged repo:evil/repo',
1295 });
1296
1297 // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it.
1298 assert.strictEqual(
1299 searchQueryOfCall(0),
1300 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web',
1301 );
1302 }),
1303 );
1304
1305 it.effect("refuses to search for a repository GitHub cannot address", () =>
1306 Effect.gen(function* () {
1307 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1308
1309 const failure = yield* Effect.flip(
1310 cli.searchPullRequests({
1311 cwd: "/w",
1312 host: "github.com",
1313 repositories: ["acme/web", "acme/web is:merged"],
1314 state: "open",
1315 involvement: "all",
1316 viewer: "bilal",
1317 limit: 10,
1318 }),
1319 );
1320
1321 // Nothing is sent: a name that could end its own qualifier is refused rather than escaped.
1322 assert.strictEqual(failure._tag, "GitHubRepositorySelectorError");
1323 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1324 }),
1325 );
1326
1327 it.effect("files each searched row under the repository it came from", () =>
1328 Effect.gen(function* () {
1329 mockedExecute.mockReturnValue(
1330 Effect.succeed(
1331 searchPage([
1332 searchItem(7, "acme/web", "2026-07-03T00:00:00Z"),
1333 searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"),
1334 // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on.
1335 {},
1336 ]),
1337 ),
1338 );
1339 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1340
1341 const batch = yield* cli.searchPullRequests({
1342 cwd: "/w",
1343 host: "github.com",
1344 repositories: ["acme/web", "pingdotgg/t3code"],
1345 state: "open",
1346 involvement: "all",
1347 viewer: "bilal",
1348 limit: 10,
1349 });
1350
1351 assert.deepStrictEqual(
1352 batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]),
1353 [
1354 ["acme/web", 7, "https://avatars/octocat"],
1355 ["pingdotgg/t3code", 9, "https://avatars/octocat"],
1356 ],
1357 );
1358 // The listing leaves the line counts to a read of their own.
1359 assert.deepStrictEqual(
1360 batch.items.map((item) => [item.additions, item.deletions]),
1361 [
1362 [0, 0],
1363 [0, 0],
1364 ],
1365 );
1366 assert.isFalse(batch.truncated);
1367 }),
1368 );
1369
1370 it.effect("reports truncation from the extra row, and from a page GitHub says has more", () =>
1371 Effect.gen(function* () {
1372 mockedExecute
1373 .mockReturnValueOnce(
1374 Effect.succeed(
1375 searchPage([
1376 searchItem(1, "acme/web", "2026-07-03T00:00:00Z"),
1377 searchItem(2, "acme/web", "2026-07-02T00:00:00Z"),
1378 searchItem(3, "acme/web", "2026-07-01T00:00:00Z"),
1379 ]),
1380 ),
1381 )
1382 .mockReturnValueOnce(
1383 Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)),
1384 );
1385 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1386 const read = () =>
1387 cli.searchPullRequests({
1388 cwd: "/w",
1389 host: "github.com",
1390 repositories: ["acme/web"],
1391 state: "open",
1392 involvement: "all",
1393 viewer: "bilal",
1394 limit: 2,
1395 });
1396
1397 const overflowing = yield* read();
1398 const capped = yield* read();
1399
1400 // The extra row is the probe, and it is not handed on.
1401 assert.strictEqual(overflowing.items.length, 2);
1402 assert.isTrue(overflowing.truncated);
1403 // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers.
1404 assert.isTrue(capped.truncated);
1405 }),
1406 );
1407
1408 it.effect("enriches only the visible fallback rows after filtering and widening", () =>
1409 Effect.gen(function* () {
1410 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1411 mockedExecute.mockReturnValueOnce(
1412 Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))),
1413 );
1414 mockedExecute.mockReturnValueOnce(
1415 Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))),
1416 );
1417 mockedStackMemberships.mockReturnValueOnce(
1418 Effect.succeed(
1419 output(
1420 encodeJson({
1421 data: {
1422 s0: {
1423 pullRequest: {
1424 stack: { number: 3, size: 2, baseRefName: "main" },
1425 stackEntry: { position: 1 },
1426 },
1427 },
1428 s1: { pullRequest: { stack: null, stackEntry: null } },
1429 },
1430 }),
1431 ),
1432 ),
1433 );
1434 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1435 const batch = yield* cli.listPullRequests({
1436 cwd: "/w",
1437 repository: "acme/web",
1438 host: "github.com",
1439 state: "open",
1440 involvement: "all",
1441 viewer: "bilal",
1442 limit: 2,
1443 filters: { draft: "hide" },
1444 });
1445 expect(batch.items.map((item) => item.number)).toEqual([4, 5]);
1446 expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 });
1447 expect(batch.items[1]?.stack).toBeUndefined();
1448 expect(batch.truncated).toBe(true);
1449 expect(batch.continues).toBe(false);
1450 expect(mockedStackMemberships).toHaveBeenCalledTimes(1);
1451 const membership = mockedStackMemberships.mock.calls[0]?.[0];
1452 const numbers = Object.entries(
1453 membership?.kind === "graphql" ? (membership.variables ?? {}) : {},
1454 )
1455 .filter(([name]) => name.endsWith("_number"))
1456 .map(([, number]) => number);
1457 expect(numbers).toEqual([4, 5]);
1458 }),
1459 );
1460
1461 it.effect("batches membership reads and keeps successful rows when one chunk fails", () =>
1462 Effect.gen(function* () {
1463 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1))));
1464 mockedStackMemberships.mockImplementation((input) =>
1465 input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26)
1466 ? Effect.fail(
1467 new GitHubApi.GitHubApiResponseError({
1468 host: "github.com",
1469 operation: "listPullRequestStackMemberships",
1470 status: 502,
1471 }),
1472 )
1473 : Effect.succeed(
1474 output(
1475 encodeJson({
1476 data: {
1477 s0: {
1478 pullRequest: {
1479 stack: { number: 3, size: 2, baseRefName: "main" },
1480 stackEntry: { position: 1 },
1481 },
1482 },
1483 },
1484 }),
1485 ),
1486 ),
1487 );
1488 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1489 const batch = yield* cli.listPullRequests({
1490 cwd: "/w",
1491 repository: "acme/web",
1492 host: "github.com",
1493 state: "open",
1494 involvement: "all",
1495 viewer: "bilal",
1496 limit: 26,
1497 });
1498 expect(batch.items.map((item) => item.number)).toEqual(
1499 Array.from({ length: 26 }, (_, i) => i + 1),
1500 );
1501 expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 });
1502 expect(batch.items[25]?.stack).toBeUndefined();
1503 expect(batch.truncated).toBe(true);
1504 expect(batch.continues).toBe(true);
1505 expect(mockedStackMemberships).toHaveBeenCalledTimes(2);
1506 }),
1507 );
1508
1509 it.effect("skips membership enrichment for empty pages and enterprise hosts", () =>
1510 Effect.gen(function* () {
1511 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1512 mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList()));
1513 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7))));
1514 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1515 const input = {
1516 cwd: "/w",
1517 repository: "acme/web",
1518 state: "open" as const,
1519 involvement: "all" as const,
1520 viewer: "bilal",
1521 limit: 2,
1522 };
1523 const empty = yield* cli.listPullRequests({ ...input, host: "github.com" });
1524 const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" });
1525 expect(empty.items).toEqual([]);
1526 expect(enterprise.items.map((item) => item.number)).toEqual([7]);
1527 expect(mockedStackMemberships).not.toHaveBeenCalled();
1528 }),
1529 );
1530
1531 it.effect("reads the line counts in chunks, and files them back by position", () =>
1532 Effect.gen(function* () {
1533 const changeRequests = Array.from({ length: 26 }, (_, index) => ({
1534 repository: "acme/web",
1535 number: index + 1,
1536 }));
1537 mockedExecute.mockImplementation(() =>
1538 // Every chunk answers for its first alias only, so a row GitHub said nothing about is
1539 // dropped rather than shown as a change of no size.
1540 Effect.succeed(
1541 output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })),
1542 ),
1543 );
1544 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1545
1546 const stats = yield* cli.listPullRequestStats({
1547 cwd: "/w",
1548 host: "github.com",
1549 changeRequests,
1550 });
1551
1552 // Twenty-five aliases a request, so twenty-six rows are two requests.
1553 assert.strictEqual(mockedExecute.mock.calls.length, 2);
1554 assert.deepStrictEqual(stats, [
1555 { repository: "acme/web", number: 1, additions: 4, deletions: 1 },
1556 { repository: "acme/web", number: 26, additions: 4, deletions: 1 },
1557 ]);
1558 expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)");
1559 expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 });
1560 }),
1561 );
1562
1563 it.effect("refuses to look up counts for a repository GitHub cannot address", () =>
1564 Effect.gen(function* () {
1565 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1566
1567 const failure = yield* Effect.flip(
1568 cli.listPullRequestStats({
1569 cwd: "/w",
1570 host: "github.com",
1571 changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }],
1572 }),
1573 );
1574
1575 assert.strictEqual(failure._tag, "GitHubRepositorySelectorError");
1576 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1577 }),
1578 );
1579
1580 it.effect("hands a search to GitHub rather than to the rows already read", () =>
1581 Effect.gen(function* () {
1582 mockedExecute.mockImplementation((call) =>
1583 Effect.succeed(
1584 call.kind === "graphql" && call.query.includes("pullRequests(")
1585 ? emptyList()
1586 : emptySearch(),
1587 ),
1588 );
1589 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1590
1591 yield* cli.listPullRequests({
1592 cwd: "/w",
1593 repository: "acme/web",
1594 host: "github.com",
1595 state: "open",
1596 involvement: "all",
1597 viewer: "bilal",
1598 limit: 10,
1599 query: "pull requests page",
1600 });
1601
1602 // The recency qualifier rides along, because free text would otherwise reorder the page
1603 // by relevance and truncation would drop the newest matches.
1604 expect(searchOfCall(0)).toBe(
1605 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web',
1606 );
1607 }),
1608 );
1609
1610 it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () =>
1611 Effect.gen(function* () {
1612 mockedExecute.mockImplementation((call) =>
1613 Effect.succeed(
1614 call.kind === "graphql" && call.query.includes("pullRequests(")
1615 ? emptyList()
1616 : emptySearch(),
1617 ),
1618 );
1619 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1620
1621 yield* cli.listPullRequests({
1622 cwd: "/w",
1623 repository: "acme/web",
1624 host: "github.com",
1625 state: "closed",
1626 involvement: "reviewing",
1627 viewer: "bilal",
1628 limit: 10,
1629 query: "page",
1630 });
1631
1632 expect(searchOfCall(0)).toBe(
1633 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web',
1634 );
1635 }),
1636 );
1637
1638 it.effect("carries the further narrowings into the search as qualifiers", () =>
1639 Effect.gen(function* () {
1640 mockedExecute.mockImplementation((call) =>
1641 Effect.succeed(
1642 call.kind === "graphql" && call.query.includes("pullRequests(")
1643 ? emptyList()
1644 : emptySearch(),
1645 ),
1646 );
1647 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1648
1649 yield* cli.listPullRequests({
1650 cwd: "/w",
1651 repository: "acme/web",
1652 host: "github.com",
1653 state: "open",
1654 involvement: "all",
1655 viewer: "bilal",
1656 limit: 10,
1657 filters: {
1658 draft: "hide",
1659 review: "changes-requested",
1660 checks: "failing",
1661 labels: [["needs design"], ['quo"te']],
1662 excludedLabels: ["wip"],
1663 author: "octocat",
1664 },
1665 });
1666
1667 // Quotes around anything a reader typed, and the one character that could end a quoted
1668 // value early dropped rather than escaped.
1669 expect(searchOfCall(0)).toBe(
1670 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' +
1671 "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web",
1672 );
1673 }),
1674 );
1675
1676 it.effect('resolves an author filter of "me" to the viewer, not the literal word', () =>
1677 Effect.gen(function* () {
1678 mockedExecute.mockImplementation((call) =>
1679 Effect.succeed(
1680 call.kind === "graphql" && call.query.includes("pullRequests(")
1681 ? emptyList()
1682 : emptySearch(),
1683 ),
1684 );
1685 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1686
1687 yield* cli.listPullRequests({
1688 cwd: "/w",
1689 repository: "acme/web",
1690 host: "github.com",
1691 state: "open",
1692 involvement: "all",
1693 viewer: "bilal",
1694 limit: 10,
1695 filters: { author: "me" },
1696 });
1697
1698 expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web');
1699 }),
1700 );
1701
1702 it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () =>
1703 Effect.gen(function* () {
1704 mockedExecute.mockImplementation((call) =>
1705 Effect.succeed(
1706 call.kind === "graphql" && call.query.includes("pullRequests(")
1707 ? emptyList()
1708 : emptySearch(),
1709 ),
1710 );
1711 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1712
1713 yield* cli.listPullRequests({
1714 cwd: "/w",
1715 repository: "acme/web",
1716 host: "github.com",
1717 state: "open",
1718 involvement: "all",
1719 viewer: "bilal",
1720 limit: 10,
1721 filters: { labels: [["size:S", "size:XS"], ["bug"]] },
1722 });
1723
1724 // One qualifier satisfied by either size, and a second one that must hold as well.
1725 expect(searchOfCall(0)).toBe(
1726 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web',
1727 );
1728 }),
1729 );
1730
1731 it.effect(
1732 "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows",
1733 () =>
1734 Effect.gen(function* () {
1735 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1736 mockedExecute.mockReturnValueOnce(
1737 Effect.succeed(
1738 output(
1739 listedPullRequests(2, 1, (number) => ({
1740 checks: number === 1 ? "SUCCESS" : "FAILURE",
1741 })),
1742 ),
1743 ),
1744 );
1745 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1746
1747 const batch = yield* cli.listPullRequests({
1748 cwd: "/w",
1749 repository: "acme/web",
1750 host: "github.com",
1751 state: "open",
1752 involvement: "all",
1753 viewer: "bilal",
1754 limit: 10,
1755 filters: { checks: "passing" },
1756 });
1757
1758 // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is
1759 // now a filter the fallback judges itself, the same as `draft`: an empty search answer
1760 // under it is still ambiguous, and the row picked out afterwards is the one whose own
1761 // `checksState` reads "passing".
1762 expect(searchOfCall(1)).toBeUndefined();
1763 assert.deepStrictEqual(
1764 batch.items.map((item) => item.number),
1765 [1],
1766 );
1767 }),
1768 );
1769
1770 it.effect("fails a checks filter for a row whose checks are still pending", () =>
1771 Effect.gen(function* () {
1772 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1773 mockedExecute.mockReturnValueOnce(
1774 Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))),
1775 );
1776 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1777
1778 const batch = yield* cli.listPullRequests({
1779 cwd: "/w",
1780 repository: "acme/web",
1781 host: "github.com",
1782 state: "open",
1783 involvement: "all",
1784 viewer: "bilal",
1785 limit: 10,
1786 filters: { checks: "passing" },
1787 });
1788
1789 // Pending equals neither "passing" nor "failing", so it satisfies neither filter value —
1790 // the same row would also be dropped by `checks: "failing"`.
1791 assert.deepStrictEqual(batch.items, []);
1792 }),
1793 );
1794
1795 it.effect(
1796 "falls back for a repository the index does not cover even under a judgeable filter",
1797 () =>
1798 Effect.gen(function* () {
1799 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1800 mockedExecute.mockReturnValueOnce(
1801 Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))),
1802 );
1803 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1804
1805 const batch = yield* cli.listPullRequests({
1806 cwd: "/w",
1807 repository: "acme/web",
1808 host: "github.com",
1809 state: "open",
1810 involvement: "all",
1811 viewer: "bilal",
1812 limit: 10,
1813 filters: { draft: "hide" },
1814 });
1815
1816 // `draft` is a filter the fallback can judge over its own rows just as search judges it,
1817 // so an empty search answer under it alone is still ambiguous between "nothing matches"
1818 // and "this repository is not indexed" — and the fallback applies the filter itself,
1819 // keeping only the non-draft row.
1820 expect(searchOfCall(1)).toBeUndefined();
1821 expect(batch.items.map((item) => item.number)).toEqual([2]);
1822 }),
1823 );
1824
1825 it.effect("carries the further narrowings into a batched search", () =>
1826 Effect.gen(function* () {
1827 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));
1828 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1829
1830 yield* cli.searchPullRequests({
1831 cwd: "/w",
1832 host: "github.com",
1833 repositories: ["acme/web"],
1834 state: "open",
1835 involvement: "all",
1836 viewer: "bilal",
1837 limit: 10,
1838 filters: { draft: "only", review: "none", labels: [["bug"]] },
1839 });
1840
1841 assert.strictEqual(
1842 searchQueryOfCall(0),
1843 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web',
1844 );
1845 }),
1846 );
1847
1848 it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () =>
1849 Effect.gen(function* () {
1850 mockedExecute.mockImplementation((call) =>
1851 Effect.succeed(
1852 call.kind === "graphql" && call.query.includes("pullRequests(")
1853 ? emptyList()
1854 : emptySearch(),
1855 ),
1856 );
1857 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1858
1859 yield* cli.listPullRequests({
1860 cwd: "/w",
1861 repository: "acme/web",
1862 host: "github.com",
1863 state: "open",
1864 involvement: "all",
1865 viewer: "bilal",
1866 limit: 10,
1867 query: '-- is:merged label:secret "widen me"',
1868 });
1869
1870 // Every word stays inside one phrase: nothing before it, nothing after it, and the
1871 // leading dashes are text rather than the start of another argument.
1872 expect(searchOfCall(0)).toBe(
1873 String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`,
1874 );
1875 }),
1876 );
1877
1878 it.effect("escapes a backslash before the quote it would otherwise let out", () =>
1879 Effect.gen(function* () {
1880 mockedExecute.mockImplementation((call) =>
1881 Effect.succeed(
1882 call.kind === "graphql" && call.query.includes("pullRequests(")
1883 ? emptyList()
1884 : emptySearch(),
1885 ),
1886 );
1887 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1888
1889 yield* cli.listPullRequests({
1890 cwd: "/w",
1891 repository: "acme/web",
1892 host: "github.com",
1893 state: "open",
1894 involvement: "all",
1895 viewer: "bilal",
1896 limit: 10,
1897 query: String.raw`a\" is:merged`,
1898 });
1899
1900 // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where
1901 // this says it does; escaping the quote alone would have closed it early.
1902 expect(searchOfCall(0)).toBe(
1903 String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`,
1904 );
1905 }),
1906 );
1907
1908 it.effect("asks for nothing but the order when the reader typed only spaces", () =>
1909 Effect.gen(function* () {
1910 mockedExecute.mockImplementation((call) =>
1911 Effect.succeed(
1912 call.kind === "graphql" && call.query.includes("pullRequests(")
1913 ? emptyList()
1914 : emptySearch(),
1915 ),
1916 );
1917 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1918
1919 yield* cli.listPullRequests({
1920 cwd: "/w",
1921 repository: "acme/web",
1922 host: "github.com",
1923 state: "open",
1924 involvement: "all",
1925 viewer: "bilal",
1926 limit: 10,
1927 query: " ",
1928 });
1929
1930 // An empty phrase would match nothing rather than everything, so it is left out; the
1931 // order the page reads rows in is asked for whether or not anything was typed.
1932 expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web");
1933 }),
1934 );
1935
1936 it.effect("carries on from the instant the last slice ended on", () =>
1937 Effect.gen(function* () {
1938 mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1))));
1939 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1940
1941 const batch = yield* cli.listPullRequests({
1942 cwd: "/w",
1943 repository: "acme/web",
1944 host: "github.com",
1945 state: "open",
1946 involvement: "all",
1947 viewer: "bilal",
1948 limit: 10,
1949 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 },
1950 });
1951
1952 // Inclusive, so the rows already sent at that instant come back for the caller to drop —
1953 // which is what keeps the ones beside them from being skipped.
1954 expect(searchOfCall(0)).toBe(
1955 "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web",
1956 );
1957 assert.isTrue(batch.continues);
1958 }),
1959 );
1960
1961 it.effect("answers a search that found nothing with nothing, not with the whole repository", () =>
1962 Effect.gen(function* () {
1963 // The fallback is for a repository the index does not cover. Under a text search an empty
1964 // answer means the text matched nothing, and listing everything instead would fill the
1965 // page with rows the reader did not search for.
1966 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1967 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1968
1969 const batch = yield* cli.listPullRequests({
1970 cwd: "/w",
1971 repository: "acme/web",
1972 host: "github.com",
1973 state: "open",
1974 involvement: "all",
1975 viewer: "bilal",
1976 limit: 10,
1977 query: "fdsfklj",
1978 });
1979
1980 assert.strictEqual(batch.items.length, 0);
1981 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1982 }),
1983 );
1984
1985 it.effect("reads a repository GitHub will not search from its own list", () =>
1986 Effect.gen(function* () {
1987 // GitHub answers for a repository outside its search index with no rows and no error.
1988 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
1989 mockedExecute.mockReturnValueOnce(
1990 Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))),
1991 );
1992 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
1993
1994 const batch = yield* cli.listPullRequests({
1995 cwd: "/w",
1996 repository: "acme/web",
1997 host: "github.com",
1998 state: "closed",
1999 involvement: "all",
2000 viewer: "bilal",
2001 limit: 10,
2002 });
2003
2004 assert.strictEqual(batch.items.length, 3);
2005 // The fallback itself uses no search, then narrows the decoded rows locally. They arrive
2006 // newest-created first, so nothing can carry on from them.
2007 expect(searchOfCall(1)).toBeUndefined();
2008 expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] });
2009 assert.isFalse(batch.continues);
2010 }),
2011 );
2012
2013 it.effect("keeps state and involvement filters on the search-free fallback", () =>
2014 Effect.gen(function* () {
2015 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
2016 mockedExecute.mockReturnValueOnce(
2017 Effect.succeed(
2018 output(
2019 listedPullRequests(4, 1, (number) => ({
2020 state: number === 4 ? "OPEN" : "CLOSED",
2021 ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}),
2022 reviewRequests:
2023 number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }],
2024 })),
2025 ),
2026 ),
2027 );
2028 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2029
2030 const batch = yield* cli.listPullRequests({
2031 cwd: "/w",
2032 repository: "acme/web",
2033 host: "github.com",
2034 state: "closed",
2035 involvement: "reviewing",
2036 viewer: "bilal",
2037 limit: 10,
2038 });
2039
2040 // Individual requests for this viewer and team requests survive. The fallback cannot
2041 // resolve team membership, so dropping team-routed reviews would hide legitimate work.
2042 expect(batch.items.map((item) => item.number)).toEqual([1, 2]);
2043 expect(searchOfCall(1)).toBeUndefined();
2044 assert.isFalse(batch.continues);
2045 }),
2046 );
2047
2048 it.effect("grows the search-free fallback until it fills the filtered page", () =>
2049 Effect.gen(function* () {
2050 const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] });
2051 mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch()));
2052 mockedExecute.mockReturnValueOnce(
2053 Effect.succeed(output(listedPullRequests(3, 1, unrelated))),
2054 );
2055 mockedExecute.mockReturnValueOnce(
2056 Effect.succeed(
2057 output(
2058 listedPullRequests(4, 1, (number) =>
2059 number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(),
2060 ),
2061 ),
2062 ),
2063 );
2064 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2065
2066 const batch = yield* cli.listPullRequests({
2067 cwd: "/w",
2068 repository: "acme/web",
2069 host: "github.com",
2070 state: "open",
2071 involvement: "reviewing",
2072 viewer: "bilal",
2073 limit: 2,
2074 });
2075
2076 expect(batch.items.map((item) => item.number)).toEqual([4]);
2077 expect(queryAt(1)).toContain("first: 3,");
2078 expect(queryAt(2)).toContain("first: 6,");
2079 assert.isFalse(batch.truncated);
2080 }),
2081 );
2082
2083 it.effect("bounds a sparse search-free fallback and reports the unread tail", () =>
2084 Effect.gen(function* () {
2085 // A repository with far more pull requests than the bound, none of them for this reader.
2086 let listed = 0;
2087 mockedExecute.mockImplementation((call) => {
2088 if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) {
2089 return Effect.succeed(emptySearch());
2090 }
2091 const first = Number(/first: (\d+)/.exec(call.query)?.[1]);
2092 const nodes = rows(first, listed + 1, () => ({
2093 reviewRequests: [{ login: "somebody-else" }],
2094 }));
2095 listed += first;
2096 return Effect.succeed(
2097 output(
2098 encodeJson({
2099 data: {
2100 repository: {
2101 pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes },
2102 },
2103 },
2104 }),
2105 ),
2106 );
2107 });
2108 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2109
2110 const batch = yield* cli.listPullRequests({
2111 cwd: "/w",
2112 repository: "acme/web",
2113 host: "github.com",
2114 state: "open",
2115 involvement: "reviewing",
2116 viewer: "bilal",
2117 limit: 2,
2118 });
2119
2120 // The last widening reads a thousand rows, a page of a hundred at a time, and stops there.
2121 const last = mockedExecute.mock.calls.length - 1;
2122 expect(varsAt(last)["after"]).toBe(`c${listed - 100}`);
2123 expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000);
2124 assert.strictEqual(batch.items.length, 0);
2125 assert.isTrue(batch.truncated);
2126 }),
2127 );
2128
2129 it.effect("takes an empty slice for a repository that has run out, not one to read again", () =>
2130 Effect.gen(function* () {
2131 mockedExecute.mockImplementation((call) =>
2132 Effect.succeed(
2133 call.kind === "graphql" && call.query.includes("pullRequests(")
2134 ? emptyList()
2135 : emptySearch(),
2136 ),
2137 );
2138 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2139
2140 yield* cli.listPullRequests({
2141 cwd: "/w",
2142 repository: "acme/web",
2143 host: "github.com",
2144 state: "open",
2145 involvement: "all",
2146 viewer: "bilal",
2147 limit: 10,
2148 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 },
2149 });
2150
2151 // A repository that answered the search once answers it again, so an empty slice under a
2152 // cursor is the end of it rather than a repository search cannot reach.
2153 assert.strictEqual(mockedExecute.mock.calls.length, 1);
2154 }),
2155 );
2156
2157 /** The state a merge or branch update reads first: behind its base and blocked on checks. */
2158 const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
2159 data: {
2160 repository: {
2161 pullRequest: {
2162 id: "PR_7",
2163 headRefOid: "abc123",
2164 isMergeQueueEnabled: false,
2165 mergeStateStatus: "BLOCKED",
2166 baseRef: { compare: { behindBy: 2 } },
2167 ...pullRequest,
2168 },
2169 },
2170 },
2171 });
2172 const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({
2173 data: {
2174 repository: {
2175 pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body },
2176 },
2177 },
2178 });
2179
2180 it.effect("updates a stale branch with a merge commit unless asked to rebase", () =>
2181 Effect.gen(function* () {
2182 route(["query PullRequestActionState", actionState()]);
2183 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2184
2185 yield* cli.runPullRequestAction({
2186 cwd: "/w",
2187 repository: "acme/web",
2188 host: "github.com",
2189 number: 7,
2190 action: "update-branch",
2191 });
2192 yield* cli.runPullRequestAction({
2193 cwd: "/w",
2194 repository: "acme/web",
2195 host: "github.com",
2196 number: 7,
2197 action: "update-branch",
2198 updateMethod: "rebase",
2199 });
2200
2201 // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read.
2202 expect(variablesOf("updatePullRequestBranch(")).toEqual([
2203 { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" },
2204 { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" },
2205 ]);
2206 }),
2207 );
2208
2209 it.effect("merges with the strategy it was asked for", () =>
2210 Effect.gen(function* () {
2211 route(["query PullRequestActionState", actionState()]);
2212 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2213
2214 yield* cli.runPullRequestAction({
2215 cwd: "/w",
2216 repository: "acme/web",
2217 host: "github.com",
2218 number: 7,
2219 action: "merge",
2220 mergeMethod: "squash",
2221 });
2222
2223 expect(variablesOf("mergePullRequest(")).toEqual([
2224 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
2225 ]);
2226 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]);
2227 }),
2228 );
2229
2230 it.effect.each([
2231 {
2232 description: "base-only write",
2233 basePermission: "WRITE",
2234 sourcePermission: "READ",
2235 headRef: { id: "REF" },
2236 expected: false,
2237 actionAllowed: false,
2238 },
2239 {
2240 description: "source-only write",
2241 basePermission: "READ",
2242 sourcePermission: "WRITE",
2243 headRef: { id: "REF" },
2244 expected: true,
2245 actionAllowed: true,
2246 },
2247 {
2248 description: "a deleted branch",
2249 basePermission: "READ",
2250 sourcePermission: "WRITE",
2251 headRef: null,
2252 expected: false,
2253 actionAllowed: true,
2254 },
2255 {
2256 description: "an unavailable source repository",
2257 basePermission: "WRITE",
2258 sourcePermission: null,
2259 headRef: { id: "REF" },
2260 expected: false,
2261 actionAllowed: false,
2262 },
2263 ])(
2264 "uses source branch access in both permission reads for $description",
2265 ({ basePermission, sourcePermission, headRef, expected, actionAllowed }) =>
2266 Effect.gen(function* () {
2267 const answer = coreResponse({
2268 headRepository: sourcePermission === null ? null : { viewerPermission: sourcePermission },
2269 headRef,
2270 });
2271 answer.data.repository.viewerPermission = basePermission;
2272 mockedExecute.mockReturnValue(Effect.succeed(output(encodeJson(answer))));
2273 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2274 const input = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 };
2275 expect((yield* cli.getViewerAccess(input)).canDeleteBranch).toBe(actionAllowed);
2276 expect((yield* cli.getPullRequestDetail(input)).viewerAccess.canDeleteBranch).toBe(
2277 expected,
2278 );
2279 for (const [call] of mockedExecute.mock.calls) {
2280 if (
2281 call.kind === "graphql" &&
2282 !call.query.includes("query PullRequestStackMemberships")
2283 ) {
2284 if (call.query.includes("$headRef: String!")) {
2285 expect(call.query).toContain("headRepository { nameWithOwner viewerPermission }");
2286 expect(call.query).toContain("headRef { id }");
2287 } else {
2288 expect(call.query).toContain("headRepository { viewerPermission }");
2289 }
2290 }
2291 }
2292 }),
2293 );
2294
2295 it.effect("preserves the actual source fork name in provider detail", () =>
2296 Effect.gen(function* () {
2297 const answer = coreResponse({
2298 state: "MERGED",
2299 isCrossRepository: true,
2300 headRepository: { nameWithOwner: "contributor/web-fork", viewerPermission: "WRITE" },
2301 headRef: { id: "REF" },
2302 });
2303 mockedExecute.mockReturnValue(Effect.succeed(output(encodeJson(answer))));
2304 const provider = yield* GitHubPullRequestProvider.make;
2305 const detail = yield* provider.getChangeRequest({
2306 cwd: "/w",
2307 repository: "acme/web",
2308 host: "github.com",
2309 number: 7,
2310 });
2311 expect(detail.headRepositoryNameWithOwner).toBe("contributor/web-fork");
2312 expect(detail.viewerPermissions.actions).toContain("delete-branch");
2313 expect(queryAt(0)).toContain("headRepository { nameWithOwner viewerPermission }");
2314 }),
2315 );
2316
2317 const branchToDelete = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({
2318 data: {
2319 repository: {
2320 pullRequest: {
2321 state: "MERGED",
2322 headRefName: "feat/delete",
2323 baseRefName: "main",
2324 headRefOid: "abc123",
2325 headRepository: { id: "FORK_REPO" },
2326 ...pullRequest,
2327 },
2328 },
2329 },
2330 });
2331 const sourceBranch = (repository: Readonly<Record<string, unknown>> = {}) => ({
2332 data: {
2333 node: {
2334 defaultBranchRef: { name: "main" },
2335 ref: { target: { oid: "abc123" } },
2336 ...repository,
2337 },
2338 },
2339 });
2340
2341 it.effect.each(["MERGED", "CLOSED"])("deletes the exact remote fork branch of a %s PR", (state) =>
2342 Effect.gen(function* () {
2343 route(
2344 ["query PullRequestBranchToDelete", branchToDelete({ state })],
2345 ["query PullRequestSourceBranch", sourceBranch()],
2346 );
2347 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2348 yield* cli.runPullRequestAction({
2349 cwd: "/w",
2350 repository: "acme/web",
2351 host: "github.enterprise.test",
2352 number: 7,
2353 action: "delete-branch",
2354 });
2355 expect(variablesOf("query PullRequestBranchToDelete")).toEqual([
2356 { owner: "acme", name: "web", number: 7 },
2357 ]);
2358 expect(variablesOf("query PullRequestSourceBranch")).toEqual([
2359 { repositoryId: "FORK_REPO", ref: "refs/heads/feat/delete" },
2360 ]);
2361 expect(variablesOf("updateRefs(")).toEqual([
2362 {
2363 input: {
2364 repositoryId: "FORK_REPO",
2365 refUpdates: [
2366 {
2367 name: "refs/heads/feat/delete",
2368 beforeOid: "abc123",
2369 afterOid: "0000000000000000000000000000000000000000",
2370 },
2371 ],
2372 },
2373 },
2374 ]);
2375 expect(mockedExecute.mock.calls.at(-1)?.[0].host).toBe("github.enterprise.test");
2376 expect(variablesOf("mergePullRequest(")).toEqual([]);
2377 expect(variablesOf("closePullRequest(")).toEqual([]);
2378 }),
2379 );
2380
2381 it.effect.each([
2382 { description: "an open PR", head: { state: "OPEN" }, source: {} },
2383 { description: "a missing source repository", head: { headRepository: null }, source: {} },
2384 { description: "the base branch", head: { headRefName: "main" }, source: {} },
2385 {
2386 description: "the default branch",
2387 head: {},
2388 source: { defaultBranchRef: { name: "feat/delete" } },
2389 },
2390 { description: "a missing default branch", head: {}, source: { defaultBranchRef: null } },
2391 {
2392 description: "a changed branch",
2393 head: {},
2394 source: { ref: { target: { oid: "new-commit" } } },
2395 },
2396 { description: "an unavailable source repository", head: {}, source: null },
2397 ])("refuses to delete $description", ({ head, source }) =>
2398 Effect.gen(function* () {
2399 route(
2400 ["query PullRequestBranchToDelete", branchToDelete(head)],
2401 [
2402 "query PullRequestSourceBranch",
2403 source === null ? { data: { node: null } } : sourceBranch(source),
2404 ],
2405 );
2406 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2407 const error = yield* Effect.flip(
2408 cli.runPullRequestAction({
2409 cwd: "/w",
2410 repository: "acme/web",
2411 host: "github.com",
2412 number: 7,
2413 action: "delete-branch",
2414 }),
2415 );
2416 expect(error._tag).toBe("GitHubBranchDeletionRefusedError");
2417 expect(variablesOf("updateRefs(")).toEqual([]);
2418 }),
2419 );
2420
2421 it.effect("accepts an already deleted source branch", () =>
2422 Effect.gen(function* () {
2423 route(
2424 ["query PullRequestBranchToDelete", branchToDelete()],
2425 ["query PullRequestSourceBranch", sourceBranch({ ref: null })],
2426 );
2427 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2428 yield* cli.runPullRequestAction({
2429 cwd: "/w",
2430 repository: "acme/web",
2431 host: "github.com",
2432 number: 7,
2433 action: "delete-branch",
2434 });
2435 expect(variablesOf("updateRefs(")).toEqual([]);
2436 }),
2437 );
2438
2439 it.effect("keeps the source branch when GitHub refuses deletion", () =>
2440 Effect.gen(function* () {
2441 route(
2442 ["query PullRequestBranchToDelete", branchToDelete()],
2443 ["query PullRequestSourceBranch", sourceBranch()],
2444 );
2445 const answer = mockedExecute.getMockImplementation()!;
2446 mockedExecute.mockImplementation((call) =>
2447 call.kind === "graphql" && call.query.includes("updateRefs(")
2448 ? Effect.fail(
2449 new GitHubApi.GitHubApiResponseError({
2450 host: call.host,
2451 operation: call.operation,
2452 status: 422,
2453 }),
2454 )
2455 : answer(call),
2456 );
2457 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2458 const error = yield* Effect.flip(
2459 cli.runPullRequestAction({
2460 cwd: "/w",
2461 repository: "acme/web",
2462 host: "github.com",
2463 number: 7,
2464 action: "delete-branch",
2465 }),
2466 );
2467 expect(error._tag).toBe("GitHubApiResponseError");
2468 expect(variablesOf("mergePullRequest(")).toEqual([]);
2469 expect(variablesOf("closePullRequest(")).toEqual([]);
2470 }),
2471 );
2472
2473 it.effect.each(["merge", "enable-auto-merge"] as const)(
2474 "removes agent credits from the proposed message for %s",
2475 (action) =>
2476 Effect.gen(function* () {
2477 route(
2478 [
2479 "query PullRequestMergeMessage",
2480 mergeMessage(
2481 "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>",
2482 ),
2483 ],
2484 ["query PullRequestActionState", actionState()],
2485 );
2486 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2487 yield* cli.runPullRequestAction({
2488 cwd: "/w",
2489 repository: "acme/web",
2490 host: "github.com",
2491 number: 7,
2492 action,
2493 mergeMethod: "squash",
2494 removeAgentCreditsOnMerge: true,
2495 });
2496 expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH");
2497 expect(
2498 variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("),
2499 ).toEqual([
2500 {
2501 input: {
2502 pullRequestId: "PR_7",
2503 mergeMethod: "SQUASH",
2504 // Pinned to the head the message was read from, so it cannot describe other commits.
2505 expectedHeadOid: "abc123",
2506 commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>",
2507 },
2508 },
2509 ]);
2510 }),
2511 );
2512
2513 it.effect.each([
2514 {
2515 description: "an unchanged message",
2516 body: "Details\n\nCo-authored-by: Alice <alice@example.com>",
2517 queued: false,
2518 },
2519 {
2520 description: "a merge queue",
2521 body: "Co-authored-by: Claude <noreply@anthropic.com>",
2522 queued: true,
2523 },
2524 ] as const)("keeps GitHub's default message for $description", ({ body, queued }) =>
2525 Effect.gen(function* () {
2526 route(
2527 ["query PullRequestMergeMessage", mergeMessage(body, queued)],
2528 ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })],
2529 );
2530 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2531 yield* cli.runPullRequestAction({
2532 cwd: "/w",
2533 repository: "acme/web",
2534 host: "github.com",
2535 number: 7,
2536 action: "merge",
2537 removeAgentCreditsOnMerge: true,
2538 });
2539 expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE");
2540 // A merge queue takes the pull request through auto-merge, with its own message.
2541 expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([
2542 { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } },
2543 ]);
2544 }),
2545 );
2546
2547 it.effect("passes an explicitly empty body when the proposed message only credits an agent", () =>
2548 Effect.gen(function* () {
2549 route(
2550 [
2551 "query PullRequestMergeMessage",
2552 mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"),
2553 ],
2554 ["query PullRequestActionState", actionState()],
2555 );
2556 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2557 yield* cli.runPullRequestAction({
2558 cwd: "/w",
2559 repository: "acme/web",
2560 host: "github.com",
2561 number: 7,
2562 action: "merge",
2563 removeAgentCreditsOnMerge: true,
2564 });
2565 expect(variablesOf("mergePullRequest(")).toEqual([
2566 {
2567 input: {
2568 pullRequestId: "PR_7",
2569 mergeMethod: "MERGE",
2570 expectedHeadOid: "abc123",
2571 commitBody: "",
2572 },
2573 },
2574 ]);
2575 }),
2576 );
2577
2578 it.effect("does not fetch a message for rebase merges", () =>
2579 Effect.gen(function* () {
2580 route(["query PullRequestActionState", actionState()]);
2581 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2582 yield* cli.runPullRequestAction({
2583 cwd: "/w",
2584 repository: "acme/web",
2585 host: "github.com",
2586 number: 7,
2587 action: "merge",
2588 mergeMethod: "rebase",
2589 removeAgentCreditsOnMerge: true,
2590 });
2591 expect(variablesOf("query PullRequestMergeMessage")).toEqual([]);
2592 expect(variablesOf("mergePullRequest(")).toEqual([
2593 { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } },
2594 ]);
2595 }),
2596 );
2597
2598 it.effect("refuses to merge when the proposed message cannot be read", () =>
2599 Effect.gen(function* () {
2600 route(
2601 ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }],
2602 ["query PullRequestActionState", actionState()],
2603 );
2604 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2605 const result = yield* Effect.result(
2606 cli.runPullRequestAction({
2607 cwd: "/w",
2608 repository: "acme/web",
2609 host: "github.com",
2610 number: 7,
2611 action: "merge",
2612 removeAgentCreditsOnMerge: true,
2613 }),
2614 );
2615 expect(result._tag).toBe("Failure");
2616 expect(variablesOf("mergePullRequest(")).toEqual([]);
2617 }),
2618 );
2619
2620 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
2621 Effect.gen(function* () {
2622 route(["query PullRequestActionState", actionState()]);
2623 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2624
2625 yield* cli.runPullRequestAction({
2626 cwd: "/w",
2627 repository: "acme/web",
2628 host: "github.com",
2629 number: 7,
2630 action: "enable-auto-merge",
2631 mergeMethod: "squash",
2632 });
2633 // No strategy asked for is GitHub's own default, exactly as it is for a merge now.
2634 yield* cli.runPullRequestAction({
2635 cwd: "/w",
2636 repository: "acme/web",
2637 host: "github.com",
2638 number: 7,
2639 action: "enable-auto-merge",
2640 });
2641
2642 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([
2643 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
2644 { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } },
2645 ]);
2646 expect(variablesOf("mergePullRequest(")).toEqual([]);
2647 }),
2648 );
2649
2650 it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () =>
2651 Effect.gen(function* () {
2652 route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]);
2653 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2654
2655 yield* cli.runPullRequestAction({
2656 cwd: "/w",
2657 repository: "acme/web",
2658 host: "github.com",
2659 number: 7,
2660 action: "enable-auto-merge",
2661 mergeMethod: "squash",
2662 });
2663
2664 expect(variablesOf("mergePullRequest(")).toEqual([
2665 { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } },
2666 ]);
2667 expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]);
2668 }),
2669 );
2670
2671 it.effect("takes auto-merge back off without naming a strategy", () =>
2672 Effect.gen(function* () {
2673 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
2674 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2675
2676 yield* cli.runPullRequestAction({
2677 cwd: "/w",
2678 repository: "acme/web",
2679 host: "github.com",
2680 number: 7,
2681 action: "disable-auto-merge",
2682 mergeMethod: "squash",
2683 });
2684
2685 expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]);
2686 }),
2687 );
2688
2689 it.effect("opens a pull request that reverts a merged pull request", () =>
2690 Effect.gen(function* () {
2691 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
2692 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2693
2694 yield* cli.runPullRequestAction({
2695 cwd: "/w",
2696 repository: "acme/web",
2697 host: "github.com",
2698 number: 7,
2699 action: "revert",
2700 });
2701
2702 expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]);
2703 }),
2704 );
2705
2706 it.effect("does not approve action-required runs for a same-repository pull request", () =>
2707 Effect.gen(function* () {
2708 mockedExecute.mockReturnValueOnce(
2709 Effect.succeed(
2710 output(
2711 encodeJson(
2712 coreResponse({
2713 number: 7,
2714 title: "Pull request 7",
2715 url: "https://github.com/acme/web/pull/7",
2716 headRefName: "feat/page",
2717 headRefOid: "abc123",
2718 isCrossRepository: false,
2719 headRepositoryOwner: { login: "acme" },
2720 baseRefName: "main",
2721 createdAt: "2026-07-01T00:00:00Z",
2722 updatedAt: "2026-07-02T00:00:00Z",
2723 }),
2724 ),
2725 ),
2726 ),
2727 );
2728 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2729
2730 yield* cli.runPullRequestAction({
2731 cwd: "/w",
2732 repository: "acme/web",
2733 host: "github.com",
2734 number: 7,
2735 action: "approve-workflows",
2736 });
2737
2738 expect(mockedExecute).toHaveBeenCalledTimes(1);
2739 }),
2740 );
2741
2742 it.effect("finds and approves every workflow waiting on a maintainer", () =>
2743 Effect.gen(function* () {
2744 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11]));
2745 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2746
2747 yield* cli.runPullRequestAction({
2748 cwd: "/w",
2749 repository: "acme/web",
2750 host: "github.com",
2751 number: 7,
2752 action: "approve-workflows",
2753 });
2754
2755 // Open pull requests on this head branch, and runs scoped to this exact head.
2756 expect(variablesOf("headRefName: $head")[0]).toEqual({
2757 owner: "acme",
2758 name: "web",
2759 head: "feat/page",
2760 after: null,
2761 });
2762 expect(restCallsTo("actions/runs?")[0]?.path).toBe(
2763 "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1",
2764 );
2765 // Each run is approved only after the head is read again and still lists it.
2766 expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([
2767 ["POST", "repos/acme/web/actions/runs/10/approve"],
2768 ["POST", "repos/acme/web/actions/runs/11/approve"],
2769 ]);
2770 assert.strictEqual(restCallsTo("actions/runs?").length, 3);
2771 }),
2772 );
2773
2774 it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () =>
2775 Effect.gen(function* () {
2776 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([]));
2777 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2778
2779 const runs = yield* cli
2780 .listWorkflowRunsRequiringApproval({
2781 cwd: "/w",
2782 repository: "acme/web",
2783 host: "github.com",
2784 number: 7,
2785 headSha: "abc123",
2786 headBranch: "feat/page",
2787 headRepositoryOwner: "octocat",
2788 isCrossRepository: true,
2789 })
2790 .pipe(
2791 Effect.provideService(KnownWorkflowRuns, {
2792 headSha: "abc123",
2793 runs: [
2794 // Live shape: waiting on approval is completed + action_required.
2795 {
2796 id: 10,
2797 status: "completed",
2798 conclusion: "action_required",
2799 head_branch: "feat/page",
2800 },
2801 { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" },
2802 ],
2803 }),
2804 );
2805
2806 expect(runs.map((run) => run.id)).toEqual([10]);
2807 assert.strictEqual(restCallsTo("actions/runs?").length, 0);
2808 }),
2809 );
2810
2811 it.effect("refuses a stale workflow approval after the pull request head changes", () =>
2812 Effect.gen(function* () {
2813 let detailReads = 0;
2814 workflowApprovalRoutes(
2815 () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"),
2816 heads([7]),
2817 workflowRuns([10]),
2818 );
2819 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2820
2821 const error = yield* Effect.flip(
2822 cli.runPullRequestAction({
2823 cwd: "/w",
2824 repository: "acme/web",
2825 host: "github.com",
2826 number: 7,
2827 action: "approve-workflows",
2828 }),
2829 );
2830
2831 expect(error).toMatchObject({
2832 _tag: "GitHubWorkflowApprovalHeadChangedError",
2833 number: 7,
2834 });
2835 assert.strictEqual(detailReads, 2);
2836 expect(restCallsTo("/approve")).toEqual([]);
2837 }),
2838 );
2839
2840 it.effect("reads workflow runs and their pull request scope concurrently", () =>
2841 Effect.gen(function* () {
2842 const headsStarted = yield* Deferred.make<void>();
2843 const runsStarted = yield* Deferred.make<void>();
2844 mockedExecute.mockImplementation((call) =>
2845 call.kind === "graphql"
2846 ? Deferred.succeed(headsStarted, undefined).pipe(
2847 Effect.andThen(Deferred.await(runsStarted)),
2848 Effect.as(output(encodeJson(heads([7])))),
2849 )
2850 : Deferred.succeed(runsStarted, undefined).pipe(
2851 Effect.andThen(Deferred.await(headsStarted)),
2852 Effect.as(output(encodeJson(workflowRuns([10])))),
2853 ),
2854 );
2855 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2856
2857 const runs = yield* cli.listWorkflowRunsRequiringApproval({
2858 cwd: "/w",
2859 repository: "acme/web",
2860 host: "github.com",
2861 number: 7,
2862 headSha: "abc123",
2863 headBranch: "feat/page",
2864 headRepositoryOwner: "octocat",
2865 isCrossRepository: true,
2866 });
2867
2868 expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]);
2869 }),
2870 );
2871
2872 it.effect("refuses workflow approval when one head belongs to several pull requests", () =>
2873 Effect.gen(function* () {
2874 workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([]));
2875 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2876
2877 const error = yield* Effect.flip(
2878 cli.listWorkflowRunsRequiringApproval({
2879 cwd: "/w",
2880 repository: "acme/web",
2881 host: "github.com",
2882 number: 7,
2883 headSha: "abc123",
2884 headBranch: "feat/page",
2885 headRepositoryOwner: "octocat",
2886 isCrossRepository: true,
2887 }),
2888 );
2889
2890 expect(error).toMatchObject({
2891 _tag: "GitHubWorkflowApprovalRefusedError",
2892 reason: "head-not-unique",
2893 number: 7,
2894 observedCount: 2,
2895 limit: 1_000,
2896 });
2897 expect(error.message).toContain("instead of uniquely matching #7");
2898 }),
2899 );
2900
2901 it.effect("refuses workflow approval when the head list stops before its end", () =>
2902 Effect.gen(function* () {
2903 // GitHub hands the same cursor back, so the second page is never read past. #7 looks
2904 // unique on what was read, and an unread page could still hold another head like it.
2905 const repeating = heads([7]);
2906 repeating.data.repository.pullRequests.pageInfo = {
2907 hasNextPage: true,
2908 endCursor: "same",
2909 } as never;
2910 workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([]));
2911 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2912
2913 const error = yield* Effect.flip(
2914 cli.listWorkflowRunsRequiringApproval({
2915 cwd: "/w",
2916 repository: "acme/web",
2917 host: "github.com",
2918 number: 7,
2919 headSha: "abc123",
2920 headBranch: "feat/page",
2921 headRepositoryOwner: "octocat",
2922 isCrossRepository: true,
2923 }),
2924 );
2925
2926 expect(error).toMatchObject({
2927 _tag: "GitHubWorkflowApprovalRefusedError",
2928 reason: "head-list-truncated",
2929 });
2930 }),
2931 );
2932
2933 it.effect("refuses workflow approval when GitHub omits the head repository", () =>
2934 Effect.gen(function* () {
2935 mockedExecute.mockReturnValueOnce(
2936 Effect.succeed(
2937 output(
2938 encodeJson(
2939 coreResponse({
2940 number: 7,
2941 title: "Pull request 7",
2942 url: "https://github.com/acme/web/pull/7",
2943 headRefName: "feat/page",
2944 headRefOid: "abc123",
2945 isCrossRepository: true,
2946 headRepositoryOwner: null,
2947 baseRefName: "main",
2948 createdAt: "2026-07-01T00:00:00Z",
2949 updatedAt: "2026-07-02T00:00:00Z",
2950 }),
2951 ),
2952 ),
2953 ),
2954 );
2955 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2956
2957 const error = yield* Effect.flip(
2958 cli.runPullRequestAction({
2959 cwd: "/w",
2960 repository: "acme/web",
2961 host: "github.com",
2962 number: 7,
2963 action: "approve-workflows",
2964 }),
2965 );
2966
2967 expect(error).toMatchObject({
2968 _tag: "GitHubWorkflowApprovalHeadUnavailableError",
2969 number: 7,
2970 });
2971 expect(mockedExecute).toHaveBeenCalledTimes(1);
2972 }),
2973 );
2974
2975 it.effect("surfaces a workflow run list beyond the safe approval bound", () =>
2976 Effect.gen(function* () {
2977 let runPages = 0;
2978 mockedExecute.mockImplementation((call) => {
2979 if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7]))));
2980 const page = runPages++;
2981 return Effect.succeed(
2982 output(
2983 encodeJson(
2984 workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)),
2985 ),
2986 ),
2987 );
2988 });
2989 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
2990
2991 const error = yield* Effect.flip(
2992 cli.listWorkflowRunsRequiringApproval({
2993 cwd: "/w",
2994 repository: "acme/web",
2995 host: "github.com",
2996 number: 7,
2997 headSha: "abc123",
2998 headBranch: "feat/page",
2999 headRepositoryOwner: "octocat",
3000 isCrossRepository: true,
3001 }),
3002 );
3003
3004 // Paged a hundred at a time, it stops at the first page past the bound.
3005 expect(error).toMatchObject({
3006 _tag: "GitHubWorkflowApprovalRefusedError",
3007 reason: "run-list-truncated",
3008 number: 7,
3009 observedCount: 1_100,
3010 limit: 1_000,
3011 });
3012 expect(error.message).toContain("more than 1000 workflow runs");
3013 assert.strictEqual(runPages, 11);
3014 }),
3015 );
3016
3017 it.effect("returns a pull request to draft by converting it", () =>
3018 Effect.gen(function* () {
3019 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
3020 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3021
3022 yield* cli.runPullRequestAction({
3023 cwd: "/w",
3024 repository: "acme/web",
3025 host: "github.com",
3026 number: 7,
3027 action: "draft",
3028 });
3029
3030 expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]);
3031 expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]);
3032 }),
3033 );
3034
3035 it.effect("sends a comment body as a variable, never inside the document", () =>
3036 Effect.gen(function* () {
3037 route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]);
3038 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3039
3040 yield* cli.commentOnPullRequest({
3041 cwd: "/w",
3042 repository: "acme/web",
3043 host: "github.com",
3044 number: 7,
3045 body: "Looks good.",
3046 });
3047
3048 expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]);
3049 expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good.");
3050 }),
3051 );
3052
3053 it.effect("names the host on every repository it addresses", () =>
3054 Effect.gen(function* () {
3055 mockedExecute.mockImplementation((call) =>
3056 Effect.succeed(
3057 call.kind === "graphql" && call.query.includes("pullRequests(")
3058 ? emptyList()
3059 : emptySearch(),
3060 ),
3061 );
3062 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3063
3064 yield* cli.listPullRequests({
3065 cwd: "/w",
3066 repository: "acme/web",
3067 host: "github.acme.dev",
3068 state: "open",
3069 involvement: "all",
3070 viewer: "bilal",
3071 limit: 10,
3072 });
3073
3074 // A request sent to github.com would read a different repository of the same name.
3075 assert.isAbove(mockedExecute.mock.calls.length, 0);
3076 expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual(
3077 new Set(["github.acme.dev"]),
3078 );
3079 expect(searchOfCall(0)).toContain("repo:acme/web");
3080 }),
3081 );
3082
3083 it.effect("asks a GitHub Enterprise host for its own review threads", () =>
3084 Effect.gen(function* () {
3085 mockedExecute.mockReturnValueOnce(
3086 Effect.succeed(
3087 output(
3088 encodeJson({
3089 data: {
3090 repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } },
3091 },
3092 }),
3093 ),
3094 ),
3095 );
3096 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3097
3098 yield* cli.listReviewThreadComments({
3099 cwd: "/w",
3100 repository: "acme/web",
3101 host: "github.acme.dev",
3102 number: 7,
3103 });
3104
3105 expect(callAt(0).host).toBe("github.acme.dev");
3106 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
3107 }),
3108 );
3109
3110 it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () =>
3111 Effect.gen(function* () {
3112 mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a")));
3113 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3114
3115 const diff = yield* cli.getPullRequestDiff({
3116 cwd: "/w",
3117 repository: "acme/web",
3118 host: "github.com",
3119 number: 7,
3120 });
3121
3122 assert.strictEqual(diff.patch, "diff --git a/a b/a");
3123 assert.isNull(diff.nextCursor);
3124 assert.isFalse(diff.truncated);
3125 // The common case pays for one request and not the files API on top of it.
3126 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3127 // GitHub's combined pull-request diff: one section per changed file, not one per commit.
3128 const call = callAt(0);
3129 assert.strictEqual(call.kind, "rest");
3130 if (call.kind === "rest") {
3131 assert.strictEqual(call.path, "repos/acme/web/pulls/7");
3132 assert.strictEqual(call.accept, "application/vnd.github.diff");
3133 }
3134 }),
3135 );
3136
3137 it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () =>
3138 Effect.gen(function* () {
3139 // GitHub answers 406 rather than a diff past 300 changed files.
3140 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
3141 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1))));
3142 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3143
3144 const diff = yield* cli.getPullRequestDiff({
3145 cwd: "/w",
3146 repository: "acme/web",
3147 host: "github.acme.dev",
3148 number: 7,
3149 });
3150
3151 assert.isFalse(diff.truncated);
3152 // A short page is the end of the change set, so there is nothing to carry on from.
3153 assert.isNull(diff.nextCursor);
3154 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");
3155 expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts");
3156 assert.strictEqual(callAt(1).host, "github.acme.dev");
3157 assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1");
3158 }),
3159 );
3160
3161 it.effect("hands back a cursor for the next page rather than walking on by itself", () =>
3162 Effect.gen(function* () {
3163 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
3164 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0))));
3165 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3166
3167 const diff = yield* cli.getPullRequestDiff({
3168 cwd: "/w",
3169 repository: "acme/web",
3170 host: "github.com",
3171 number: 7,
3172 });
3173
3174 // A full page means more files, which the reader asks for; it is not a truncated slice.
3175 assert.isFalse(diff.truncated);
3176 assert.isNotNull(diff.nextCursor);
3177 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3178 }),
3179 );
3180
3181 it.effect("carries on from a cursor without asking for the whole diff again", () =>
3182 Effect.gen(function* () {
3183 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
3184 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0))));
3185 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3186 const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 };
3187
3188 const first = yield* cli.getPullRequestDiff(target);
3189 assert.isNotNull(first.nextCursor);
3190 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100))));
3191 const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor });
3192
3193 assert.isNull(second.nextCursor);
3194 expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts");
3195 // The second slice is one request: the cursor already says where to read.
3196 assert.strictEqual(mockedExecute.mock.calls.length, 3);
3197 assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2");
3198 }),
3199 );
3200
3201 it.effect("refuses a cursor it never handed out rather than reading it into a request", () =>
3202 Effect.gen(function* () {
3203 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3204
3205 const error = yield* Effect.flip(
3206 cli.getPullRequestDiff({
3207 cwd: "/w",
3208 repository: "acme/web",
3209 host: "github.com",
3210 number: 7,
3211 cursor: "1&per_page=1",
3212 }),
3213 );
3214
3215 assert.strictEqual(error._tag, "GitHubDiffCursorError");
3216 assert.strictEqual(mockedExecute.mock.calls.length, 0);
3217 }),
3218 );
3219
3220 it.effect("reads a named commit from the commit endpoint rather than the whole diff", () =>
3221 Effect.gen(function* () {
3222 mockedExecute.mockReturnValueOnce(
3223 Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)),
3224 );
3225 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3226
3227 const diff = yield* cli.getPullRequestDiff({
3228 cwd: "/w",
3229 repository: "acme/web",
3230 host: "github.com",
3231 number: 7,
3232 commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
3233 });
3234
3235 // One request: the commit's own changes never take the whole-diff road.
3236 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3237 assert.isNull(diff.nextCursor);
3238 // The commit endpoint wraps its files in an object, which the decoder unwraps.
3239 expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts");
3240 assert.strictEqual(
3241 pathAt(0),
3242 "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1",
3243 );
3244 }),
3245 );
3246
3247 it.effect("pages inside a commit the way it pages the pull request's own files", () =>
3248 Effect.gen(function* () {
3249 mockedExecute.mockReturnValueOnce(
3250 Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)),
3251 );
3252 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3253 const target = {
3254 cwd: "/w",
3255 repository: "acme/web",
3256 host: "github.com",
3257 number: 7,
3258 commit: "a1b2c3d",
3259 };
3260
3261 const first = yield* cli.getPullRequestDiff(target);
3262 assert.isNotNull(first.nextCursor);
3263 mockedExecute.mockReturnValueOnce(
3264 Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)),
3265 );
3266 const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor });
3267
3268 assert.isNull(second.nextCursor);
3269 expect(second.patch).toContain("src/file100.ts");
3270 assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2");
3271 }),
3272 );
3273
3274 it.effect("refuses a commit that is not a sha rather than reading it into a request", () =>
3275 Effect.gen(function* () {
3276 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3277
3278 const error = yield* Effect.flip(
3279 cli.getPullRequestDiff({
3280 cwd: "/w",
3281 repository: "acme/web",
3282 host: "github.com",
3283 number: 7,
3284 commit: "../../pulls/8/files",
3285 }),
3286 );
3287
3288 assert.strictEqual(error._tag, "GitHubDiffCommitError");
3289 assert.strictEqual(mockedExecute.mock.calls.length, 0);
3290 }),
3291 );
3292
3293 it.effect("expands a new file from a root commit without requiring a parent", () =>
3294 Effect.gen(function* () {
3295 mockedExecute.mockReturnValueOnce(
3296 Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))),
3297 );
3298 mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n")));
3299 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3300
3301 const contents = yield* cli.getPullRequestDiffFileContents({
3302 cwd: "/w",
3303 repository: "acme/web",
3304 host: "github.com",
3305 number: 7,
3306 commit: "a1b2c3d",
3307 changeType: "new",
3308 oldPath: "src/root.ts",
3309 newPath: "src/root.ts",
3310 });
3311
3312 expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" });
3313 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3314 assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d");
3315 assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d");
3316 }),
3317 );
3318
3319 it.effect("reports unusable diff revisions as a structured error", () =>
3320 Effect.gen(function* () {
3321 mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n")));
3322 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3323
3324 const error = yield* Effect.flip(
3325 cli.getPullRequestDiffFileContents({
3326 cwd: "/w",
3327 repository: "acme/web",
3328 host: "github.com",
3329 number: 7,
3330 commit: "a1b2c3d",
3331 changeType: "change",
3332 oldPath: "src/a.ts",
3333 newPath: "src/a.ts",
3334 }),
3335 );
3336
3337 assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError");
3338 if (error._tag === "GitHubDiffRevisionsUnavailableError") {
3339 assert.strictEqual(error.number, 7);
3340 assert.strictEqual(error.commit, "a1b2c3d");
3341 }
3342 }),
3343 );
3344
3345 it.effect("reports an oversized diff file with its path and reason", () =>
3346 Effect.gen(function* () {
3347 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
3348 mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true)));
3349 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3350
3351 const error = yield* Effect.flip(
3352 cli.getPullRequestDiffFileContents({
3353 cwd: "/w",
3354 repository: "acme/web",
3355 host: "github.com",
3356 number: 7,
3357 changeType: "deleted",
3358 oldPath: "src/large.ts",
3359 newPath: "src/large.ts",
3360 }),
3361 );
3362
3363 assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError");
3364 if (error._tag === "GitHubDiffFileContentsUnavailableError") {
3365 assert.strictEqual(error.path, "src/large.ts");
3366 assert.strictEqual(error.reason, "oversized");
3367 }
3368 // A deleted file is read at the base revision only.
3369 assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d");
3370 }),
3371 );
3372
3373 it.effect("reports undecodable diff file contents as binary", () =>
3374 Effect.gen(function* () {
3375 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
3376 mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true)));
3377 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3378
3379 const error = yield* Effect.flip(
3380 cli.getPullRequestDiffFileContents({
3381 cwd: "/w",
3382 repository: "acme/web",
3383 host: "github.com",
3384 number: 7,
3385 changeType: "deleted",
3386 oldPath: "assets/logo.png",
3387 newPath: "assets/logo.png",
3388 }),
3389 );
3390
3391 assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError");
3392 if (error._tag === "GitHubDiffFileContentsUnavailableError") {
3393 assert.strictEqual(error.path, "assets/logo.png");
3394 assert.strictEqual(error.reason, "binary");
3395 }
3396 }),
3397 );
3398
3399 it.effect("returns valid text containing a literal replacement character", () =>
3400 Effect.gen(function* () {
3401 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs)));
3402 mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after")));
3403 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3404
3405 const contents = yield* cli.getPullRequestDiffFileContents({
3406 cwd: "/w",
3407 repository: "acme/web",
3408 host: "github.com",
3409 number: 7,
3410 changeType: "deleted",
3411 oldPath: "docs/encoding.md",
3412 newPath: "docs/encoding.md",
3413 });
3414
3415 assert.strictEqual(contents.oldContents, "before�after");
3416 }),
3417 );
3418
3419 it.effect("ends the diff on a page with no files rather than asking for it again", () =>
3420 Effect.gen(function* () {
3421 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
3422 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3423
3424 const diff = yield* cli.getPullRequestDiff({
3425 cwd: "/w",
3426 repository: "acme/web",
3427 host: "github.com",
3428 number: 7,
3429 cursor: "4",
3430 });
3431
3432 assert.strictEqual(diff.patch, "");
3433 assert.isNull(diff.nextCursor);
3434 }),
3435 );
3436
3437 it.effect("reports the refused diff when the files API cannot answer either", () =>
3438 Effect.gen(function* () {
3439 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
3440 mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json")));
3441 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3442
3443 const error = yield* Effect.flip(
3444 cli.getPullRequestDiff({
3445 cwd: "/w",
3446 repository: "acme/web",
3447 host: "github.com",
3448 number: 7,
3449 }),
3450 );
3451
3452 assert.strictEqual(error, diffRefused);
3453 }),
3454 );
3455
3456 it.effect("skips the avatar lookup when a listing named nobody", () =>
3457 Effect.gen(function* () {
3458 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3459
3460 const avatars = yield* cli.listActorAvatars({
3461 cwd: "/w",
3462 repository: "acme/web",
3463 host: "github.com",
3464 ids: [],
3465 });
3466
3467 assert.strictEqual(avatars.size, 0);
3468 assert.strictEqual(mockedExecute.mock.calls.length, 0);
3469 }),
3470 );
3471
3472 it.effect("looks up avatars by node id in one GraphQL read", () =>
3473 Effect.gen(function* () {
3474 mockedExecute.mockReturnValueOnce(
3475 Effect.succeed(
3476 output(
3477 encodeJson({
3478 data: {
3479 nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }],
3480 },
3481 }),
3482 ),
3483 ),
3484 );
3485 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3486
3487 const avatars = yield* cli.listActorAvatars({
3488 cwd: "/w",
3489 repository: "acme/web",
3490 host: "github.com",
3491 ids: ["MDQ6VXNlcjE="],
3492 });
3493
3494 expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] });
3495 expect(avatars.get("octocat")).toBe("https://avatars/octocat");
3496 }),
3497 );
3498
3499 it.effect("fails when the authenticated account has no login", () =>
3500 Effect.gen(function* () {
3501 mockedExecute.mockReturnValueOnce(Effect.succeed(output(" ")));
3502 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3503
3504 const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" }));
3505
3506 assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError");
3507 }),
3508 );
3509
3510 it.effect("looks up the authenticated account on the requested enterprise host", () =>
3511 Effect.gen(function* () {
3512 mockedExecute.mockReturnValueOnce(
3513 Effect.succeed(output('{"id":456,"login":"enterprise-user"}')),
3514 );
3515 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3516
3517 const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" });
3518
3519 expect(login).toBe("enterprise-user");
3520 expect(mockedCredential).toHaveBeenCalledWith("github.acme.com");
3521 expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" });
3522 }),
3523 );
3524
3525 it.effect("reuses verified credentials offline and refuses an unverified replacement", () =>
3526 Effect.gen(function* () {
3527 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3528 const input = { cwd: "/w", host: "github.identity-cache.test" };
3529 mockedCredential.mockImplementation((host) =>
3530 Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }),
3531 );
3532 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}')));
3533 expect(yield* cli.getRoutingIdentity(input)).toEqual({
3534 accountId: "123",
3535 viewer: "maria-rcks",
3536 });
3537
3538 // The same credential again is answered from what was verified, without asking GitHub.
3539 expect(yield* cli.getRoutingIdentity(input)).toEqual({
3540 accountId: "123",
3541 viewer: "maria-rcks",
3542 });
3543 expect(mockedExecute).toHaveBeenCalledTimes(1);
3544
3545 // A switched credential is not trusted on the strength of the old one's answer.
3546 mockedCredential.mockImplementation((host) =>
3547 Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }),
3548 );
3549 mockedExecute.mockReturnValueOnce(
3550 Effect.fail(
3551 new GitHubApi.GitHubApiResponseError({
3552 host: "github.com",
3553 operation: "getRoutingIdentity",
3554 status: 502,
3555 }),
3556 ),
3557 );
3558 // GitHub's own refusal is reported as itself, so the page can say what went wrong.
3559 const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip);
3560 expect(failure._tag).toBe("GitHubApiResponseError");
3561 expect(String(failure)).not.toContain("test-credential-b");
3562 expect(mockedExecute).toHaveBeenCalledTimes(2);
3563
3564 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}')));
3565 expect(yield* cli.getRoutingIdentity(input)).toEqual({
3566 accountId: "456",
3567 viewer: "maria-rcks",
3568 });
3569 }),
3570 );
3571
3572 it.effect("sends a whole review as one request", () =>
3573 Effect.gen(function* () {
3574 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
3575 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3576
3577 yield* cli.submitReview({
3578 cwd: "/w",
3579 repository: "acme/web",
3580 host: "github.com",
3581 number: 7,
3582 verdict: "approve",
3583 body: "Looks right.",
3584 comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }],
3585 });
3586
3587 // One request, so nothing is on the pull request until the verdict is.
3588 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3589 expect(restCallsTo("/reviews")).toMatchObject([
3590 {
3591 method: "POST",
3592 path: "repos/acme/web/pulls/7/reviews",
3593 body: {
3594 event: "APPROVE",
3595 body: "Looks right.",
3596 comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }],
3597 },
3598 },
3599 ]);
3600 }),
3601 );
3602
3603 it.effect("sends a reply body as a variable, never inside the document", () =>
3604 Effect.gen(function* () {
3605 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
3606 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3607
3608 yield* cli.replyToReviewThread({
3609 cwd: "/w",
3610 repository: "acme/web",
3611 host: "github.com",
3612 threadId: "PRRT_1",
3613 body: "Fixed in 42ff8ec.",
3614 });
3615
3616 expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([
3617 { threadId: "PRRT_1", body: "Fixed in 42ff8ec." },
3618 ]);
3619 expect(queryAt(0)).not.toContain("Fixed in 42ff8ec.");
3620 }),
3621 );
3622
3623 it.effect("resolves and unresolves through the mutation each one needs", () =>
3624 Effect.gen(function* () {
3625 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
3626 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3627
3628 yield* cli.setReviewThreadResolution({
3629 cwd: "/w",
3630 repository: "acme/web",
3631 host: "github.acme.dev",
3632 threadId: "PRRT_1",
3633 resolved: true,
3634 });
3635 yield* cli.setReviewThreadResolution({
3636 cwd: "/w",
3637 repository: "acme/web",
3638 host: "github.acme.dev",
3639 threadId: "PRRT_1",
3640 resolved: false,
3641 });
3642
3643 expect(queryAt(0)).toContain("resolveReviewThread(");
3644 expect(queryAt(0)).not.toContain("unresolveReviewThread(");
3645 expect(queryAt(1)).toContain("unresolveReviewThread(");
3646 expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]);
3647 // A GitHub Enterprise thread is resolved on its own host, not on github.com.
3648 expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]);
3649 }),
3650 );
3651
3652 it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () =>
3653 Effect.gen(function* () {
3654 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
3655 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3656
3657 yield* cli.setReaction({
3658 cwd: "/w",
3659 repository: "acme/web",
3660 host: "github.com",
3661 number: 7,
3662 subjectId: "IC_1",
3663 content: "heart",
3664 reacted: true,
3665 });
3666
3667 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3668 expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([
3669 { owner: "acme", name: "web", number: 7, subjectId: "IC_1" },
3670 ]);
3671 expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]);
3672 }),
3673 );
3674
3675 it.effect("refuses a given subject that belongs to a different pull request", () =>
3676 Effect.gen(function* () {
3677 mockedExecute.mockReturnValueOnce(
3678 Effect.succeed(
3679 output(
3680 encodeJson({
3681 data: {
3682 repository: { pullRequest: { id: "PR_thisOne" } },
3683 // A comment on pull request #99 of a different repository, named as though it
3684 // belonged to #7 here.
3685 node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } },
3686 },
3687 }),
3688 ),
3689 ),
3690 );
3691 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3692
3693 const error = yield* Effect.flip(
3694 cli.setReaction({
3695 cwd: "/w",
3696 repository: "acme/web",
3697 host: "github.com",
3698 number: 7,
3699 subjectId: "IC_99",
3700 content: "heart",
3701 reacted: true,
3702 }),
3703 );
3704
3705 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
3706 // Refused before any mutation was sent.
3707 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3708 }),
3709 );
3710
3711 it.effect("looks up the pull request's own node id when no subject was given", () =>
3712 Effect.gen(function* () {
3713 route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]);
3714 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3715
3716 // Its own pull request: a node id looked up once is remembered for the life of the service.
3717 yield* cli.setReaction({
3718 cwd: "/w",
3719 repository: "acme/web",
3720 host: "github.com",
3721 number: 21,
3722 content: "rocket",
3723 reacted: true,
3724 });
3725
3726 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3727 expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]);
3728 expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]);
3729 }),
3730 );
3731
3732 it.effect("takes a reaction back through the remove mutation", () =>
3733 Effect.gen(function* () {
3734 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
3735 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3736
3737 yield* cli.setReaction({
3738 cwd: "/w",
3739 repository: "acme/web",
3740 host: "github.com",
3741 number: 7,
3742 subjectId: "IC_1",
3743 content: "heart",
3744 reacted: false,
3745 });
3746
3747 expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]);
3748 expect(variablesOf("addReaction(")).toEqual([]);
3749 }),
3750 );
3751
3752 it.effect("rewrites only the words a request named", () =>
3753 Effect.gen(function* () {
3754 route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]);
3755 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3756 const rewrite = (fields: { readonly title?: string; readonly body?: string }) =>
3757 cli.updatePullRequest({
3758 cwd: "/w",
3759 repository: "acme/web",
3760 host: "github.com",
3761 number: 22,
3762 ...fields,
3763 });
3764
3765 yield* rewrite({ title: "A better title" });
3766 yield* rewrite({ body: "A better description." });
3767 yield* rewrite({ title: "Both", body: "at once." });
3768
3769 // One node id lookup for the pull request, then a mutation per rewrite.
3770 assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1);
3771 expect(variablesOf("updatePullRequest(")).toEqual([
3772 { pullRequestId: "PR_kwDOA", title: "A better title" },
3773 { pullRequestId: "PR_kwDOA", body: "A better description." },
3774 { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." },
3775 ]);
3776 }),
3777 );
3778
3779 it.effect("rewrites a remark through the mutation its kind needs", () =>
3780 Effect.gen(function* () {
3781 route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]);
3782 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3783 const rewrite = (kind: "issue-comment" | "review-comment") =>
3784 cli.updateComment({
3785 cwd: "/w",
3786 repository: "acme/web",
3787 host: "github.com",
3788 number: 7,
3789 commentId: "IC_1",
3790 kind,
3791 body: "Reworded.",
3792 });
3793
3794 yield* rewrite("issue-comment");
3795 yield* rewrite("review-comment");
3796
3797 expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([
3798 "IC_1",
3799 "IC_1",
3800 ]);
3801 expect(variablesOf("updateIssueComment(")).toEqual([
3802 { commentId: "IC_1", body: "Reworded." },
3803 ]);
3804 expect(variablesOf("updatePullRequestReviewComment(")).toEqual([
3805 { commentId: "IC_1", body: "Reworded." },
3806 ]);
3807 }),
3808 );
3809
3810 it.effect("refuses a comment that belongs to a different pull request", () =>
3811 Effect.gen(function* () {
3812 mockedExecute.mockReturnValueOnce(
3813 Effect.succeed(
3814 output(
3815 encodeJson({
3816 data: {
3817 repository: { pullRequest: { id: "PR_thisOne" } },
3818 node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } },
3819 },
3820 }),
3821 ),
3822 ),
3823 );
3824 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3825
3826 const error = yield* Effect.flip(
3827 cli.updateComment({
3828 cwd: "/w",
3829 repository: "acme/web",
3830 host: "github.com",
3831 number: 7,
3832 commentId: "IC_99",
3833 kind: "issue-comment",
3834 body: "Reworded.",
3835 }),
3836 );
3837
3838 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
3839 if (error._tag === "GitHubSubjectScopeError")
3840 assert.strictEqual(error.operation, "updateComment");
3841 // Refused before any mutation was sent.
3842 assert.strictEqual(mockedExecute.mock.calls.length, 1);
3843 }),
3844 );
3845
3846 it.effect("fails the read when gh returns something unreadable", () =>
3847 Effect.gen(function* () {
3848 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')));
3849 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3850
3851 const error = yield* Effect.flip(
3852 cli.getPullRequestDetail({
3853 cwd: "/w",
3854 repository: "acme/web",
3855 host: "github.com",
3856 number: 7,
3857 }),
3858 );
3859
3860 assert.strictEqual(error._tag, "GitHubPullRequestReadError");
3861 }),
3862 );
3863
3864 it.effect("keeps the core detail read separate from conversation activity", () =>
3865 Effect.gen(function* () {
3866 mockedExecute.mockReturnValueOnce(
3867 Effect.succeed(
3868 output(
3869 encodeJson(
3870 coreResponse({
3871 title: "Progressive detail",
3872 author: { login: "octocat" },
3873 body: "Core body",
3874 changedFiles: 2,
3875 }),
3876 ),
3877 ),
3878 ),
3879 );
3880 mockedExecute.mockReturnValueOnce(
3881 Effect.succeed(
3882 output(
3883 encodeJson({
3884 data: {
3885 repository: {
3886 pullRequest: {
3887 author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" },
3888 commits: { nodes: [] },
3889 comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
3890 reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
3891 },
3892 },
3893 },
3894 }),
3895 ),
3896 ),
3897 );
3898 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3899 const input = {
3900 cwd: "/w",
3901 repository: "acme/web",
3902 host: "github.com",
3903 number: 7,
3904 } as const;
3905
3906 const detail = yield* cli.getPullRequestDetail(input);
3907 const activity = yield* cli.getPullRequestActivity(input);
3908
3909 expect(detail.body).toBe("Core body");
3910 expect(activity.author?.login).toBe("octocat");
3911 expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" });
3912 expect(queryAt(0)).toContain("viewerCanUpdateBranch");
3913 expect(queryAt(0)).not.toContain("reviews(");
3914 expect(detail.viewerAccess.mergeCapabilities).toEqual({
3915 merge: true,
3916 squash: false,
3917 rebase: true,
3918 });
3919 expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true });
3920 // Conversation activity is its own read, and asks for the head of the conversation once.
3921 expect(queryAt(1)).toContain("reviews(");
3922 expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true });
3923 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3924 }),
3925 );
3926
3927 it.effect("decodes reviewers, labels and workflow checks without another detail read", () =>
3928 Effect.gen(function* () {
3929 mockedExecute.mockReturnValueOnce(
3930 Effect.succeed(
3931 output(
3932 encodeJson(
3933 coreResponse({
3934 baseRef: null,
3935 reviewRequests: {
3936 nodes: [
3937 { requestedReviewer: { login: "reviewer" } },
3938 {
3939 requestedReviewer: {
3940 slug: "maintainers",
3941 combinedSlug: "octo/maintainers",
3942 name: "Maintainers",
3943 avatarUrl: "https://avatars.githubusercontent.com/t/1",
3944 },
3945 },
3946 ],
3947 },
3948 labels: { nodes: [{ name: "bug", color: "ff0000" }] },
3949 commits: {
3950 nodes: [
3951 {
3952 commit: {
3953 statusCheckRollup: {
3954 contexts: {
3955 nodes: [
3956 {
3957 __typename: "CheckRun",
3958 name: "build",
3959 status: "COMPLETED",
3960 conclusion: "SUCCESS",
3961 checkSuite: { workflowRun: { workflow: { name: "linux" } } },
3962 },
3963 {
3964 __typename: "CheckRun",
3965 name: "build",
3966 status: "COMPLETED",
3967 conclusion: "FAILURE",
3968 checkSuite: { workflowRun: { workflow: { name: "windows" } } },
3969 },
3970 ],
3971 pageInfo: { hasNextPage: false },
3972 },
3973 },
3974 },
3975 },
3976 ],
3977 },
3978 }),
3979 ),
3980 ),
3981 ),
3982 );
3983 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
3984 const detail = yield* cli.getPullRequestDetail({
3985 cwd: "/w",
3986 repository: "acme/web",
3987 host: "github.com",
3988 number: 7,
3989 });
3990 expect(mockedExecute).toHaveBeenCalledOnce();
3991 expect(detail.comparison).toBeNull();
3992 expect(detail.reviewRequestLogins).toEqual(["reviewer"]);
3993 expect(detail.hasTeamReviewRequest).toBe(true);
3994 expect(detail.reviewRequestTeams).toEqual([
3995 {
3996 login: "octo/maintainers",
3997 name: "Maintainers",
3998 avatarUrl: "https://avatars.githubusercontent.com/t/1",
3999 },
4000 ]);
4001 expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]);
4002 expect(detail.checks).toHaveLength(2);
4003 expect(detail.checksState).toBe("failing");
4004 }),
4005 );
4006
4007 it.effect("reads every check when the combined response has another page", () =>
4008 Effect.gen(function* () {
4009 mockedExecute.mockReturnValueOnce(
4010 Effect.succeed(
4011 output(
4012 encodeJson(
4013 coreResponse({
4014 commits: {
4015 nodes: [
4016 {
4017 commit: {
4018 statusCheckRollup: {
4019 contexts: {
4020 nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }],
4021 pageInfo: { hasNextPage: true, endCursor: "c1" },
4022 },
4023 },
4024 },
4025 },
4026 ],
4027 },
4028 }),
4029 ),
4030 ),
4031 ),
4032 );
4033 mockedExecute.mockReturnValueOnce(
4034 Effect.succeed(
4035 output(
4036 checkContextsPage(
4037 [
4038 { name: "first", status: "COMPLETED", conclusion: "SUCCESS" },
4039 { name: "last", status: "COMPLETED", conclusion: "FAILURE" },
4040 ],
4041 null,
4042 ),
4043 ),
4044 ),
4045 );
4046 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4047 const detail = yield* cli.getPullRequestDetail({
4048 cwd: "/w",
4049 repository: "acme/web",
4050 host: "github.com",
4051 number: 7,
4052 });
4053 expect(detail.checks).toHaveLength(2);
4054 expect(detail.checksState).toBe("failing");
4055 expect(detail.checksTruncated).toBe(false);
4056 // The whole rollup is walked from its start, so no check is counted twice or skipped.
4057 expect(varsAt(1)).toMatchObject({ number: 7, after: null });
4058 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4059 }),
4060 );
4061
4062 it.effect("refuses to combine checks from different head revisions", () =>
4063 Effect.gen(function* () {
4064 const response = coreResponse({
4065 commits: {
4066 nodes: [
4067 {
4068 commit: {
4069 statusCheckRollup: {
4070 contexts: {
4071 nodes: [],
4072 pageInfo: { hasNextPage: true },
4073 },
4074 },
4075 },
4076 },
4077 ],
4078 },
4079 });
4080 mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response))));
4081 mockedExecute.mockReturnValueOnce(
4082 Effect.succeed(
4083 output(
4084 encodeJson({
4085 ...response.data.repository.pullRequest,
4086 headRefOid: "new-head",
4087 reviewRequests: [],
4088 labels: [],
4089 statusCheckRollup: [],
4090 }),
4091 ),
4092 ),
4093 );
4094 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4095 const error = yield* Effect.flip(
4096 cli.getPullRequestDetail({
4097 cwd: "/w",
4098 repository: "acme/web",
4099 host: "github.com",
4100 number: 7,
4101 }),
4102 );
4103 expect(error._tag).toBe("GitHubPullRequestReadError");
4104 }),
4105 );
4106
4107 it.effect("preserves the reserve for automatic detail reads and allows manual checks", () =>
4108 Effect.gen(function* () {
4109 const response = coreResponse();
4110 mockedExecute.mockReturnValue(
4111 Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))),
4112 );
4113 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4114 const input = {
4115 cwd: "/w",
4116 repository: "acme/web",
4117 host: "github.core-reserve.test",
4118 number: 7,
4119 };
4120 yield* cli.getPullRequestDetail(input);
4121 const error = yield* Effect.flip(cli.getPullRequestDetail(input));
4122 expect(error._tag).toBe("SourceControlRateLimitPausedError");
4123 expect(mockedExecute).toHaveBeenCalledOnce();
4124 yield* cli
4125 .getPullRequestDetail(input)
4126 .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true));
4127 expect(mockedExecute).toHaveBeenCalledTimes(2);
4128 }),
4129 );
4130
4131 it.effect("fails a files page too large to read rather than calling the diff whole", () =>
4132 Effect.gen(function* () {
4133 mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused));
4134 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true)));
4135 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4136
4137 const error = yield* Effect.flip(
4138 cli.getPullRequestDiff({
4139 cwd: "/w",
4140 repository: "acme/web",
4141 host: "github.com",
4142 number: 7,
4143 }),
4144 );
4145
4146 // What matters is that it fails at all: an empty patch with no cursor would render as a
4147 // change with no files and report the rest of it as already read. The refusal that sent
4148 // the read down this road is the one reported, by design.
4149 assert.strictEqual(error, diffRefused);
4150 }),
4151 );
4152
4153 it.effect("pages an oversized patch by file rather than handing back a severed one", () =>
4154 Effect.gen(function* () {
4155 // The whole diff came back, but cut at a byte, which lands mid-file.
4156 mockedExecute.mockReturnValueOnce(
4157 Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)),
4158 );
4159 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1))));
4160 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4161
4162 const slice = yield* cli.getPullRequestDiff({
4163 cwd: "/w",
4164 repository: "acme/web",
4165 host: "github.com",
4166 number: 7,
4167 });
4168
4169 // The severed patch is thrown away; what comes back is assembled from whole files.
4170 assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1");
4171 expect(slice.patch).toContain("src/file1.ts");
4172 expect(slice.patch).not.toContain("diff --git a/a b/a");
4173 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4174 }),
4175 );
4176
4177 it.effect("follows the cursor to the review threads the first page left behind", () =>
4178 Effect.gen(function* () {
4179 mockedExecute.mockReturnValueOnce(
4180 Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))),
4181 );
4182 mockedExecute.mockReturnValueOnce(
4183 Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))),
4184 );
4185 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4186
4187 const conversation = yield* cli.listReviewThreadComments({
4188 cwd: "/w",
4189 repository: "acme/web",
4190 host: "github.com",
4191 number: 7,
4192 });
4193
4194 // The first page asks from the beginning; the next carries on from where it stopped.
4195 expect(varsAt(0)["cursor"]).toBeNull();
4196 expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE");
4197 expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]);
4198 assert.isFalse(conversation.truncated);
4199 }),
4200 );
4201
4202 it.effect("stops at the thread bound and says the conversation was cut short", () =>
4203 Effect.gen(function* () {
4204 // A host that never runs out of pages: the walk has to end itself.
4205 mockedExecute.mockReturnValue(
4206 Effect.succeed(
4207 output(
4208 reviewThreadsPage(
4209 [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }],
4210 "Y3Vyc29yOjE",
4211 ),
4212 ),
4213 ),
4214 );
4215 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4216
4217 const conversation = yield* cli.listReviewThreadComments({
4218 cwd: "/w",
4219 repository: "acme/web",
4220 host: "github.com",
4221 number: 7,
4222 });
4223
4224 assert.strictEqual(mockedExecute.mock.calls.length, 10);
4225 assert.isTrue(conversation.truncated);
4226 assert.isTrue(conversation.reviewThreadsTruncated);
4227 }),
4228 );
4229
4230 it.effect("leaves a long thread paged until the reader asks for more", () =>
4231 Effect.gen(function* () {
4232 mockedExecute.mockReturnValueOnce(
4233 Effect.succeed(
4234 output(
4235 reviewThreadsPage(
4236 [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }],
4237 null,
4238 ),
4239 ),
4240 ),
4241 );
4242 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4243
4244 const conversation = yield* cli.listReviewThreadComments({
4245 cwd: "/w",
4246 repository: "acme/web",
4247 host: "github.com",
4248 number: 7,
4249 });
4250
4251 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4252 expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]);
4253 expect(conversation.reviewThreads[0]).toMatchObject({
4254 commentCount: 3,
4255 nextCommentsCursor: "Y3Vyc29yOjI",
4256 });
4257 assert.isTrue(conversation.truncated);
4258 assert.isFalse(conversation.reviewThreadsTruncated);
4259 }),
4260 );
4261
4262 it.effect("reads one requested page from a review thread cursor", () =>
4263 Effect.gen(function* () {
4264 mockedExecute.mockReturnValueOnce(
4265 Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))),
4266 );
4267 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4268
4269 const page = yield* cli.getReviewThreadComments({
4270 cwd: "/w",
4271 repository: "acme/web",
4272 host: "github.com",
4273 number: 7,
4274 threadId: "PRRT_1",
4275 cursor: "Y3Vyc29yOjI",
4276 });
4277
4278 expect(varsAt(0)).toMatchObject({
4279 owner: "acme",
4280 name: "web",
4281 number: 7,
4282 threadId: "PRRT_1",
4283 cursor: "Y3Vyc29yOjI",
4284 });
4285 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4286 expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]);
4287 expect(page.nextCursor).toBeNull();
4288 }),
4289 );
4290
4291 it.effect("refuses a review thread from another pull request", () =>
4292 Effect.gen(function* () {
4293 mockedExecute.mockReturnValueOnce(
4294 Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))),
4295 );
4296 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4297
4298 const error = yield* Effect.flip(
4299 cli.getReviewThreadComments({
4300 cwd: "/w",
4301 repository: "acme/web",
4302 host: "github.com",
4303 number: 7,
4304 threadId: "PRRT_FOREIGN",
4305 cursor: "Y3Vyc29yOjI",
4306 }),
4307 );
4308
4309 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
4310 }),
4311 );
4312
4313 it.effect(
4314 "asks for the reader's standing on the repository and on the pull request at once",
4315 () =>
4316 Effect.gen(function* () {
4317 mockedExecute.mockReturnValue(
4318 Effect.succeed(
4319 output(
4320 encodeJson({
4321 data: {
4322 repository: {
4323 mergeCommitAllowed: true,
4324 squashMergeAllowed: false,
4325 rebaseMergeAllowed: true,
4326 viewerPermission: "READ",
4327 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4328 },
4329 },
4330 }),
4331 ),
4332 ),
4333 );
4334 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4335
4336 const access = yield* cli.getViewerAccess({
4337 cwd: "/w",
4338 repository: "acme/web",
4339 host: "github.com",
4340 number: 7,
4341 });
4342
4343 // One request, because both answers hang off the same repository object.
4344 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4345 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
4346 expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed");
4347 expect(access).toEqual({
4348 mergeCapabilities: { merge: true, squash: false, rebase: true },
4349 canWrite: false,
4350 canDeleteBranch: false,
4351 canTriage: false,
4352 canUpdate: true,
4353 didAuthor: true,
4354 });
4355 }),
4356 );
4357
4358 it.effect("stops GraphQL reads at the protected reserve until reset", () =>
4359 Effect.gen(function* () {
4360 mockedExecute.mockReturnValue(
4361 Effect.succeed(
4362 output(
4363 encodeJson({
4364 data: {
4365 repository: {
4366 mergeCommitAllowed: true,
4367 squashMergeAllowed: false,
4368 rebaseMergeAllowed: true,
4369 viewerPermission: "READ",
4370 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4371 },
4372 },
4373 }),
4374 false,
4375 false,
4376 graphqlQuota(499),
4377 ),
4378 ),
4379 );
4380 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4381 const input = {
4382 cwd: "/w",
4383 repository: "acme/web",
4384 host: "github.com",
4385 number: 7,
4386 } as const;
4387
4388 yield* cli.getViewerAccess(input);
4389
4390 const error = yield* Effect.flip(cli.getViewerAccess(input));
4391
4392 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");
4393 if (error._tag !== "SourceControlRateLimitPausedError") return;
4394 assert.strictEqual(error.host, "github.com");
4395 assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z"));
4396 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4397 yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z"));
4398 }),
4399 );
4400
4401 it.effect("lets an interactive permission read use the protected reserve", () =>
4402 Effect.gen(function* () {
4403 mockedExecute
4404 .mockReturnValueOnce(
4405 Effect.succeed(
4406 output(
4407 encodeJson({
4408 data: {
4409 repository: {
4410 mergeCommitAllowed: true,
4411 squashMergeAllowed: false,
4412 rebaseMergeAllowed: true,
4413 viewerPermission: "READ",
4414 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4415 },
4416 },
4417 }),
4418 false,
4419 false,
4420 graphqlQuota(499),
4421 ),
4422 ),
4423 )
4424 .mockReturnValueOnce(
4425 Effect.succeed(
4426 output(
4427 encodeJson({
4428 data: {
4429 repository: {
4430 mergeCommitAllowed: true,
4431 squashMergeAllowed: false,
4432 rebaseMergeAllowed: true,
4433 viewerPermission: "READ",
4434 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4435 },
4436 },
4437 }),
4438 ),
4439 ),
4440 );
4441 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4442
4443 yield* cli.getViewerAccess({
4444 cwd: "/w",
4445 repository: "acme/web",
4446 host: "github.com",
4447 number: 7,
4448 });
4449 const access = yield* cli.getViewerAccess({
4450 cwd: "/w",
4451 repository: "acme/web",
4452 host: "github.com",
4453 number: 7,
4454 allowReserve: true,
4455 });
4456
4457 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4458 expect(access).toEqual({
4459 mergeCapabilities: { merge: true, squash: false, rebase: true },
4460 canWrite: false,
4461 canDeleteBranch: false,
4462 canTriage: false,
4463 canUpdate: true,
4464 didAuthor: true,
4465 });
4466 yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z"));
4467 }),
4468 );
4469
4470 it.effect("asks GitHub to review, naming the collection a request is added to", () =>
4471 Effect.gen(function* () {
4472 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
4473 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4474
4475 yield* cli.setReviewerRequest({
4476 cwd: "/w",
4477 repository: "acme/web",
4478 host: "github.com",
4479 number: 7,
4480 reviewers: [
4481 { id: "octocat", kind: "user" },
4482 { id: "reviewers", kind: "team" },
4483 ],
4484 requested: true,
4485 });
4486
4487 expect(callAt(0)).toMatchObject({
4488 kind: "rest",
4489 method: "POST",
4490 path: "repos/acme/web/pulls/7/requested_reviewers",
4491 body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] },
4492 });
4493 }),
4494 );
4495
4496 it.effect("takes a request back by deleting from the same collection it was added to", () =>
4497 Effect.gen(function* () {
4498 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
4499 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4500
4501 yield* cli.setReviewerRequest({
4502 cwd: "/w",
4503 repository: "acme/web",
4504 host: "github.com",
4505 number: 7,
4506 reviewers: [{ id: "octocat", kind: "user" }],
4507 requested: false,
4508 });
4509
4510 expect(callAt(0)).toMatchObject({
4511 kind: "rest",
4512 method: "DELETE",
4513 path: "repos/acme/web/pulls/7/requested_reviewers",
4514 body: { reviewers: ["octocat"], team_reviewers: [] },
4515 });
4516 }),
4517 );
4518
4519 it.effect("reads who may review and who already has in one request", () =>
4520 Effect.gen(function* () {
4521 mockedExecute.mockReturnValue(
4522 Effect.succeed(
4523 output(
4524 encodeJson({
4525 data: {
4526 repository: {
4527 assignableUsers: {
4528 pageInfo: { hasNextPage: false },
4529 nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }],
4530 },
4531 pullRequest: {
4532 author: { login: "bilal" },
4533 reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] },
4534 },
4535 },
4536 },
4537 }),
4538 ),
4539 ),
4540 );
4541 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4542
4543 const list = yield* cli.listReviewerCandidates({
4544 cwd: "/w",
4545 repository: "acme/web",
4546 host: "github.com",
4547 number: 7,
4548 });
4549
4550 // The people, who has been asked and who opened the pull request all hang off the same
4551 // repository object, so the menu costs one request.
4552 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4553 expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 });
4554 expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([
4555 ["octocat", true],
4556 ["hubot", false],
4557 ]);
4558 }),
4559 );
4560
4561 it.effect("puts labels on by posting to the issue's own collection, all at once", () =>
4562 Effect.gen(function* () {
4563 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
4564 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4565
4566 yield* cli.setLabels({
4567 cwd: "/w",
4568 repository: "acme/web",
4569 host: "github.com",
4570 number: 7,
4571 labels: ["bug", "size:XL"],
4572 applied: true,
4573 });
4574
4575 assert.strictEqual(mockedExecute.mock.calls.length, 1);
4576 expect(callAt(0)).toMatchObject({
4577 kind: "rest",
4578 method: "POST",
4579 path: "repos/acme/web/issues/7/labels",
4580 body: { labels: ["bug", "size:XL"] },
4581 });
4582 }),
4583 );
4584
4585 it.effect("takes labels off one at a time, naming each in the path encoded", () =>
4586 Effect.gen(function* () {
4587 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
4588 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4589
4590 yield* cli.setLabels({
4591 cwd: "/w",
4592 repository: "acme/web",
4593 host: "github.com",
4594 number: 7,
4595 labels: ["good first issue", "area/web"],
4596 applied: false,
4597 });
4598
4599 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4600 expect(callAt(0)).toMatchObject({
4601 method: "DELETE",
4602 path: "repos/acme/web/issues/7/labels/good%20first%20issue",
4603 });
4604 expect(callAt(1)).toMatchObject({
4605 method: "DELETE",
4606 path: "repos/acme/web/issues/7/labels/area%2Fweb",
4607 });
4608 }),
4609 );
4610
4611 it.effect("reads every page of viewed files, and says so when there are too many", () =>
4612 Effect.gen(function* () {
4613 const page = (index: number, hasNextPage: boolean) =>
4614 Effect.succeed(
4615 output(
4616 encodeJson({
4617 data: {
4618 repository: {
4619 pullRequest: {
4620 files: {
4621 pageInfo: { hasNextPage, endCursor: `cursor-${index}` },
4622 nodes: [
4623 { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" },
4624 { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" },
4625 ],
4626 },
4627 },
4628 },
4629 },
4630 }),
4631 ),
4632 );
4633 mockedExecute
4634 .mockReturnValueOnce(page(0, true))
4635 .mockReturnValueOnce(page(1, true))
4636 .mockReturnValueOnce(page(2, false));
4637 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4638
4639 const viewed = yield* cli.getPullRequestFilesViewed({
4640 cwd: "/w",
4641 repository: "acme/web",
4642 host: "github.com",
4643 number: 7,
4644 });
4645
4646 assert.strictEqual(mockedExecute.mock.calls.length, 3);
4647 // The first page asks from the start; each one after it carries the cursor before it.
4648 expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([
4649 null,
4650 "cursor-0",
4651 "cursor-1",
4652 ]);
4653 assert.isFalse(viewed.truncated);
4654 expect(viewed.files.map((file) => [file.path, file.state])).toEqual([
4655 ["src/file0.ts", "viewed"],
4656 ["src/other0.ts", "unviewed"],
4657 ["src/file1.ts", "viewed"],
4658 ["src/other1.ts", "unviewed"],
4659 ["src/file2.ts", "viewed"],
4660 ["src/other2.ts", "unviewed"],
4661 ]);
4662 }),
4663 );
4664
4665 it.effect("stops paging viewed files rather than following a change without end", () =>
4666 Effect.gen(function* () {
4667 mockedExecute.mockReturnValue(
4668 Effect.succeed(
4669 output(
4670 encodeJson({
4671 data: {
4672 repository: {
4673 pullRequest: {
4674 files: {
4675 pageInfo: { hasNextPage: true, endCursor: "cursor" },
4676 nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }],
4677 },
4678 },
4679 },
4680 },
4681 }),
4682 ),
4683 ),
4684 );
4685 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4686
4687 const viewed = yield* cli.getPullRequestFilesViewed({
4688 cwd: "/w",
4689 repository: "acme/web",
4690 host: "github.com",
4691 number: 7,
4692 });
4693
4694 // The same cursor twice is a page GitHub already gave, so reading stops there.
4695 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4696 assert.isTrue(viewed.truncated);
4697 }),
4698 );
4699
4700 it.effect("clears and restores a burst of files in one request", () =>
4701 Effect.gen(function* () {
4702 mockedExecute
4703 .mockReturnValueOnce(
4704 Effect.succeed(
4705 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
4706 ),
4707 )
4708 .mockReturnValueOnce(Effect.succeed(output("{}")));
4709 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4710
4711 yield* cli.setPullRequestFilesViewed({
4712 cwd: "/w",
4713 repository: "acme/web",
4714 host: "github.com",
4715 number: 23,
4716 files: [
4717 { path: "src/a.ts", viewed: true },
4718 { path: "src/b.ts", viewed: false },
4719 ],
4720 });
4721
4722 // One request to learn the pull request's node id, one for every press together.
4723 assert.strictEqual(mockedExecute.mock.calls.length, 2);
4724 expect(queryAt(1)).toContain("f0: markFileAsViewed");
4725 expect(queryAt(1)).toContain("f1: unmarkFileAsViewed");
4726 expect(varsAt(1)).toEqual({
4727 pullRequestId: "PR_1",
4728 f0_path: "src/a.ts",
4729 f1_path: "src/b.ts",
4730 });
4731 }),
4732 );
4733
4734 it.effect("asks the host nothing when nothing was pressed", () =>
4735 Effect.gen(function* () {
4736 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4737
4738 yield* cli.setPullRequestFilesViewed({
4739 cwd: "/w",
4740 repository: "acme/web",
4741 host: "github.com",
4742 number: 7,
4743 files: [],
4744 });
4745
4746 assert.strictEqual(mockedExecute.mock.calls.length, 0);
4747 }),
4748 );
4749
4750 it.effect("looks a pull request's node id up once, however often it is written to", () =>
4751 Effect.gen(function* () {
4752 mockedExecute
4753 .mockReturnValueOnce(
4754 Effect.succeed(
4755 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })),
4756 ),
4757 )
4758 .mockReturnValue(Effect.succeed(output("{}")));
4759 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4760 const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 };
4761
4762 yield* cli.setPullRequestFilesViewed({
4763 ...pullRequest,
4764 files: [{ path: "src/a.ts", viewed: true }],
4765 });
4766 yield* cli.setPullRequestFilesViewed({
4767 ...pullRequest,
4768 files: [{ path: "src/b.ts", viewed: true }],
4769 });
4770 yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" });
4771
4772 // One lookup, then a mutation per write, every one of them addressed by the id it answered.
4773 assert.strictEqual(mockedExecute.mock.calls.length, 4);
4774 expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 });
4775 expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([
4776 "PR_24",
4777 "PR_24",
4778 "PR_24",
4779 ]);
4780 }),
4781 );
4782
4783 it.effect("does not remember a node id lookup that failed", () =>
4784 Effect.gen(function* () {
4785 mockedExecute
4786 .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')))
4787 .mockReturnValueOnce(
4788 Effect.succeed(
4789 output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })),
4790 ),
4791 )
4792 .mockReturnValueOnce(Effect.succeed(output("{}")));
4793 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4794 const write = () =>
4795 cli.setPullRequestFilesViewed({
4796 cwd: "/w",
4797 repository: "acme/web",
4798 host: "github.com",
4799 number: 25,
4800 files: [{ path: "src/a.ts", viewed: true }],
4801 });
4802
4803 const error = yield* Effect.flip(write());
4804 assert.strictEqual(error._tag, "GitHubPullRequestReadError");
4805
4806 yield* write();
4807
4808 assert.strictEqual(mockedExecute.mock.calls.length, 3);
4809 expect(varsAt(2)["pullRequestId"]).toEqual("PR_25");
4810 }),
4811 );
4812 it.effect("keeps the pull request being ticked through, not the one looked up first", () =>
4813 Effect.gen(function* () {
4814 // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is
4815 // working down is the first thing evicted once a listing has walked a cache's worth of cold
4816 // pull requests, and every press after that pays a round trip again.
4817 // This block shares one cache, so these numbers are its own and it runs last.
4818 const HOT = 9_000;
4819 const lookupsOf = new Map<number, number>();
4820 mockedExecute.mockImplementation((input) => {
4821 if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) {
4822 return Effect.succeed(output("{}"));
4823 }
4824 const number = Number(input.variables?.["number"]);
4825 lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1);
4826 return Effect.succeed(
4827 output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })),
4828 );
4829 });
4830 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
4831 const tick = (number: number) =>
4832 cli.setPullRequestFilesViewed({
4833 cwd: "/w",
4834 repository: "acme/web",
4835 host: "github.com",
4836 number,
4837 files: [{ path: "src/a.ts", viewed: true }],
4838 });
4839
4840 yield* tick(HOT);
4841 // A cache's worth of cold pull requests, with the open one pressed in between each of them.
4842 for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) {
4843 yield* tick(HOT + 1 + filled);
4844 yield* tick(HOT);
4845 }
4846
4847 assert.strictEqual(lookupsOf.get(HOT), 1);
4848 }),
4849 );
4850});