1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5import * as Result from "effect/Result";
6import type { PullRequestReaction } from "@t3tools/contracts";
7
8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubApi from "./GitHubApi.ts";
10import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
13import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
14import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
15
16const coreFields = {
17 reviewRequestTeams: [],
18 checksTruncated: false,
19 comparison: { behindBy: 0, viewerCanUpdate: true },
20 viewerAccess: {
21 canWrite: true,
22 canTriage: true,
23 canUpdate: true,
24 didAuthor: false,
25 mergeCapabilities: { merge: true, squash: true, rebase: true },
26 },
27};
28
29it.effect("maps credential verification failures without relabeling operation failures", () =>
30 Effect.gen(function* () {
31 let verificationFails = true;
32 let operations = 0;
33 const provider = yield* make.pipe(
34 Effect.provide(
35 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
36 revalidateChecks: (_input, read) => read,
37 withVerifiedCredential: (_input, use) =>
38 verificationFails
39 ? Effect.fail(
40 new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({
41 cwd: "/w",
42 }),
43 )
44 : use({ accountId: "123", viewer: "viewer", credentialFingerprint: "fingerprint" }),
45 }),
46 ),
47 );
48 const verify = provider.withVerifiedCredential;
49 if (verify === undefined)
50 return yield* Effect.die("credential verification was not implemented");
51 const input = { cwd: "/w", host: "github.com" };
52 const operation = () =>
53 Effect.sync(() => {
54 operations++;
55 }).pipe(Effect.andThen(Effect.fail("operation-failed")));
56 expect(yield* verify(input, operation).pipe(Effect.flip)).toMatchObject({
57 _tag: "PullRequestProviderError",
58 operation: "routeIdentity",
59 });
60 expect(operations).toBe(0);
61 verificationFails = false;
62 expect(yield* verify(input, operation).pipe(Effect.flip)).toBe("operation-failed");
63 expect(operations).toBe(1);
64 }),
65);
66
67it.effect("advertises standalone branch deletion for source repository writers", () =>
68 Effect.gen(function* () {
69 let action: string | undefined;
70 const provider = yield* make.pipe(
71 Effect.provide(
72 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
73 runPullRequestAction: (input) =>
74 Effect.sync(() => {
75 action = input.action;
76 }),
77 }),
78 ),
79 );
80 expect(provider.capabilities.deleteBranch).toBe(true);
81 expect(provider.capabilities.actions).toContain("delete-branch");
82 yield* provider.runAction({
83 cwd: "/w",
84 repository: "acme/web",
85 host: "github.com",
86 number: 7,
87 action: "delete-branch",
88 });
89 expect(action).toBe("delete-branch");
90 const access = {
91 canWrite: false,
92 canTriage: false,
93 canUpdate: false,
94 didAuthor: false,
95 canDeleteBranch: true,
96 };
97 expect(gitHubViewerPermissions(access).actions).toContain("delete-branch");
98 expect(
99 gitHubViewerPermissions({
100 ...access,
101 canWrite: true,
102 didAuthor: true,
103 canDeleteBranch: false,
104 }).actions,
105 ).not.toContain("delete-branch");
106 }),
107);
108
109it.effect("refreshes checks without permissions or comparison reads", () =>
110 Effect.gen(function* () {
111 let reads = 0;
112 const snapshot = Result.getOrThrow(
113 decodePullRequestDetailJson(`{
114 "number": 7, "title": "Checks", "url": "https://github.com/acme/web/pull/7",
115 "headRefName": "feature", "baseRefName": "main", "state": "OPEN",
116 "createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z"
117 }`),
118 );
119 const provider = yield* make.pipe(
120 Effect.provide(
121 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
122 revalidateChecks: (_input, read) => read,
123 getPullRequestDetail: () =>
124 Effect.sync(() => {
125 reads++;
126 return {
127 ...snapshot,
128 ...coreFields,
129 state: reads === 3 ? ("merged" as const) : ("open" as const),
130 checks: [
131 {
132 name: "build",
133 status: reads === 1 ? ("pending" as const) : ("success" as const),
134 description: null,
135 url: null,
136 },
137 ],
138 };
139 }),
140 }),
141 ),
142 );
143 const read = provider.getChangeRequestChecks;
144 if (read === undefined) return yield* Effect.die("checks read missing");
145 for (let tick = 1; tick <= 3; tick++) {
146 const result = yield* read({
147 cwd: "/w",
148 repository: "acme/web",
149 host: "github.com",
150 number: 7,
151 });
152 expect(result.checks[0]?.status).toBe(tick === 1 ? "pending" : "success");
153 expect(result.state).toBe(tick === 3 ? "merged" : "open");
154 }
155 expect(reads).toBe(3);
156 }),
157);
158
159it.effect("uses one narrow read for a linked pull request summary", () =>
160 Effect.gen(function* () {
161 let summaryReads = 0;
162 const provider = yield* make.pipe(
163 Effect.provide(
164 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
165 revalidateChecks: (_input, read) => read,
166 getPullRequestSummary: () =>
167 Effect.sync(() => {
168 summaryReads += 1;
169 return {
170 number: 7,
171 title: "Summary",
172 url: "https://github.com/acme/web/pull/7",
173 headBranch: "feat/summary",
174 baseBranch: "main",
175 state: "open" as const,
176 updatedAt: "2026-08-24T12:34:56.000Z",
177 author: { login: "octocat", name: null, avatarUrl: null },
178 };
179 }),
180 }),
181 ),
182 );
183
184 const readSummary = provider.getChangeRequestSummary;
185 if (readSummary === undefined) return yield* Effect.die("summary read was not implemented");
186 const summary = yield* readSummary({
187 cwd: "/w",
188 repository: "acme/web",
189 host: "github.com",
190 number: 7,
191 });
192
193 expect(summary.state).toBe("open");
194 expect(summaryReads).toBe(1);
195 // The author's avatar comes from the login-shaped URL, not a second request.
196 expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80");
197 }),
198);
199
200it.effect("declares host-native stacks and passes the one the CLI reads through", () =>
201 Effect.gen(function* () {
202 const stack = {
203 id: "42",
204 number: 3,
205 url: "https://github.com/acme/web/stacks/3",
206 base: "main",
207 layers: [
208 { number: 6, headBranch: "feat/one", state: "merged" as const },
209 { number: 7, headBranch: "feat/two", state: "open" as const },
210 ],
211 };
212 const provider = yield* make.pipe(
213 Effect.provide(
214 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
215 revalidateChecks: (_input, read) => read,
216 getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null),
217 }),
218 ),
219 );
220
221 expect(provider.capabilities.stacks).toBe(true);
222 const readStack = provider.getChangeRequestStack;
223 if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
224 const ref = { cwd: "/w", repository: "acme/web", host: "github.com" };
225 expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack);
226 expect(yield* readStack({ ...ref, number: 8 })).toBeNull();
227 }),
228);
229
230it.effect("reports a failed stack read against its own operation", () =>
231 Effect.gen(function* () {
232 const provider = yield* make.pipe(
233 Effect.provide(
234 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
235 revalidateChecks: (_input, read) => read,
236 getPullRequestStack: () =>
237 Effect.fail(
238 new GitHubPullRequestApi.GitHubPullRequestReadError({
239 cwd: "/w",
240 operation: "getPullRequestStack",
241 cause: new Error("unreadable"),
242 }),
243 ),
244 }),
245 ),
246 );
247
248 const readStack = provider.getChangeRequestStack;
249 if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
250 const error = yield* Effect.flip(
251 readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }),
252 );
253
254 expect(error.operation).toBe("getChangeRequestStack");
255 expect(error.reason).toBe("failed");
256 }),
257);
258
259describe("gitHubViewerPermissions", () => {
260 it("offers everything to a viewer who can write to the repository", () => {
261 expect(
262 gitHubViewerPermissions({
263 canWrite: true,
264 canDeleteBranch: true,
265 canTriage: true,
266 canUpdate: true,
267 didAuthor: false,
268 }),
269 ).toEqual({
270 // Arming a merge for later is the merge, so it travels with it.
271 actions: [
272 "delete-branch",
273 "merge",
274 "enable-auto-merge",
275 "disable-auto-merge",
276 "revert",
277 "approve-workflows",
278 "ready",
279 "draft",
280 "close",
281 "reopen",
282 ],
283 comment: true,
284 resolve: true,
285 stackRebase: true,
286 verdicts: ["comment", "approve", "request-changes"],
287 requestReviewers: true,
288 labels: true,
289 });
290 });
291
292 it("leaves a passer-by on a repository they can only read nothing but the review", () => {
293 // Every open-source pull request somebody else opened: GitHub says no to all five actions
294 // and to resolving, and yes to commenting and to every verdict.
295 expect(
296 gitHubViewerPermissions({
297 canWrite: false,
298 canTriage: false,
299 canUpdate: false,
300 didAuthor: false,
301 }),
302 ).toEqual({
303 actions: [],
304 comment: true,
305 resolve: false,
306 verdicts: ["comment", "approve", "request-changes"],
307 // Asking somebody else to review is the one thing read access never stretches to.
308 requestReviewers: false,
309 labels: false,
310 });
311 });
312
313 it("lets a triager label without letting them merge or ask for a review", () => {
314 const permissions = gitHubViewerPermissions({
315 canWrite: false,
316 canTriage: true,
317 canUpdate: false,
318 didAuthor: false,
319 });
320 expect(permissions.labels).toBe(true);
321 expect(permissions.requestReviewers).toBe(false);
322 expect(permissions.actions).toEqual([]);
323 });
324
325 it("keeps an author's own pull request theirs to close, with read access and no more", () => {
326 expect(
327 gitHubViewerPermissions({
328 canWrite: false,
329 canTriage: false,
330 canUpdate: true,
331 didAuthor: true,
332 }),
333 ).toEqual({
334 // Merging is the one thing writing is needed for, now or later; the rest an author may do.
335 actions: ["ready", "draft", "close", "reopen"],
336 comment: true,
337 resolve: true,
338 // GitHub refuses an author's approval of their own change, so the page does not offer one.
339 verdicts: ["comment"],
340 requestReviewers: false,
341 labels: false,
342 });
343 });
344
345 it.effect("uses the permissions carried by the core read without a second request", () =>
346 Effect.gen(function* () {
347 const provider = yield* make;
348 const detail = yield* provider.getChangeRequest({
349 cwd: "/w",
350 repository: "acme/web",
351 host: "github.com",
352 number: 7,
353 });
354
355 expect(detail.viewerPermissions).toEqual({
356 actions: ["ready", "draft", "close", "reopen"],
357 comment: true,
358 resolve: false,
359 verdicts: ["comment", "approve", "request-changes"],
360 requestReviewers: false,
361 labels: false,
362 });
363 expect(detail.workflowApprovalsRequired).toBeUndefined();
364 expect(detail.checks).toContainEqual({
365 name: "Workflow approval status",
366 status: "action-required",
367 description: "GitHub could not determine whether workflows are awaiting approval.",
368 url: null,
369 });
370 for (const fingerprint of ["broad", "restricted", "broad"]) {
371 const scoped = yield* provider
372 .getChangeRequest({
373 cwd: "/w",
374 repository: "acme/web",
375 host: "github.com",
376 number: 7,
377 })
378 .pipe(
379 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
380 host: "github.com",
381 token: Redacted.make("credential"),
382 credentialFingerprint: fingerprint,
383 }),
384 );
385 expect(scoped.mergeCapabilities.squash).toBe(fingerprint !== "restricted");
386 }
387 }).pipe(
388 Effect.provide(
389 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
390 revalidateChecks: (_input, read) => read,
391 getPullRequestDetail: () =>
392 Effect.succeed({
393 ...coreFields,
394 comparison: null,
395 viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false },
396 authorId: null,
397 number: 7,
398 title: "Pull request 7",
399 url: "https://github.com/acme/web/pull/7",
400 author: null,
401 isCrossRepository: true,
402 headRepositoryOwner: null,
403 headBranch: "feat/page",
404 baseBranch: "main",
405 state: "open" as const,
406 isDraft: false,
407 mergeability: "mergeable" as const,
408 reviewDecision: null,
409 additions: 1,
410 deletions: 1,
411 createdAt: "2026-07-01T00:00:00Z",
412 updatedAt: "2026-07-02T00:00:00Z",
413 reviewRequestLogins: [],
414 hasTeamReviewRequest: false,
415 checksState: null,
416 labels: [],
417 body: "",
418 changedFiles: 1,
419 mergedAt: null,
420 closedAt: null,
421 checks: [],
422 comments: [],
423 commits: [],
424 }).pipe(
425 Effect.flatMap((detail) =>
426 GitHubApi.PinnedGitHubCredential.pipe(
427 Effect.map((credential) => ({
428 ...detail,
429 viewerAccess: {
430 ...detail.viewerAccess,
431 mergeCapabilities: {
432 ...detail.viewerAccess.mergeCapabilities,
433 squash: credential?.credentialFingerprint !== "restricted",
434 },
435 },
436 })),
437 ),
438 ),
439 ),
440 }),
441 ),
442 ),
443 );
444
445 it.effect("keeps fork workflows awaiting approval out of the passing state", () =>
446 Effect.gen(function* () {
447 const provider = yield* make;
448 const detail = yield* provider.getChangeRequest({
449 cwd: "/w",
450 repository: "acme/web",
451 host: "github.com",
452 number: 7,
453 });
454
455 const readChecks = provider.getChangeRequestChecks;
456 if (readChecks === undefined) return yield* Effect.die("checks read missing");
457 expect(
458 yield* readChecks({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }),
459 ).toEqual({ state: detail.state, checks: detail.checks });
460 expect(detail.workflowApprovalsRequired).toBe(1);
461 expect(detail.checks).toEqual([
462 {
463 name: "manual gate",
464 status: "action-required",
465 description: null,
466 url: "https://example.com/manual-gate",
467 },
468 {
469 name: "build",
470 status: "success",
471 description: null,
472 url: null,
473 },
474 {
475 name: "contributor tests",
476 status: "action-required",
477 description: "A maintainer must approve this workflow before it can run.",
478 url: "https://github.com/acme/web/actions/runs/123",
479 },
480 ]);
481 }).pipe(
482 Effect.provide(
483 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
484 revalidateChecks: (_input, read) => read,
485 getPullRequestDetail: () =>
486 Effect.succeed({
487 ...coreFields,
488 authorId: null,
489 number: 7,
490 title: "Pull request 7",
491 url: "https://github.com/acme/web/pull/7",
492 author: null,
493 isCrossRepository: true,
494 headRepositoryOwner: "octocat",
495 headSha: "abc123",
496 headBranch: "feat/page",
497 baseBranch: "main",
498 state: "open",
499 isDraft: false,
500 mergeability: "mergeable",
501 reviewDecision: null,
502 additions: 1,
503 deletions: 1,
504 createdAt: "2026-07-01T00:00:00Z",
505 updatedAt: "2026-07-02T00:00:00Z",
506 reviewRequestLogins: [],
507 hasTeamReviewRequest: false,
508 checksState: "passing",
509 labels: [],
510 body: "",
511 changedFiles: 1,
512 mergedAt: null,
513 closedAt: null,
514 checks: [
515 {
516 name: "manual gate",
517 status: "action-required",
518 description: null,
519 url: "https://example.com/manual-gate",
520 },
521 { name: "build", status: "success", description: null, url: null },
522 ],
523 comments: [],
524 commits: [],
525 }),
526 listWorkflowRunsRequiringApproval: () =>
527 Effect.succeed([
528 {
529 id: 123,
530 name: "contributor tests",
531 url: "https://github.com/acme/web/actions/runs/123",
532 },
533 ]),
534 getViewerAccess: () =>
535 Effect.succeed({
536 canWrite: true,
537 canTriage: true,
538 canUpdate: true,
539 didAuthor: false,
540 mergeCapabilities: { merge: true, squash: true, rebase: true },
541 }),
542 }),
543 ),
544 ),
545 );
546});
547
548const openDetail = {
549 ...coreFields,
550 authorId: null,
551 number: 7,
552 title: "Pull request 7",
553 url: "https://github.com/acme/web/pull/7",
554 author: null,
555 isCrossRepository: true,
556 headRepositoryOwner: "acme",
557 headSha: "abc123",
558 headBranch: "feat/page",
559 baseBranch: "main",
560 state: "open" as const,
561 isDraft: false,
562 mergeability: "mergeable" as const,
563 reviewDecision: null,
564 additions: 1,
565 deletions: 1,
566 createdAt: "2026-07-01T00:00:00Z",
567 updatedAt: "2026-07-02T00:00:00Z",
568 reviewRequestLogins: [],
569 hasTeamReviewRequest: false,
570 checksState: null,
571 labels: [],
572 body: "",
573 changedFiles: 1,
574 mergedAt: null,
575 closedAt: null,
576 checks: [],
577 comments: [],
578 commits: [],
579};
580
581it.effect(
582 "uses the core comparison and permissions while preserving workflow approval checks",
583 () =>
584 Effect.gen(function* () {
585 const provider = yield* make.pipe(
586 Effect.provide(
587 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
588 getPullRequestDetail: () =>
589 Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }),
590 listWorkflowRunsRequiringApproval: () =>
591 Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]),
592 }),
593 ),
594 );
595
596 const detail = yield* provider.getChangeRequest({
597 cwd: "/w",
598 repository: "acme/web",
599 host: "github.com",
600 number: 7,
601 });
602
603 expect(detail.behindBy).toBe(2);
604 expect(detail.workflowApprovalsRequired).toBe(1);
605 expect(detail.checks).toContainEqual({
606 name: "tests",
607 status: "action-required",
608 description: "A maintainer must approve this workflow before it can run.",
609 url: "https://example.com/runs/123",
610 });
611 }),
612);
613
614it.effect("does not classify same-repository gates as fork workflow approvals", () =>
615 Effect.gen(function* () {
616 const provider = yield* make;
617 const detail = yield* provider.getChangeRequest({
618 cwd: "/w",
619 repository: "acme/web",
620 host: "github.com",
621 number: 7,
622 });
623
624 expect(detail.workflowApprovalsRequired).toBe(0);
625 expect(detail.checks).toEqual([]);
626 }).pipe(
627 Effect.provide(
628 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
629 revalidateChecks: (_input, read) => read,
630 getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }),
631 listWorkflowRunsRequiringApproval: () =>
632 Effect.die("same-repository pull requests must not probe fork workflow approvals"),
633 getViewerAccess: () =>
634 Effect.succeed({
635 canWrite: true,
636 canTriage: true,
637 canUpdate: true,
638 didAuthor: false,
639 mergeCapabilities: { merge: true, squash: true, rebase: true },
640 }),
641 }),
642 ),
643 ),
644);
645
646it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
647 Effect.gen(function* () {
648 const provider = yield* make;
649 const detail = yield* provider.getChangeRequest({
650 cwd: "/w",
651 repository: "acme/web",
652 host: "github.com",
653 number: 7,
654 });
655
656 expect(detail.workflowApprovalsRequired).toBeUndefined();
657 expect(detail.checks).toEqual([
658 {
659 name: "Workflow approval status",
660 status: "action-required",
661 description: "GitHub could not determine whether workflows are awaiting approval.",
662 url: null,
663 },
664 ]);
665 }).pipe(
666 Effect.provide(
667 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
668 revalidateChecks: (_input, read) => read,
669 getPullRequestDetail: () => Effect.succeed(openDetail),
670 listWorkflowRunsRequiringApproval: () =>
671 Effect.fail(
672 new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({
673 cwd: "/w",
674 number: 7,
675 reason: "head-not-unique",
676 observedCount: 2,
677 limit: 1_000,
678 }),
679 ),
680 getViewerAccess: () =>
681 Effect.succeed({
682 canWrite: true,
683 canTriage: true,
684 canUpdate: true,
685 didAuthor: false,
686 mergeCapabilities: { merge: true, squash: true, rebase: true },
687 }),
688 }),
689 ),
690 ),
691);
692
693it.effect("propagates workflow discovery rate limits", () =>
694 Effect.gen(function* () {
695 const provider = yield* make;
696 const error = yield* provider
697 .getChangeRequest({
698 cwd: "/w",
699 repository: "acme/web",
700 host: "github.com",
701 number: 7,
702 })
703 .pipe(Effect.flip);
704
705 expect(error.operation).toBe("getChangeRequest");
706 expect(error.reason).toBe("rate-limited");
707 }).pipe(
708 Effect.provide(
709 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
710 revalidateChecks: (_input, read) => read,
711 getPullRequestDetail: () => Effect.succeed(openDetail),
712 listWorkflowRunsRequiringApproval: () =>
713 Effect.fail(
714 new GitHubApi.GitHubApiRateLimitError({
715 host: "github.com",
716 operation: "listWorkflowRunsRequiringApproval",
717 }),
718 ),
719 getViewerAccess: () =>
720 Effect.succeed({
721 canWrite: true,
722 canTriage: true,
723 canUpdate: true,
724 didAuthor: false,
725 mergeCapabilities: { merge: true, squash: true, rebase: true },
726 }),
727 }),
728 ),
729 ),
730);
731
732describe("getViewerPermissions", () => {
733 const access = {
734 canWrite: true,
735 canTriage: true,
736 canUpdate: true,
737 didAuthor: false,
738 mergeCapabilities: { merge: true, squash: true, rebase: true },
739 };
740
741 it.effect("checks fresh access without reading branch details for unrelated operations", () => {
742 let accessReads = 0;
743 return Effect.gen(function* () {
744 const provider = yield* make;
745 const permissions = yield* provider.getViewerPermissions({
746 cwd: "/w",
747 repository: "acme/web",
748 host: "github.com",
749 number: 7,
750 includeUpdateBranch: false,
751 });
752
753 expect(accessReads).toBe(1);
754 expect(permissions.actions).toContain("merge");
755 expect(permissions.actions).not.toContain("update-branch");
756 }).pipe(
757 Effect.provide(
758 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
759 revalidateChecks: (_input, read) => read,
760 getPullRequestDetail: () => Effect.die("Unexpected detail read"),
761 getViewerAccess: () => Effect.sync(() => (accessReads++, access)),
762 }),
763 ),
764 );
765 });
766
767 const layerWithDetail = (
768 detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApi.GitHubPullRequestApiError>,
769 onDetail: (allowReserve: boolean) => void = () => {},
770 ) =>
771 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
772 revalidateChecks: (_input, read) => read,
773 getPullRequestDetail: () =>
774 SourceControlRateLimit.Interactive.pipe(
775 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),
776 Effect.flatMap(() => detail),
777 ),
778 getViewerAccess: () => Effect.die("Unexpected access read"),
779 });
780
781 it.effect("answers access and update-branch from the one detail read", () =>
782 Effect.gen(function* () {
783 const provider = yield* make;
784 const permissions = yield* provider.getViewerPermissions({
785 cwd: "/w",
786 repository: "acme/web",
787 host: "github.com",
788 number: 7,
789 });
790
791 expect(permissions.actions).toContain("merge");
792 expect(permissions.actions).toContain("update-branch");
793 expect(permissions.updateMethods).toEqual(["merge", "rebase"]);
794 }).pipe(
795 Effect.provide(
796 layerWithDetail(
797 Effect.succeed({ ...openDetail, comparison: { behindBy: 3, viewerCanUpdate: true } }),
798 ),
799 ),
800 ),
801 );
802
803 it.effect("uses the GraphQL reserve for manual permission checks", () => {
804 let allowed: boolean | undefined;
805 return Effect.gen(function* () {
806 const provider = yield* make;
807 yield* provider.getViewerPermissions({
808 cwd: "/w",
809 repository: "acme/web",
810 host: "github.com",
811 number: 7,
812 });
813 expect(allowed).toBe(true);
814 }).pipe(
815 Effect.provide(
816 layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)),
817 ),
818 );
819 });
820
821 it.effect("still answers from the light access read when the detail read fails", () =>
822 Effect.gen(function* () {
823 const provider = yield* make;
824 const permissions = yield* provider.getViewerPermissions({
825 cwd: "/w",
826 repository: "acme/web",
827 host: "github.com",
828 number: 7,
829 });
830 expect(permissions.actions).toContain("merge");
831 expect(permissions.actions).not.toContain("update-branch");
832 }).pipe(
833 Effect.provide(
834 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
835 revalidateChecks: (_input, read) => read,
836 getPullRequestDetail: () =>
837 Effect.fail(
838 new GitHubPullRequestApi.GitHubPullRequestReadError({
839 cwd: "/w",
840 operation: "getPullRequestDetail",
841 cause: new Error("head changed"),
842 }),
843 ),
844 getViewerAccess: () => Effect.succeed(access),
845 }),
846 ),
847 ),
848 );
849
850 it.effect("withholds update-branch where GitHub could not compare the branch", () =>
851 Effect.gen(function* () {
852 const provider = yield* make;
853 const permissions = yield* provider.getViewerPermissions({
854 cwd: "/w",
855 repository: "acme/web",
856 host: "github.com",
857 number: 7,
858 });
859
860 expect(permissions.actions).not.toContain("update-branch");
861 expect(permissions.updateMethods).toBeUndefined();
862 expect(permissions.actions).toContain("merge");
863 }).pipe(Effect.provide(layerWithDetail(Effect.succeed({ ...openDetail, comparison: null })))),
864 );
865});
866
867describe("getChangeRequest commits", () => {
868 const baseDetail = {
869 authorId: null,
870 number: 7,
871 title: "Pull request 7",
872 url: "https://github.com/acme/web/pull/7",
873 author: null,
874 headBranch: "feat/page",
875 baseBranch: "main",
876 state: "open" as const,
877 isDraft: false,
878 mergeability: "mergeable" as const,
879 additions: 1,
880 deletions: 1,
881 createdAt: "2026-07-01T00:00:00Z",
882 updatedAt: "2026-07-02T00:00:00Z",
883 reviewRequestLogins: [],
884 hasTeamReviewRequest: false,
885 checksState: null,
886 labels: [],
887 body: "",
888 changedFiles: 1,
889 mergedAt: null,
890 closedAt: null,
891 checks: [],
892 comments: [],
893 };
894
895 const baseThreadComments = {
896 comments: [],
897 dismissalsByReviewId: new Map<string, string>(),
898 reviewThreads: [],
899 commentCount: 0,
900 truncated: false,
901 reviewThreadsTruncated: false,
902 reactions: [],
903 reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),
904 editedAtById: new Map<string, string>(),
905 reviewers: [],
906 avatarsByLogin: new Map<string, string>(),
907 botLogins: new Set<string>(),
908 commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(),
909 viewer: { canUpdate: true, didAuthor: false },
910 };
911
912 const layerWith = (commits: GitHubReviewThreadComments["commits"]) =>
913 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
914 revalidateChecks: (_input, read) => read,
915 getPullRequestActivity: () =>
916 Effect.succeed({
917 author: baseDetail.author,
918 comments: baseDetail.comments,
919 commits: [
920 {
921 oid: "view-oldest",
922 messageHeadline: "gh pr view's oldest commit",
923 committedDate: "2026-01-01T00:00:00Z",
924 authors: [],
925 },
926 ],
927 }),
928 listReviewThreadComments: () => Effect.succeed({ ...baseThreadComments, commits }),
929 });
930
931 it.effect("prefers the GraphQL commits, which are the newest, over the gh view list", () =>
932 Effect.gen(function* () {
933 const provider = yield* make;
934 const detail = yield* provider.getChangeRequestActivity({
935 cwd: "/w",
936 repository: "acme/web",
937 host: "github.com",
938 number: 7,
939 });
940
941 expect(detail.commits.map((commit) => commit.oid)).toEqual(["graphql-newest"]);
942 }).pipe(
943 Effect.provide(
944 layerWith([
945 {
946 oid: "graphql-newest",
947 messageHeadline: "the newest commit gh pr view drops",
948 committedDate: "2026-07-06T00:00:00Z",
949 authors: [],
950 },
951 ]),
952 ),
953 ),
954 );
955
956 it.effect("falls back to the gh view list when the GraphQL read has no commits", () =>
957 Effect.gen(function* () {
958 const provider = yield* make;
959 const detail = yield* provider.getChangeRequestActivity({
960 cwd: "/w",
961 repository: "acme/web",
962 host: "github.com",
963 number: 7,
964 });
965
966 expect(detail.commits.map((commit) => commit.oid)).toEqual(["view-oldest"]);
967 }).pipe(Effect.provide(layerWith([]))),
968 );
969});
970
971describe("getChangeRequestActivity dismissed reviews", () => {
972 const dismissedReview = (body: string) => ({
973 id: "PRR_1",
974 kind: "review" as const,
975 author: { login: "macroscopeapp", name: null, avatarUrl: null },
976 body,
977 createdAt: "2026-07-03T00:00:00Z",
978 url: null,
979 path: null,
980 reviewState: "DISMISSED",
981 });
982 const threadComments: GitHubReviewThreadComments = {
983 comments: [],
984 dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]),
985 reviewThreads: [],
986 commentCount: 0,
987 truncated: false,
988 reviewThreadsTruncated: false,
989 reactions: [],
990 reactionsById: new Map(),
991 editedAtById: new Map([["PRR_1", "2026-07-04T00:00:00Z"]]),
992 reviewers: [],
993 avatarsByLogin: new Map(),
994 botLogins: new Set(["macroscopeapp"]),
995 commitStats: new Map(),
996 commits: [],
997 viewer: { canUpdate: true, didAuthor: false },
998 };
999 const layerFor = (body: string) =>
1000 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
1001 revalidateChecks: (_input, read) => read,
1002 getPullRequestActivity: () =>
1003 Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }),
1004 listReviewThreadComments: () => Effect.succeed(threadComments),
1005 });
1006 const readActivity = Effect.gen(function* () {
1007 const provider = yield* make;
1008 return yield* provider.getChangeRequestActivity({
1009 cwd: "/w",
1010 repository: "acme/web",
1011 host: "github.com",
1012 number: 7,
1013 });
1014 });
1015
1016 it.effect("fills a marker-only dismissed review with the timeline's reason", () =>
1017 // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing —
1018 // an empty-string check misses them and the card opens onto nothing.
1019 readActivity.pipe(
1020 Effect.map((activity) => {
1021 expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581");
1022 expect(activity.comments[0]?.author?.isBot).toBe(true);
1023 expect(activity.comments[0]?.editedAt).toBe("2026-07-04T00:00:00Z");
1024 }),
1025 Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")),
1026 ),
1027 );
1028
1029 it.effect("keeps the words of a dismissed review that has its own", () =>
1030 readActivity.pipe(
1031 Effect.map((activity) => {
1032 expect(activity.comments[0]?.body).toBe("These findings still stand.");
1033 }),
1034 Effect.provide(layerFor("These findings still stand.")),
1035 ),
1036 );
1037});
1038
1039describe("editing", () => {
1040 const rewrites: Array<unknown> = [];
1041
1042 it.effect("hands a rewrite to the CLI as the request named it", () =>
1043 Effect.gen(function* () {
1044 const provider = yield* make;
1045
1046 expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true });
1047 yield* provider.updateChangeRequest!({
1048 cwd: "/w",
1049 repository: "acme/web",
1050 host: "github.com",
1051 number: 7,
1052 title: "A better title",
1053 });
1054 yield* provider.updateComment!({
1055 cwd: "/w",
1056 repository: "acme/web",
1057 host: "github.com",
1058 number: 7,
1059 commentId: "IC_1",
1060 kind: "review-comment",
1061 body: "Reworded.",
1062 });
1063
1064 expect(rewrites).toEqual([
1065 {
1066 cwd: "/w",
1067 repository: "acme/web",
1068 host: "github.com",
1069 number: 7,
1070 title: "A better title",
1071 },
1072 {
1073 cwd: "/w",
1074 repository: "acme/web",
1075 host: "github.com",
1076 number: 7,
1077 commentId: "IC_1",
1078 kind: "review-comment",
1079 body: "Reworded.",
1080 },
1081 ]);
1082 }).pipe(
1083 Effect.provide(
1084 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
1085 revalidateChecks: (_input, read) => read,
1086 updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)),
1087 updateComment: (input) => Effect.sync(() => void rewrites.push(input)),
1088 }),
1089 ),
1090 ),
1091 );
1092});
1093
1094describe("loginAvatarUrl", () => {
1095 it("serves a user's picture from the host they belong to", () => {
1096 expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80");
1097 expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe(
1098 "https://ghe.example.com/octocat.png?size=80",
1099 );
1100 });
1101
1102 it("has nothing for an app, which names no page", () => {
1103 // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is
1104 // worse than the initials it would replace.
1105 expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull();
1106 });
1107
1108 it("refuses anything that is not a login, rather than building a URL out of it", () => {
1109 for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) {
1110 expect(loginAvatarUrl(login, "github.com")).toBeNull();
1111 }
1112 });
1113});