1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 buildPullRequestSummariesGraphQlQuery,
6 buildPullRequestWatchFingerprintsGraphQlQuery,
7 decodePullRequestSummariesJson,
8 decodePullRequestWatchFingerprintsJson,
9 buildReviewSubmission,
10 buildPullRequestStackMembershipsGraphQlQuery,
11 decodePullRequestStackMembershipsJson,
12 buildReviewerRequest,
13 buildSetFilesViewedGraphQlMutation,
14 decodePullRequestActivityJson,
15 decodePullRequestDetailJson,
16 decodePullRequestFilesJson,
17 decodePullRequestFilesViewedJson,
18 decodePullRequestListJson,
19 decodePullRequestNodeIdJson,
20 decodePullRequestSearchJson,
21 decodePullRequestStacksJson,
22 decodeLabelCandidatesJson,
23 decodeReviewerCandidatesJson,
24 decodeReviewThreadCommentsJson,
25 decodeReviewThreadsJson,
26 decodeViewerPermissionsJson,
27 decodeWorkflowRunsJson,
28 reviewThreadConversation,
29 REVIEW_THREADS_GRAPHQL_QUERY,
30 pullRequestCoreGraphQlQuery,
31 pullRequestSearchGraphQlQuery,
32} from "./gitHubPullRequestJson.ts";
33
34/**
35 * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity:
36 * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state.
37 */
38function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
39 return JSON.stringify({
40 data: {
41 repository: {
42 pullRequests: {
43 pageInfo: { hasNextPage: false },
44 nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({
45 number: 1,
46 title: "Add the pull requests page",
47 url: "https://github.com/pingdotgg/t3code/pull/1",
48 headRefName: "feat/page",
49 baseRefName: "main",
50 createdAt: "2026-07-01T00:00:00Z",
51 updatedAt: "2026-07-02T00:00:00Z",
52 ...(reviewRequests === undefined
53 ? {}
54 : {
55 reviewRequests: {
56 nodes: (reviewRequests as ReadonlyArray<unknown>).map((requestedReviewer) => ({
57 requestedReviewer,
58 })),
59 },
60 }),
61 ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }),
62 ...(checks === undefined
63 ? {}
64 : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }),
65 ...entry,
66 })),
67 },
68 },
69 },
70 });
71}
72
73function expectSuccess<A>(result: Result.Result<A, unknown>): A {
74 expect(Result.isSuccess(result)).toBe(true);
75 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
76 return result.success;
77}
78
79describe("pull request list decoding", () => {
80 it("treats a merge timestamp as merged even when the state still says closed", () => {
81 const [entry] = expectSuccess(
82 decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])),
83 ).items;
84 expect(entry?.state).toBe("merged");
85 });
86
87 it("normalizes mergeability and defaults unknown values", () => {
88 const batch = expectSuccess(
89 decodePullRequestListJson(
90 listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]),
91 ),
92 );
93 expect(batch.items.map((entry) => entry.mergeability)).toEqual([
94 "conflicting",
95 "unknown",
96 "unknown",
97 ]);
98 });
99
100 it("keeps user review requests and drops team ones, which are not logins", () => {
101 const [entry] = expectSuccess(
102 decodePullRequestListJson(
103 listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]),
104 ),
105 ).items;
106 expect(entry?.reviewRequestLogins).toEqual(["octocat"]);
107 });
108
109 it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => {
110 const batch = expectSuccess(
111 decodePullRequestListJson(
112 listJson([
113 { reviewDecision: "APPROVED" },
114 { reviewDecision: "CHANGES_REQUESTED" },
115 { reviewDecision: "REVIEW_REQUIRED" },
116 { reviewDecision: null },
117 ]),
118 ),
119 );
120 expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([
121 "approved",
122 "changes-requested",
123 "review-required",
124 null,
125 ]);
126 });
127
128 it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => {
129 const batch = expectSuccess(
130 decodePullRequestListJson(
131 listJson([
132 {
133 reviewDecision: null,
134 latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }],
135 },
136 {
137 reviewDecision: "REVIEW_REQUIRED",
138 latestReviews: [
139 { author: { login: "octocat" }, state: "APPROVED" },
140 { author: { login: "hubot" }, state: "CHANGES_REQUESTED" },
141 ],
142 },
143 {
144 reviewDecision: "APPROVED",
145 latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }],
146 },
147 {
148 reviewDecision: null,
149 latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }],
150 },
151 ]),
152 ),
153 );
154 expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([
155 "approved",
156 "changes-requested",
157 "approved",
158 null,
159 ]);
160 });
161
162 it("rolls the head commit's rollup up to the one word a row has space for", () => {
163 const batch = expectSuccess(
164 decodePullRequestListJson(
165 listJson([
166 { checks: "FAILURE" },
167 { checks: "PENDING" },
168 { checks: "SUCCESS" },
169 { checks: "ERROR" },
170 { checks: "EXPECTED" },
171 {},
172 ]),
173 ),
174 );
175 expect(batch.items.map((entry) => entry.checksState)).toEqual([
176 "failing",
177 "pending",
178 "passing",
179 "failing",
180 "pending",
181 null,
182 ]);
183 });
184
185 it("skips malformed entries but still counts them, so paging does not stop early", () => {
186 const batch = expectSuccess(
187 decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])),
188 );
189 expect(batch.items).toHaveLength(1);
190 expect(batch.rawCount).toBe(2);
191 });
192});
193
194describe("pull request search decoding", () => {
195 function searchJson(rollupStates: ReadonlyArray<string | null>): string {
196 return JSON.stringify({
197 data: {
198 search: {
199 pageInfo: { hasNextPage: false },
200 nodes: rollupStates.map((state, index) => ({
201 number: index + 1,
202 title: "Add the pull requests page",
203 url: "https://github.com/pingdotgg/t3code/pull/1",
204 headRefName: "feat/page",
205 baseRefName: "main",
206 createdAt: "2026-07-01T00:00:00Z",
207 updatedAt: "2026-07-02T00:00:00Z",
208 repository: { nameWithOwner: "pingdotgg/t3code" },
209 commits: {
210 nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }],
211 },
212 })),
213 },
214 },
215 });
216 }
217
218 it("keeps stack membership beside search results without extra per-PR reads", () => {
219 const raw = JSON.parse(searchJson(["SUCCESS", null]));
220 raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" };
221 raw.data.search.nodes[0].stackEntry = { position: 1 };
222 const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw)));
223 expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" });
224 expect(batch.items[1]?.stack).toBeUndefined();
225 expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry");
226 expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry");
227 });
228
229 it("maps the rollup enum the search answers with onto the same three words", () => {
230 // The search asks GitHub for the verdict rather than the checks behind it, so this path sees
231 // one enum where the listing sees an array.
232 const batch = expectSuccess(
233 decodePullRequestSearchJson(
234 searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]),
235 ),
236 );
237 expect(batch.items.map((entry) => entry.checksState)).toEqual([
238 "passing",
239 "failing",
240 "failing",
241 "pending",
242 "pending",
243 null,
244 ]);
245 });
246});
247
248describe("pull request detail decoding", () => {
249 const detailJson = JSON.stringify({
250 number: 7,
251 title: "Detail",
252 url: "https://github.com/pingdotgg/t3code/pull/7",
253 headRefName: "feat/detail",
254 baseRefName: "main",
255 createdAt: "2026-07-01T00:00:00Z",
256 updatedAt: "2026-07-05T00:00:00Z",
257 body: "Body",
258 statusCheckRollup: [
259 { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" },
260 { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" },
261 { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" },
262 ],
263 comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }],
264 reviews: [
265 { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" },
266 { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" },
267 ],
268 commits: [
269 {
270 oid: "abc1234",
271 messageHeadline: "Ship the timeline",
272 committedDate: "2026-07-05T00:00:00Z",
273 authors: [
274 { login: "octocat", name: "Octo Cat", email: "octo@example.com" },
275 { name: "Pair Author", email: "pair@example.com" },
276 ],
277 },
278 ],
279 });
280
281 /** The same conversation as the GraphQL activity query answers it. */
282 const activityJson = (raw: Record<string, unknown>) =>
283 JSON.stringify({
284 data: {
285 repository: {
286 pullRequest: {
287 comments: { nodes: raw["comments"] ?? [] },
288 reviews: { nodes: raw["reviews"] ?? [] },
289 commits: {
290 nodes: ((raw["commits"] ?? []) as ReadonlyArray<Record<string, unknown>>).map(
291 ({ authors, ...commit }) => ({
292 commit: {
293 ...commit,
294 authors: {
295 nodes: ((authors ?? []) as ReadonlyArray<Record<string, unknown>>).map(
296 ({ login, ...author }) => ({
297 ...author,
298 ...(login === undefined ? {} : { user: { login } }),
299 }),
300 ),
301 },
302 },
303 }),
304 ),
305 },
306 },
307 },
308 },
309 });
310 const activity = (raw: Record<string, unknown>) =>
311 Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({
312 comments: page.remarks.toSorted((left, right) =>
313 left.createdAt.localeCompare(right.createdAt),
314 ),
315 commits: page.commits ?? [],
316 }));
317
318 it("maps check-run status and commit-status state onto one vocabulary", () => {
319 const detail = expectSuccess(decodePullRequestDetailJson(detailJson));
320 expect(detail.checks.map((check) => [check.name, check.status])).toEqual([
321 ["build", "pending"],
322 ["test", "failure"],
323 ["ci/legacy", "success"],
324 ]);
325 });
326
327 it("keeps what branch protection requires, and asks for it on github.com only", () => {
328 const raw = JSON.parse(detailJson) as Record<string, unknown>;
329 const detail = expectSuccess(
330 decodePullRequestDetailJson(
331 JSON.stringify({
332 ...raw,
333 statusCheckRollup: [
334 { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true },
335 { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false },
336 { __typename: "StatusContext", context: "legacy", state: "SUCCESS" },
337 ],
338 }),
339 ),
340 );
341 expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]);
342 expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired");
343 expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired");
344 });
345
346 it("keeps a workflow waiting for approval out of the passing state", () => {
347 const raw = JSON.parse(detailJson) as Record<string, unknown>;
348 const detail = expectSuccess(
349 decodePullRequestDetailJson(
350 JSON.stringify({
351 ...raw,
352 statusCheckRollup: [
353 { __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
354 {
355 __typename: "CheckRun",
356 name: "contributor tests",
357 status: "COMPLETED",
358 conclusion: "ACTION_REQUIRED",
359 },
360 ],
361 }),
362 ),
363 );
364
365 expect(detail.checks.map((check) => check.status)).toEqual(["success", "action-required"]);
366 expect(detail.checksState).toBe("pending");
367 });
368
369 it("decodes workflow runs that can be approved", () => {
370 expect(
371 expectSuccess(
372 decodeWorkflowRunsJson(
373 JSON.stringify({
374 workflow_runs: [
375 { id: 10, name: "contributor tests", html_url: "https://example.com/10" },
376 { id: 11, name: null, html_url: null },
377 ],
378 }),
379 ),
380 ).runs,
381 ).toEqual([
382 { id: 10, name: "contributor tests", url: "https://example.com/10" },
383 { id: 11, name: "Workflow run 11", url: null },
384 ]);
385 });
386
387 it("reads an auto-merge request and strategy, its null as off and its absence as neither", () => {
388 const raw = JSON.parse(detailJson) as Record<string, unknown>;
389 const armed = (entry: Record<string, unknown>) =>
390 expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry })));
391
392 expect(
393 armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }),
394 ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
395 expect(armed({ autoMergeRequest: null }).autoMergeEnabled).toBe(false);
396 // `gh` not answering for the field at all is not GitHub saying the merge is unarmed.
397 expect(armed({}).autoMergeEnabled).toBeUndefined();
398 });
399
400 it("shows a re-running check once, as the run that is happening now", () => {
401 // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice,
402 // the finished run and the one that replaced it, with no id to tell them apart.
403 const raw = JSON.parse(detailJson) as Record<string, unknown>;
404 const detail = expectSuccess(
405 decodePullRequestDetailJson(
406 JSON.stringify({
407 ...raw,
408 statusCheckRollup: [
409 {
410 __typename: "CheckRun",
411 name: "Prepare PR size config",
412 workflowName: "PR Size",
413 status: "COMPLETED",
414 conclusion: "SUCCESS",
415 startedAt: "2026-08-11T16:06:20Z",
416 completedAt: "2026-08-11T16:06:25Z",
417 },
418 {
419 __typename: "CheckRun",
420 name: "Prepare PR size config",
421 workflowName: "PR Size",
422 status: "IN_PROGRESS",
423 conclusion: "",
424 startedAt: "2026-08-11T17:01:04Z",
425 completedAt: "0001-01-01T00:00:00Z",
426 },
427 ],
428 }),
429 ),
430 );
431
432 expect(detail.checks.map((check) => [check.name, check.status])).toEqual([
433 ["Prepare PR size config", "pending"],
434 ]);
435 expect(detail.checksState).toBe("pending");
436 });
437
438 it("preserves a failed rerun's job URL and completion time", () => {
439 const raw = JSON.parse(detailJson) as Record<string, unknown>;
440 const previous = {
441 __typename: "CheckRun",
442 name: "build",
443 workflowName: "Checks",
444 status: "COMPLETED",
445 conclusion: "FAILURE",
446 startedAt: "2026-08-11T16:00:00Z",
447 completedAt: "2026-08-11T16:01:00Z",
448 detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/2",
449 };
450 const rerun = {
451 ...previous,
452 startedAt: "2026-08-11T17:00:00Z",
453 completedAt: "2026-08-11T17:01:00Z",
454 detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/3",
455 };
456 const detail = expectSuccess(
457 decodePullRequestDetailJson(JSON.stringify({ ...raw, statusCheckRollup: [previous, rerun] })),
458 );
459 expect(detail.checks).toEqual([
460 {
461 name: "build",
462 status: "failure",
463 description: null,
464 url: rerun.detailsUrl,
465 completedAt: rerun.completedAt,
466 },
467 ]);
468 });
469
470 it("keeps a check pending while another run of it on the same commit is still going", () => {
471 // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The
472 // `pull_request` run finished after the `push` run started, and the `push` run is still going.
473 const finished = {
474 __typename: "CheckRun",
475 name: "check",
476 workflowName: "Checks",
477 status: "COMPLETED",
478 conclusion: "SUCCESS",
479 startedAt: "2026-10-08T04:06:10Z",
480 completedAt: "2026-10-08T04:18:58Z",
481 };
482 const running = {
483 ...finished,
484 status: "IN_PROGRESS",
485 conclusion: "",
486 startedAt: "2026-10-08T04:06:13Z",
487 completedAt: "0001-01-01T00:00:00Z",
488 };
489 // A run still queued has not started either, so it carries no time at all.
490 const queued = { ...running, status: "QUEUED", startedAt: "0001-01-01T00:00:00Z" };
491 const raw = JSON.parse(detailJson) as Record<string, unknown>;
492 const checksOf = (statusCheckRollup: ReadonlyArray<object>) => {
493 const detail = expectSuccess(
494 decodePullRequestDetailJson(JSON.stringify({ ...raw, statusCheckRollup })),
495 );
496 return [detail.checks.map((check) => [check.name, check.status]), detail.checksState];
497 };
498
499 // Both listing orders, so the run still going wins on its own rather than by coming last.
500 for (const rollup of [
501 [finished, running],
502 [running, finished],
503 [finished, queued],
504 [queued, finished],
505 ]) {
506 expect(checksOf(rollup)).toEqual([[["check", "pending"]], "pending"]);
507 }
508 });
509
510 it("merges reviews with comments in time order and keeps a bodyless approval", () => {
511 const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>));
512 // r2 approved without writing anything, which is still the event worth seeing.
513 expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]);
514 expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED");
515 });
516
517 it("keeps every attributed commit author, including an unlinked signature", () => {
518 const detail = expectSuccess(activity(JSON.parse(detailJson) as Record<string, unknown>));
519 expect(detail.commits[0]?.authors).toEqual([
520 { login: "octocat", name: "Octo Cat", avatarUrl: null },
521 { login: "Pair Author", name: "Pair Author", avatarUrl: null },
522 ]);
523 });
524
525 it("drops the bodyless review GitHub opens to hold line comments", () => {
526 const raw = JSON.parse(detailJson) as Record<string, unknown>;
527 const detail = expectSuccess(
528 activity({
529 ...raw,
530 reviews: [
531 // What a reviewer leaving inline comments produces: a container with a state but
532 // nothing to read. Its comments come from the review threads instead.
533 { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" },
534 {
535 id: "r5",
536 body: "Looks good.",
537 state: "COMMENTED",
538 submittedAt: "2026-07-08T00:00:00Z",
539 },
540 ],
541 }),
542 );
543
544 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]);
545 });
546
547 it.each(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])(
548 "keeps a bodyless %s review, which is the event itself",
549 (state) => {
550 const raw = JSON.parse(detailJson) as Record<string, unknown>;
551 const detail = expectSuccess(
552 activity({
553 ...raw,
554 reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }],
555 }),
556 );
557
558 expect(detail.comments.map((comment) => comment.id)).toContain("r6");
559 },
560 );
561
562 it("drops a review that carries neither a body nor a state", () => {
563 const raw = JSON.parse(detailJson) as Record<string, unknown>;
564 const detail = expectSuccess(
565 activity({
566 ...raw,
567 reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }],
568 }),
569 );
570 expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]);
571 });
572});
573
574describe("review thread decoding", () => {
575 const threadsJson = (
576 nodes: ReadonlyArray<Record<string, unknown>>,
577 totalCount = nodes.length,
578 pageInfo: Record<string, unknown> = { hasNextPage: false, endCursor: null },
579 ): string =>
580 JSON.stringify({
581 data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } },
582 });
583
584 /** The same query carries the review roster, so it is built alongside the threads. */
585 const reviewJson = (input: {
586 readonly requested?: ReadonlyArray<unknown>;
587 readonly reviewed?: ReadonlyArray<unknown>;
588 }): string =>
589 JSON.stringify({
590 data: {
591 repository: {
592 pullRequest: {
593 reviewThreads: { totalCount: 0, nodes: [] },
594 reviewRequests: {
595 nodes: (input.requested ?? []).map((r) => ({ requestedReviewer: r })),
596 },
597 latestReviews: { nodes: (input.reviewed ?? []).map((a) => ({ author: a })) },
598 },
599 },
600 },
601 });
602
603 it("keeps a reviewer who has already reviewed, app or person, with their avatar", () => {
604 const result = expectSuccess(
605 decodeReviewThreadsJson(
606 reviewJson({
607 requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }],
608 // An app that has reviewed is no longer an outstanding request, which is why asking
609 // only for requests reported nobody on a pull request a bot had reviewed.
610 reviewed: [
611 {
612 __typename: "Bot",
613 login: "macroscopeapp",
614 avatarUrl: "https://avatars/in/900172.png",
615 },
616 ],
617 }),
618 ),
619 );
620
621 expect(result.botLogins).toEqual(new Set(["macroscopeapp"]));
622 expect(result.reviewers).toEqual([
623 { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" },
624 {
625 login: "macroscopeapp",
626 name: null,
627 avatarUrl: "https://avatars/in/900172.png",
628 isBot: true,
629 },
630 ]);
631 });
632
633 it("carries per-commit line counts from the pull-request connection", () => {
634 const result = expectSuccess(
635 decodeReviewThreadsJson(
636 JSON.stringify({
637 data: {
638 repository: {
639 pullRequest: {
640 reviewThreads: { totalCount: 0, nodes: [] },
641 commits: {
642 nodes: [
643 { commit: { oid: "abc123", additions: 18, deletions: 7 } },
644 { commit: { oid: "def456", additions: 3, deletions: 0 } },
645 ],
646 },
647 },
648 },
649 },
650 }),
651 ),
652 );
653
654 expect([...result.commitStats]).toEqual([
655 ["abc123", { additions: 18, deletions: 7 }],
656 ["def456", { additions: 3, deletions: 0 }],
657 ]);
658 });
659
660 it("omits misleading line counts from merge commits", () => {
661 const result = expectSuccess(
662 decodeReviewThreadsJson(
663 JSON.stringify({
664 data: {
665 repository: {
666 pullRequest: {
667 reviewThreads: { totalCount: 0, nodes: [] },
668 commits: {
669 nodes: [
670 {
671 commit: {
672 oid: "merge123",
673 additions: 36_858,
674 deletions: 12_928,
675 parents: { totalCount: 2 },
676 },
677 },
678 ],
679 },
680 },
681 },
682 },
683 }),
684 ),
685 );
686
687 expect([...result.commitStats]).toEqual([]);
688 });
689
690 it("decodes the newest commits off the same connection, oldest to newest", () => {
691 const result = expectSuccess(
692 decodeReviewThreadsJson(
693 JSON.stringify({
694 data: {
695 repository: {
696 pullRequest: {
697 reviewThreads: { totalCount: 0, nodes: [] },
698 commits: {
699 nodes: [
700 {
701 commit: {
702 oid: "abc123",
703 messageHeadline: "Ship the timeline",
704 committedDate: "2026-07-05T00:00:00Z",
705 additions: 18,
706 deletions: 7,
707 authors: { nodes: [{ name: "Julius", user: { login: "julius" } }] },
708 },
709 },
710 {
711 commit: {
712 oid: "def456",
713 messageHeadline: "Fix the flaky test",
714 committedDate: "2026-07-06T00:00:00Z",
715 },
716 },
717 ],
718 },
719 },
720 },
721 },
722 }),
723 ),
724 );
725
726 expect(result.commits).toEqual([
727 {
728 oid: "abc123",
729 messageHeadline: "Ship the timeline",
730 committedDate: "2026-07-05T00:00:00Z",
731 authors: [{ login: "julius", name: "Julius", avatarUrl: null }],
732 },
733 {
734 oid: "def456",
735 messageHeadline: "Fix the flaky test",
736 committedDate: "2026-07-06T00:00:00Z",
737 authors: [],
738 },
739 ]);
740 });
741
742 it("lists someone who was asked and then answered only once", () => {
743 const result = expectSuccess(
744 decodeReviewThreadsJson(
745 reviewJson({
746 requested: [{ login: "julius", avatarUrl: "https://avatars/j.png" }],
747 reviewed: [{ login: "julius", avatarUrl: "https://avatars/j.png" }],
748 }),
749 ),
750 );
751
752 expect(result.reviewers).toHaveLength(1);
753 });
754
755 it("lists a requested team by org/slug, apart from a user who shares its slug", () => {
756 const result = expectSuccess(
757 decodeReviewThreadsJson(
758 reviewJson({
759 // Recorded from pingdotgg/t3code#12329, where CODEOWNERS asked the team.
760 requested: [
761 {
762 combinedSlug: "pingdotgg/ping-devs",
763 name: "Ping Devs",
764 avatarUrl: "https://avatars.githubusercontent.com/t/6692390?s=400&v=4",
765 },
766 ],
767 reviewed: [
768 { __typename: "User", login: "ping-devs", avatarUrl: "https://avatars/u.png" },
769 ],
770 }),
771 ),
772 );
773
774 expect(result.reviewers).toEqual([
775 {
776 login: "pingdotgg/ping-devs",
777 name: "Ping Devs",
778 avatarUrl: "https://avatars.githubusercontent.com/t/6692390?s=400&v=4",
779 },
780 { login: "ping-devs", name: null, avatarUrl: "https://avatars/u.png" },
781 ]);
782 });
783
784 it("skips a request whose reviewer GitHub does not show", () => {
785 const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] })));
786
787 expect(result.reviewers).toEqual([]);
788 });
789
790 it("keeps the conversation when a reviewer has neither a login nor a team slug", () => {
791 // GraphQL answers with an empty object for a union member the query has no fragment for,
792 // such as a mannequin. Failing on it would take the whole response down, comments included.
793 const result = expectSuccess(
794 decodeReviewThreadsJson(
795 reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }),
796 ),
797 );
798
799 expect(result.reviewers).toEqual([
800 { login: "julius", name: null, avatarUrl: "https://avatars/j.png" },
801 ]);
802 });
803
804 it("carries a resolved thread into the conversation, which was still said", () => {
805 const result = expectSuccess(
806 decodeReviewThreadsJson(
807 threadsJson([
808 {
809 id: "PRRT_a",
810 isResolved: false,
811 path: "apps/server/src/ws.ts",
812 comments: {
813 nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }],
814 },
815 },
816 {
817 id: "PRRT_b",
818 isResolved: true,
819 path: "apps/web/src/main.tsx",
820 comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] },
821 },
822 ]),
823 ),
824 );
825 const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread));
826 expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]);
827 expect(comments[0]).toMatchObject({
828 id: "t1",
829 kind: "review-comment",
830 path: "apps/server/src/ws.ts",
831 });
832 });
833
834 it("carries every reply, not only the remark each thread opened with", () => {
835 const result = expectSuccess(
836 decodeReviewThreadsJson(
837 threadsJson([
838 {
839 id: "PRRT_c",
840 isResolved: false,
841 path: "apps/server/src/ws.ts",
842 comments: {
843 nodes: [
844 { id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" },
845 { id: "t2", body: "fixed", createdAt: "2026-07-01T01:00:00Z" },
846 ],
847 },
848 },
849 ]),
850 ),
851 );
852 const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread));
853 expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]);
854 });
855
856 it("hands back the cursor the next page of threads carries on from", () => {
857 const result = expectSuccess(
858 decodeReviewThreadsJson(
859 threadsJson(
860 [
861 {
862 id: "PRRT_d",
863 path: "apps/server/src/ws.ts",
864 isResolved: false,
865 comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] },
866 },
867 ],
868 80,
869 { hasNextPage: true, endCursor: "Y3Vyc29yOjE" },
870 ),
871 ),
872 );
873 expect(result.nextCursor).toBe("Y3Vyc29yOjE");
874 });
875
876 it("keeps GitHub's own count of a thread whose comments were not all read", () => {
877 const result = expectSuccess(
878 decodeReviewThreadsJson(
879 threadsJson([
880 {
881 id: "PRRT_e",
882 path: "apps/server/src/ws.ts",
883 isResolved: false,
884 comments: {
885 totalCount: 140,
886 pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjI" },
887 nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }],
888 },
889 },
890 ]),
891 ),
892 );
893 expect(result.threads[0]).toMatchObject({
894 commentCount: 140,
895 nextCommentCursor: "Y3Vyc29yOjI",
896 });
897 });
898
899 it("ends a thread's walk on the last page, which still names a cursor", () => {
900 const decoded = expectSuccess(
901 decodeReviewThreadCommentsJson(
902 JSON.stringify({
903 data: {
904 repository: { pullRequest: { id: "PR_1" } },
905 node: {
906 pullRequest: { id: "PR_1" },
907 comments: {
908 pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" },
909 nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }],
910 },
911 },
912 },
913 }),
914 ),
915 );
916 expect(decoded.comments.map((comment) => comment.id)).toEqual(["t9"]);
917 expect(decoded.nextCursor).toBeNull();
918 });
919});
920
921describe("reaction decoding", () => {
922 const commentWithGroups = (reactionGroups: ReadonlyArray<Record<string, unknown>>) =>
923 JSON.stringify({
924 data: {
925 repository: { pullRequest: { id: "PR_1" } },
926 node: {
927 pullRequest: { id: "PR_1" },
928 comments: {
929 pageInfo: { hasNextPage: false, endCursor: null },
930 nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }],
931 },
932 },
933 },
934 });
935
936 it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => {
937 const decoded = expectSuccess(
938 decodeReviewThreadCommentsJson(
939 commentWithGroups([
940 {
941 content: "THUMBS_UP",
942 viewerHasReacted: true,
943 reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] },
944 },
945 // Not one of the eight the contract carries.
946 {
947 content: "PARTY_PARROT",
948 reactors: { totalCount: 1, nodes: [{ login: "hubot" }] },
949 },
950 // Nobody behind it, which GitHub still answers a group for.
951 { content: "HEART", reactors: { totalCount: 0, nodes: [] } },
952 // More reactors than the bounded read named, and no `viewerHasReacted` at all.
953 {
954 content: "ROCKET",
955 reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] },
956 },
957 ]),
958 ),
959 );
960
961 expect(decoded.comments[0]?.reactions).toEqual([
962 { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true },
963 { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false },
964 ]);
965 });
966
967 it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => {
968 const decoded = expectSuccess(
969 decodeReviewThreadCommentsJson(
970 JSON.stringify({
971 data: {
972 viewer: { login: "Bilal" },
973 repository: { pullRequest: { id: "PR_1" } },
974 node: {
975 pullRequest: { id: "PR_1" },
976 comments: {
977 pageInfo: { hasNextPage: false, endCursor: null },
978 nodes: [
979 {
980 id: "t1",
981 body: "nice",
982 createdAt: "2026-07-01T00:00:00Z",
983 reactionGroups: [
984 {
985 content: "HEART",
986 viewerHasReacted: true,
987 reactors: {
988 totalCount: 2,
989 nodes: [{ login: "bilal" }, { login: "julius" }],
990 },
991 },
992 ],
993 },
994 ],
995 },
996 },
997 },
998 }),
999 ),
1000 );
1001
1002 expect(decoded.comments[0]?.reactions).toEqual([
1003 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
1004 ]);
1005 });
1006});
1007
1008describe("repository access decoding", () => {
1009 const repositoryJson = (viewerPermission?: string | null) =>
1010 JSON.stringify({
1011 data: {
1012 repository: {
1013 pullRequest: null,
1014 mergeCommitAllowed: true,
1015 squashMergeAllowed: false,
1016 rebaseMergeAllowed: true,
1017 ...(viewerPermission === undefined ? {} : { viewerPermission }),
1018 },
1019 },
1020 });
1021
1022 it("reads merge settings with viewer permissions", () => {
1023 expect(
1024 expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities,
1025 ).toEqual({ merge: true, squash: false, rebase: true });
1026 });
1027
1028 it("fails rather than defaulting open when a setting is missing", () => {
1029 const decoded = decodeViewerPermissionsJson(
1030 JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }),
1031 );
1032 expect(Result.isSuccess(decoded)).toBe(false);
1033 });
1034
1035 it("counts the roles that can push as write, and the ones that cannot as read", () => {
1036 for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) {
1037 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe(
1038 true,
1039 );
1040 }
1041 for (const permission of ["TRIAGE", "READ", "NONE"]) {
1042 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe(
1043 false,
1044 );
1045 }
1046 });
1047
1048 it("withholds write where gh names no permission, which is not a standing it gave", () => {
1049 // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes
1050 // the press, where a missing one still leaves the pull request open on its host.
1051 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false);
1052 });
1053
1054 it("grants write and triage to a GitHub App, which GitHub answers with a null permission", () => {
1055 const access = expectSuccess(decodeViewerPermissionsJson(repositoryJson(null)));
1056 expect(access.canWrite).toBe(true);
1057 expect(access.canTriage).toBe(true);
1058 });
1059});
1060
1061describe("viewer permission decoding", () => {
1062 const viewerJson = (repository: Record<string, unknown>) =>
1063 JSON.stringify({
1064 data: {
1065 repository: {
1066 mergeCommitAllowed: true,
1067 squashMergeAllowed: false,
1068 rebaseMergeAllowed: true,
1069 ...repository,
1070 },
1071 },
1072 });
1073
1074 it("reads the repository's role and the pull request's own viewer fields together", () => {
1075 expect(
1076 expectSuccess(
1077 decodeViewerPermissionsJson(
1078 viewerJson({
1079 viewerPermission: "READ",
1080 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
1081 }),
1082 ),
1083 ),
1084 ).toEqual({
1085 mergeCapabilities: { merge: true, squash: false, rebase: true },
1086 canWrite: false,
1087 canTriage: false,
1088 canUpdate: true,
1089 didAuthor: true,
1090 canDeleteBranch: false,
1091 });
1092 });
1093
1094 it("says no to a passer-by on a repository they can only read", () => {
1095 expect(
1096 expectSuccess(
1097 decodeViewerPermissionsJson(
1098 viewerJson({
1099 viewerPermission: "READ",
1100 pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false },
1101 }),
1102 ),
1103 ),
1104 ).toEqual({
1105 mergeCapabilities: { merge: true, squash: false, rebase: true },
1106 canWrite: false,
1107 canTriage: false,
1108 canUpdate: false,
1109 didAuthor: false,
1110 canDeleteBranch: false,
1111 });
1112 });
1113
1114 it("reads silence as permission, but not as authorship", () => {
1115 // A node the viewer cannot see comes back null. Updating is a permission, so an unknown
1116 // answer grants it and lets the host refuse; authorship is a fact about who wrote the change,
1117 // and claiming it for someone who did not is how an author's own rules get handed out.
1118 expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({
1119 mergeCapabilities: { merge: true, squash: false, rebase: true },
1120 canWrite: false,
1121 canTriage: false,
1122 canUpdate: true,
1123 didAuthor: false,
1124 canDeleteBranch: false,
1125 });
1126 });
1127
1128 it("reads triage as enough to label, and not enough to write", () => {
1129 const access = expectSuccess(
1130 decodeViewerPermissionsJson(
1131 viewerJson({
1132 viewerPermission: "TRIAGE",
1133 pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false },
1134 }),
1135 ),
1136 );
1137 expect(access.canTriage).toBe(true);
1138 expect(access.canWrite).toBe(false);
1139 });
1140});
1141
1142describe("label candidate decoding", () => {
1143 const labelsJson = (input: {
1144 readonly defined: ReadonlyArray<Record<string, unknown>>;
1145 readonly applied?: ReadonlyArray<string>;
1146 readonly hasNextPage?: boolean;
1147 }) =>
1148 JSON.stringify({
1149 data: {
1150 repository: {
1151 labels: {
1152 pageInfo: { hasNextPage: input.hasNextPage ?? false },
1153 nodes: input.defined,
1154 },
1155 pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } },
1156 },
1157 },
1158 });
1159
1160 it("marks the labels the pull request already wears", () => {
1161 const list = expectSuccess(
1162 decodeLabelCandidatesJson(
1163 labelsJson({
1164 defined: [
1165 { name: "bug", color: "d73a4a", description: "Something is broken" },
1166 { name: "size:XL", color: "e4572e", description: null },
1167 ],
1168 applied: ["size:XL"],
1169 }),
1170 ),
1171 );
1172 expect(list.candidates).toEqual([
1173 { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false },
1174 { name: "size:XL", color: "e4572e", description: null, isApplied: true },
1175 ]);
1176 expect(list.truncated).toBe(false);
1177 });
1178
1179 it("keeps a worn label the repository no longer defines, so it can be taken off", () => {
1180 const list = expectSuccess(
1181 decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })),
1182 );
1183 expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([
1184 ["legacy", true],
1185 ["bug", false],
1186 ]);
1187 });
1188
1189 it("says so when the repository defines more labels than the read asked for", () => {
1190 expect(
1191 expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true })))
1192 .truncated,
1193 ).toBe(true);
1194 });
1195});
1196
1197describe("review thread decoding", () => {
1198 const threadsJson = (
1199 nodes: ReadonlyArray<Record<string, unknown>>,
1200 pullRequest: Record<string, unknown> = {},
1201 ) =>
1202 JSON.stringify({
1203 data: {
1204 repository: {
1205 pullRequest: {
1206 reviewThreads: { totalCount: nodes.length, nodes },
1207 author: null,
1208 comments: { nodes: [] },
1209 reviewRequests: { nodes: [] },
1210 latestReviews: { nodes: [] },
1211 ...pullRequest,
1212 },
1213 },
1214 },
1215 });
1216
1217 it("carries what the reader may do with the pull request, off the conversation read", () => {
1218 // The same response the threads arrive in, so knowing this costs no request of its own.
1219 expect(
1220 expectSuccess(
1221 decodeReviewThreadsJson(
1222 threadsJson([], { viewerCanUpdate: false, viewerDidAuthor: false }),
1223 ),
1224 ).viewer,
1225 ).toEqual({ canUpdate: false, didAuthor: false });
1226 expect(expectSuccess(decodeReviewThreadsJson(threadsJson([]))).viewer).toEqual({
1227 canUpdate: true,
1228 didAuthor: false,
1229 });
1230 });
1231
1232 const comment = (id: string, body: string) => ({
1233 id,
1234 author: { login: "bilal", avatarUrl: "https://avatars/b.png" },
1235 body,
1236 createdAt: "2026-07-01T00:00:00Z",
1237 url: `https://github.com/acme/web/pull/1#discussion_r${id}`,
1238 });
1239
1240 it("anchors a thread to its line and side, keeping the whole conversation", () => {
1241 const reviewThreads = expectSuccess(
1242 decodeReviewThreadsJson(
1243 threadsJson([
1244 {
1245 id: "PRRT_1",
1246 isResolved: false,
1247 isOutdated: false,
1248 path: "src/a.ts",
1249 line: 42,
1250 diffSide: "LEFT",
1251 comments: {
1252 totalCount: 2,
1253 nodes: [
1254 { ...comment("c1", "first"), lastEditedAt: "2026-07-02T00:00:00Z" },
1255 comment("c2", "second"),
1256 ],
1257 },
1258 },
1259 ]),
1260 ),
1261 );
1262 expect(reviewThreads.threads.map((entry) => entry.thread)).toEqual([
1263 {
1264 id: "PRRT_1",
1265 path: "src/a.ts",
1266 line: 42,
1267 side: "left",
1268 isResolved: false,
1269 isOutdated: false,
1270 comments: [
1271 {
1272 id: "c1",
1273 author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" },
1274 body: "first",
1275 createdAt: "2026-07-01T00:00:00Z",
1276 editedAt: "2026-07-02T00:00:00Z",
1277 url: "https://github.com/acme/web/pull/1#discussion_rc1",
1278 reactions: [],
1279 },
1280 {
1281 id: "c2",
1282 author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" },
1283 body: "second",
1284 createdAt: "2026-07-01T00:00:00Z",
1285 editedAt: null,
1286 url: "https://github.com/acme/web/pull/1#discussion_rc2",
1287 reactions: [],
1288 },
1289 ],
1290 },
1291 ]);
1292 expect(
1293 reviewThreadConversation(reviewThreads.threads.map((entry) => entry.thread))[0]?.editedAt,
1294 ).toBe("2026-07-02T00:00:00Z");
1295 });
1296
1297 it("leaves an outdated thread without a line rather than pinning it to a stale one", () => {
1298 const reviewThreads = expectSuccess(
1299 decodeReviewThreadsJson(
1300 threadsJson([
1301 {
1302 id: "PRRT_2",
1303 isResolved: true,
1304 isOutdated: true,
1305 path: "src/a.ts",
1306 // GitHub reports no current line once the thread has fallen off the diff.
1307 line: null,
1308 diffSide: "RIGHT",
1309 comments: { totalCount: 1, nodes: [comment("c3", "stale")] },
1310 },
1311 ]),
1312 ),
1313 );
1314 expect(reviewThreads.threads[0]?.thread).toMatchObject({
1315 line: null,
1316 isOutdated: true,
1317 isResolved: true,
1318 });
1319 });
1320
1321 it("keeps a resolved thread in the conversation as well as against its line", () => {
1322 const decoded = expectSuccess(
1323 decodeReviewThreadsJson(
1324 threadsJson([
1325 {
1326 id: "PRRT_3",
1327 isResolved: true,
1328 path: "src/a.ts",
1329 line: 7,
1330 diffSide: "RIGHT",
1331 comments: { totalCount: 1, nodes: [comment("c4", "done")] },
1332 },
1333 ]),
1334 ),
1335 );
1336 // A resolved conversation is finished work, not unsaid work: the timeline reads it and the
1337 // diff pins it to its line, the same as any other.
1338 const threads = decoded.threads.map((entry) => entry.thread);
1339 expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]);
1340 expect(threads).toHaveLength(1);
1341 });
1342
1343 it("reads edit times for issue comments and reviews without using reaction updates", () => {
1344 const editedAt = "2026-10-02T12:06:00Z";
1345 const result = expectSuccess(
1346 decodeReviewThreadsJson(
1347 threadsJson([], {
1348 comments: {
1349 nodes: [
1350 { id: "edited", lastEditedAt: editedAt },
1351 { id: "reaction-only", lastEditedAt: null, updatedAt: editedAt },
1352 ],
1353 },
1354 reviews: { nodes: [{ id: "review", lastEditedAt: editedAt }] },
1355 }),
1356 ),
1357 );
1358 expect([...result.editedAtById]).toEqual([
1359 ["edited", editedAt],
1360 ["review", editedAt],
1361 ]);
1362 });
1363
1364 it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => {
1365 const result = expectSuccess(
1366 decodeReviewThreadsJson(
1367 threadsJson([], {
1368 reactionGroups: [
1369 {
1370 content: "HEART",
1371 viewerHasReacted: true,
1372 reactors: { totalCount: 1, nodes: [{ login: "bilal" }] },
1373 },
1374 ],
1375 comments: {
1376 nodes: [
1377 {
1378 id: "c1",
1379 reactionGroups: [
1380 {
1381 content: "THUMBS_UP",
1382 reactors: { totalCount: 1, nodes: [{ login: "julius" }] },
1383 },
1384 ],
1385 },
1386 ],
1387 },
1388 reviews: {
1389 nodes: [
1390 {
1391 id: "r1",
1392 reactionGroups: [
1393 { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } },
1394 ],
1395 },
1396 ],
1397 },
1398 }),
1399 ),
1400 );
1401
1402 expect(result.reactions).toEqual([
1403 { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true },
1404 ]);
1405 expect([...result.reactionsById]).toEqual([
1406 ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]],
1407 ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]],
1408 ]);
1409 });
1410
1411 it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => {
1412 const result = expectSuccess(
1413 decodeReviewThreadsJson(
1414 JSON.stringify({
1415 data: {
1416 viewer: { login: "Bilal" },
1417 repository: {
1418 pullRequest: {
1419 reviewThreads: { totalCount: 0, nodes: [] },
1420 reactionGroups: [
1421 {
1422 content: "HEART",
1423 viewerHasReacted: true,
1424 reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] },
1425 },
1426 ],
1427 },
1428 },
1429 },
1430 }),
1431 ),
1432 );
1433
1434 expect(result.reactions).toEqual([
1435 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
1436 ]);
1437 });
1438});
1439
1440describe("decodePullRequestNodeIdJson", () => {
1441 it("reads the pull request's own node id, which a reaction on its description is addressed by", () => {
1442 expect(
1443 expectSuccess(
1444 decodePullRequestNodeIdJson(
1445 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
1446 ),
1447 ),
1448 ).toBe("PR_kwDOA");
1449 });
1450});
1451
1452describe("REVIEW_THREADS_GRAPHQL_QUERY", () => {
1453 it("caps the initial query after the 104-point rate-limit regression", () => {
1454 const match = REVIEW_THREADS_GRAPHQL_QUERY.match(
1455 /reviewThreads\(first: (\d+)[\s\S]*?comments\(first: (\d+)\)/u,
1456 );
1457
1458 expect(match).not.toBeNull();
1459 if (match === null) throw new Error("expected review-thread connections");
1460 expect(Number(match[1]) * Number(match[2])).toBeLessThanOrEqual(1_000);
1461 });
1462
1463 it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => {
1464 expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4);
1465 // The reviews connection is new: only reactions were ever wanted off it.
1466 expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:");
1467 });
1468});
1469
1470describe("reviewer candidate decoding", () => {
1471 const candidatesJson = (input: {
1472 readonly assignable: ReadonlyArray<Record<string, unknown> | null>;
1473 readonly requested?: ReadonlyArray<Record<string, unknown> | null>;
1474 readonly author?: string;
1475 readonly hasNextPage?: boolean;
1476 }) =>
1477 JSON.stringify({
1478 data: {
1479 repository: {
1480 assignableUsers: {
1481 pageInfo: { hasNextPage: input.hasNextPage ?? false },
1482 nodes: input.assignable,
1483 },
1484 pullRequest: {
1485 author: input.author === undefined ? null : { login: input.author },
1486 reviewRequests: {
1487 nodes: (input.requested ?? []).map((requestedReviewer) => ({ requestedReviewer })),
1488 },
1489 },
1490 },
1491 },
1492 });
1493
1494 it("leaves the author out of the people their own pull request can be sent to", () => {
1495 const list = expectSuccess(
1496 decodeReviewerCandidatesJson(
1497 candidatesJson({
1498 assignable: [{ login: "bilal" }, { login: "octocat", name: "The Octocat" }],
1499 author: "bilal",
1500 }),
1501 ),
1502 );
1503 expect(list.candidates).toEqual([
1504 {
1505 id: "octocat",
1506 kind: "user",
1507 login: "octocat",
1508 name: "The Octocat",
1509 avatarUrl: null,
1510 isRequested: false,
1511 },
1512 ]);
1513 expect(list.truncated).toBe(false);
1514 });
1515
1516 it("marks whoever has already been asked, and leaves the rest to be asked", () => {
1517 const list = expectSuccess(
1518 decodeReviewerCandidatesJson(
1519 candidatesJson({
1520 assignable: [{ login: "octocat" }, { login: "hubot" }],
1521 requested: [{ login: "octocat" }],
1522 }),
1523 ),
1524 );
1525 expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([
1526 ["octocat", true],
1527 ["hubot", false],
1528 ]);
1529 });
1530
1531 it("keeps a requested team apart from the people, so the request can be taken back", () => {
1532 // A team is never among the assignable users, and a request that cannot be seen cannot be
1533 // undone — so the ones GitHub reports are carried, marked as the teams they are.
1534 const list = expectSuccess(
1535 decodeReviewerCandidatesJson(
1536 candidatesJson({
1537 assignable: [{ login: "octocat" }],
1538 requested: [{ slug: "reviewers", combinedSlug: "acme/reviewers", name: "Reviewers" }],
1539 }),
1540 ),
1541 );
1542 expect(list.candidates).toEqual([
1543 {
1544 // Asked by slug, shown by the same `org/slug` as its reviewer row.
1545 id: "reviewers",
1546 kind: "team",
1547 login: "acme/reviewers",
1548 name: "Reviewers",
1549 avatarUrl: null,
1550 isRequested: true,
1551 },
1552 {
1553 id: "octocat",
1554 kind: "user",
1555 login: "octocat",
1556 name: null,
1557 avatarUrl: null,
1558 isRequested: false,
1559 },
1560 ]);
1561 });
1562
1563 it("says so when the repository has more people than the read asked for", () => {
1564 expect(
1565 expectSuccess(
1566 decodeReviewerCandidatesJson(
1567 candidatesJson({ assignable: [{ login: "octocat" }], hasNextPage: true }),
1568 ),
1569 ).truncated,
1570 ).toBe(true);
1571 });
1572});
1573
1574describe("reviewer request payload", () => {
1575 it("sends people and teams in the two lists GitHub keeps them in", () => {
1576 expect(
1577 buildReviewerRequest([
1578 { id: "octocat", kind: "user" },
1579 { id: "reviewers", kind: "team" },
1580 { id: "hubot", kind: "user" },
1581 ]),
1582 ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] });
1583 });
1584
1585 it("sends both lists even where one of them is empty, which is what GitHub reads", () => {
1586 expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({
1587 reviewers: ["octocat"],
1588 team_reviewers: [],
1589 });
1590 });
1591});
1592
1593describe("review submission payload", () => {
1594 it("sends the verdict, the summary and every line comment in one body", () => {
1595 const payload = buildReviewSubmission({
1596 verdict: "request-changes",
1597 body: "Two things.",
1598 comments: [
1599 {
1600 path: "src/a.ts",
1601 position: { kind: "added", newLine: 12 },
1602 body: "rename this",
1603 },
1604 {
1605 path: "src/b.ts",
1606 position: { kind: "deleted", oldLine: 3 },
1607 body: "why remove?",
1608 },
1609 ],
1610 });
1611 expect(payload).toEqual({
1612 event: "REQUEST_CHANGES",
1613 body: "Two things.",
1614 comments: [
1615 { path: "src/a.ts", line: 12, side: "RIGHT", body: "rename this" },
1616 { path: "src/b.ts", line: 3, side: "LEFT", body: "why remove?" },
1617 ],
1618 });
1619 });
1620
1621 it("sends an approval with no words and no comments", () => {
1622 expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({
1623 event: "APPROVE",
1624 body: "",
1625 comments: [],
1626 });
1627 });
1628});
1629
1630describe("decodePullRequestFilesJson", () => {
1631 it("quotes literal backslashes without interpreting them as escapes", () => {
1632 const result = expectSuccess(
1633 decodePullRequestFilesJson(
1634 JSON.stringify([
1635 {
1636 filename: String.raw`src\notes.ts`,
1637 status: "modified",
1638 patch: "@@ -1 +1 @@\n-old\n+new",
1639 },
1640 ]),
1641 ),
1642 );
1643
1644 expect(result.patch).toBe(
1645 [
1646 String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`,
1647 String.raw`--- "a/src\\notes.ts"`,
1648 String.raw`+++ "b/src\\notes.ts"`,
1649 "@@ -1 +1 @@",
1650 "-old",
1651 "+new",
1652 "",
1653 ].join("\n"),
1654 );
1655 });
1656
1657 it("preserves spaces and literal backslashes in both rename paths", () => {
1658 const result = expectSuccess(
1659 decodePullRequestFilesJson(
1660 JSON.stringify([
1661 {
1662 previous_filename: String.raw` old\name.ts `,
1663 filename: String.raw` new\name.ts `,
1664 status: "renamed",
1665 },
1666 ]),
1667 ),
1668 );
1669
1670 expect(result.patch).toBe(
1671 [
1672 String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`,
1673 String.raw`rename from " old\\name.ts "`,
1674 String.raw`rename to " new\\name.ts "`,
1675 String.raw`--- "a/ old\\name.ts "`,
1676 String.raw`+++ "b/ new\\name.ts "`,
1677 "",
1678 ].join("\n"),
1679 );
1680 });
1681
1682 it("assembles a unified patch the files API does not return", () => {
1683 const result = expectSuccess(
1684 decodePullRequestFilesJson(
1685 JSON.stringify([
1686 { filename: "src/app.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" },
1687 ]),
1688 ),
1689 );
1690
1691 expect(result.patch).toBe(
1692 [
1693 "diff --git a/src/app.ts b/src/app.ts",
1694 "--- a/src/app.ts",
1695 "+++ b/src/app.ts",
1696 "@@ -1 +1 @@",
1697 "-old",
1698 "+new",
1699 "",
1700 ].join("\n"),
1701 );
1702 expect(result.truncated).toBe(false);
1703 expect(result.rawCount).toBe(1);
1704 });
1705
1706 it("points an added file at /dev/null on the left and a removed one on the right", () => {
1707 const result = expectSuccess(
1708 decodePullRequestFilesJson(
1709 JSON.stringify([
1710 { filename: "src/new.ts", status: "added", patch: "@@ -0,0 +1 @@\n+hello" },
1711 { filename: "src/gone.ts", status: "removed", patch: "@@ -1 +0,0 @@\n-bye" },
1712 ]),
1713 ),
1714 );
1715
1716 expect(result.patch).toBe(
1717 [
1718 "diff --git a/src/new.ts b/src/new.ts",
1719 "new file mode 100644",
1720 "--- /dev/null",
1721 "+++ b/src/new.ts",
1722 "@@ -0,0 +1 @@",
1723 "+hello",
1724 "diff --git a/src/gone.ts b/src/gone.ts",
1725 "deleted file mode 100644",
1726 "--- a/src/gone.ts",
1727 "+++ /dev/null",
1728 "@@ -1 +0,0 @@",
1729 "-bye",
1730 "",
1731 ].join("\n"),
1732 );
1733 });
1734
1735 it("names both paths of a rename, counting its hunks against the old one", () => {
1736 const result = expectSuccess(
1737 decodePullRequestFilesJson(
1738 JSON.stringify([
1739 {
1740 filename: "src/new.ts",
1741 status: "renamed",
1742 previous_filename: "src/old.ts",
1743 patch: "@@ -1 +1 @@\n-old\n+new",
1744 },
1745 ]),
1746 ),
1747 );
1748
1749 expect(result.patch).toBe(
1750 [
1751 "diff --git a/src/old.ts b/src/new.ts",
1752 "rename from src/old.ts",
1753 "rename to src/new.ts",
1754 "--- a/src/old.ts",
1755 "+++ b/src/new.ts",
1756 "@@ -1 +1 @@",
1757 "-old",
1758 "+new",
1759 "",
1760 ].join("\n"),
1761 );
1762 });
1763
1764 it("still lists a file GitHub sent no hunks for, and says what was withheld", () => {
1765 const result = expectSuccess(
1766 decodePullRequestFilesJson(
1767 JSON.stringify([
1768 // Binary: it changed, and none of it can be shown.
1769 { filename: "logo.png", status: "modified", additions: 4, deletions: 2 },
1770 {
1771 filename: "src/app.ts",
1772 status: "modified",
1773 additions: 1,
1774 deletions: 1,
1775 patch: "@@ -1 +1 @@\n-old\n+new",
1776 },
1777 ]),
1778 ),
1779 );
1780
1781 // Dropping it would take the file out of the change altogether, not just its contents.
1782 expect(result.patch).toContain("diff --git a/logo.png b/logo.png");
1783 expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts");
1784 expect(result.truncated).toBe(true);
1785 expect(result.rawCount).toBe(2);
1786 });
1787
1788 it("does not call a pure rename incomplete, since it has no hunks to withhold", () => {
1789 const result = expectSuccess(
1790 decodePullRequestFilesJson(
1791 JSON.stringify([
1792 {
1793 filename: "src/new.ts",
1794 previous_filename: "src/old.ts",
1795 status: "renamed",
1796 additions: 0,
1797 deletions: 0,
1798 },
1799 ]),
1800 ),
1801 );
1802
1803 expect(result.patch).toContain("rename from src/old.ts");
1804 expect(result.truncated).toBe(false);
1805 });
1806});
1807
1808describe("decodePullRequestFilesViewedJson", () => {
1809 const page = (
1810 nodes: ReadonlyArray<unknown>,
1811 pageInfo: { hasNextPage: boolean; endCursor: string | null },
1812 ) =>
1813 JSON.stringify({
1814 data: { repository: { pullRequest: { files: { pageInfo, nodes } } } },
1815 });
1816
1817 it("reads each file's state and where the next page carries on", () => {
1818 const decoded = decodePullRequestFilesViewedJson(
1819 page(
1820 [
1821 { path: "src/a.ts", viewerViewedState: "VIEWED" },
1822 { path: "src/b.ts", viewerViewedState: "UNVIEWED" },
1823 { path: "src/c.ts", viewerViewedState: "DISMISSED" },
1824 ],
1825 { hasNextPage: true, endCursor: "cursor-2" },
1826 ),
1827 );
1828 expect(Result.isSuccess(decoded)).toBe(true);
1829 if (!Result.isSuccess(decoded)) return;
1830 expect(decoded.success).toEqual({
1831 files: [
1832 { path: "src/a.ts", state: "viewed" },
1833 { path: "src/b.ts", state: "unviewed" },
1834 { path: "src/c.ts", state: "dismissed" },
1835 ],
1836 nextCursor: "cursor-2",
1837 });
1838 });
1839
1840 it("treats a state it has never heard of as unread rather than failing the page", () => {
1841 const decoded = decodePullRequestFilesViewedJson(
1842 page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], {
1843 hasNextPage: false,
1844 endCursor: null,
1845 }),
1846 );
1847 expect(Result.isSuccess(decoded)).toBe(true);
1848 if (!Result.isSuccess(decoded)) return;
1849 expect(decoded.success).toEqual({
1850 files: [{ path: "src/a.ts", state: "unviewed" }],
1851 nextCursor: null,
1852 });
1853 });
1854
1855 it("answers empty for a pull request the host has nothing to say about", () => {
1856 const decoded = decodePullRequestFilesViewedJson(
1857 JSON.stringify({ data: { repository: { pullRequest: null } } }),
1858 );
1859 expect(Result.isSuccess(decoded)).toBe(true);
1860 if (!Result.isSuccess(decoded)) return;
1861 expect(decoded.success).toEqual({ files: [], nextCursor: null });
1862 });
1863});
1864
1865describe("buildSetFilesViewedGraphQlMutation", () => {
1866 it("asks for nothing when nothing was pressed", () => {
1867 expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull();
1868 });
1869
1870 it("clears and restores in one document, each file under its own alias", () => {
1871 const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [
1872 { path: "src/a.ts", viewed: true },
1873 { path: "src/b.ts", viewed: false },
1874 ]);
1875 expect(mutation).not.toBeNull();
1876 if (mutation === null) return;
1877 expect(mutation.query).toContain(
1878 "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)",
1879 );
1880 expect(mutation.query).toContain(
1881 "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })",
1882 );
1883 expect(mutation.query).toContain(
1884 "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })",
1885 );
1886 expect(mutation.variables).toEqual({
1887 pullRequestId: "PR_1",
1888 f0_path: "src/a.ts",
1889 f1_path: "src/b.ts",
1890 });
1891 });
1892
1893 it("keeps a path out of the document, so one cannot be read as part of it", () => {
1894 const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [
1895 { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true },
1896 ]);
1897 expect(mutation).not.toBeNull();
1898 if (mutation === null) return;
1899 expect(mutation.query).not.toContain("evil");
1900 expect(mutation.variables.f0_path).toBe(
1901 '") { __typename } evil: markFileAsViewed(input: { path: "x',
1902 );
1903 });
1904});
1905
1906describe("host-native stack decoding", () => {
1907 /** A stack as the preview lists it, bottom to top, with the fields it answers today. */
1908 function stack(overrides: Record<string, unknown> = {}) {
1909 return {
1910 id: 42,
1911 number: 3,
1912 node_id: "STK_kwDO",
1913 url: "https://api.github.com/repos/acme/web/stacks/3",
1914 base: { ref: "main", sha: "abc" },
1915 open: true,
1916 created_at: "2026-09-01T00:00:00Z",
1917 pull_requests: [
1918 {
1919 number: 10,
1920 head: { ref: "feat/one" },
1921 state: "closed",
1922 merged_at: "2026-09-02T00:00:00Z",
1923 },
1924 { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null },
1925 { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null },
1926 ],
1927 ...overrides,
1928 };
1929 }
1930
1931 /** The one stack a listing answered with, which these reads all expect to find. */
1932 function expectStack(overrides: Record<string, unknown> = {}) {
1933 const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)])));
1934 if (decoded === null) throw new Error("expected a stack");
1935 return decoded;
1936 }
1937
1938 it("reads the first stack, bottom to top, with merged_at outranking state", () => {
1939 expect(expectStack()).toEqual({
1940 id: "42",
1941 number: 3,
1942 url: "https://api.github.com/repos/acme/web/stacks/3",
1943 base: "main",
1944 layers: [
1945 { number: 10, headBranch: "feat/one", state: "merged" },
1946 { number: 11, headBranch: "feat/two", state: "open" },
1947 { number: 12, headBranch: "feat/three", state: "closed" },
1948 ],
1949 });
1950 });
1951
1952 it("retains the detailed layer titles, draft state and expected revision", () => {
1953 expect(
1954 expectStack({
1955 pull_requests: [
1956 {
1957 number: 11,
1958 title: "Second layer",
1959 draft: true,
1960 head: { ref: "feat/two", sha: "abc123" },
1961 state: "open",
1962 merged_at: null,
1963 },
1964 ],
1965 }).layers,
1966 ).toEqual([
1967 {
1968 number: 11,
1969 title: "Second layer",
1970 isDraft: true,
1971 headSha: "abc123",
1972 headBranch: "feat/two",
1973 state: "open",
1974 },
1975 ]);
1976 });
1977
1978 it("accepts a base named as a bare branch, which is what the preview started out sending", () => {
1979 expect(expectStack({ base: "develop" }).base).toBe("develop");
1980 });
1981
1982 it("prefers the page a person opens over the API URL, where the host reports one", () => {
1983 expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe(
1984 "https://github.com/acme/web/stacks/3",
1985 );
1986 });
1987
1988 it("falls back to the node id, then the number, for a stack without an id", () => {
1989 expect(expectStack({ id: undefined }).id).toBe("STK_kwDO");
1990 expect(expectStack({ id: null, node_id: null }).id).toBe("3");
1991 });
1992
1993 it("reads an empty listing as not stacked", () => {
1994 expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull();
1995 });
1996
1997 it("refuses a stack without a number or without its pull requests", () => {
1998 expect(
1999 Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))),
2000 ).toBe(false);
2001 expect(
2002 Result.isSuccess(
2003 decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])),
2004 ),
2005 ).toBe(false);
2006 expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false);
2007 });
2008});
2009
2010describe("pull request stack membership batches", () => {
2011 it("maps aliases while skipping missing pull requests and incomplete memberships", () => {
2012 const memberships = expectSuccess(
2013 decodePullRequestStackMembershipsJson(
2014 JSON.stringify({
2015 data: {
2016 s0: {
2017 pullRequest: {
2018 stack: { number: 3, size: 2, baseRefName: "main" },
2019 stackEntry: { position: 1 },
2020 },
2021 },
2022 s1: null,
2023 s2: { pullRequest: null },
2024 s3: { pullRequest: { stack: null, stackEntry: null } },
2025 s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } },
2026 },
2027 }),
2028 ),
2029 );
2030 expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]);
2031 });
2032
2033 it("refuses malformed responses and unsafe query selectors", () => {
2034 expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true);
2035 expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull();
2036 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull();
2037 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull();
2038 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull();
2039 expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({
2040 s0_owner: "acme",
2041 s0_name: "web",
2042 s0_number: 7,
2043 s1_owner: "acme",
2044 s1_name: "web",
2045 s1_number: 8,
2046 });
2047 });
2048});
2049
2050describe("batched pull request summaries", () => {
2051 it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => {
2052 expect(
2053 buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]),
2054 ).toBeNull();
2055 expect(
2056 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]),
2057 ).toBeNull();
2058 expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull();
2059 });
2060
2061 it("files each answer by its alias and skips what GitHub or the decoder could not give", () => {
2062 const decoded = decodePullRequestSummariesJson(
2063 JSON.stringify({
2064 data: {
2065 s0: {
2066 pullRequest: {
2067 number: 7,
2068 title: "Merged",
2069 url: "https://github.com/acme/web/pull/7",
2070 author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" },
2071 headRefName: "feat/seven",
2072 baseRefName: "main",
2073 state: "MERGED",
2074 mergedAt: "2026-08-24T00:00:00Z",
2075 closedAt: "2026-08-24T00:00:00Z",
2076 updatedAt: "2026-08-24T00:00:00Z",
2077 commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] },
2078 },
2079 },
2080 s1: { pullRequest: null },
2081 s2: { pullRequest: { number: 9 } },
2082 rateLimit: { cost: 1 },
2083 },
2084 }),
2085 );
2086 expect(Result.isSuccess(decoded)).toBe(true);
2087 if (!Result.isSuccess(decoded)) return;
2088 expect([...decoded.success.keys()]).toEqual([0]);
2089 expect(decoded.success.get(0)).toMatchObject({
2090 number: 7,
2091 state: "merged",
2092 mergedAt: "2026-08-24T00:00:00Z",
2093 author: { login: "renovate", isBot: true },
2094 checksState: "failing",
2095 mergeability: "unknown",
2096 additions: 0,
2097 });
2098 // The document did not ask about stacks, so the summary does not claim an answer.
2099 expect(decoded.success.get(0)).not.toHaveProperty("stack");
2100 });
2101
2102 it("reads stack membership only where the document asked for it", () => {
2103 expect(
2104 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query,
2105 ).toContain("stack { number size baseRefName } stackEntry { position }");
2106 expect(
2107 buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query,
2108 ).not.toContain("stack {");
2109 const pullRequest = (number: number, stack: Record<string, unknown>) => ({
2110 pullRequest: {
2111 number,
2112 title: "Stacked",
2113 url: `https://github.com/acme/web/pull/${number}`,
2114 headRefName: `feat/${number}`,
2115 baseRefName: "main",
2116 state: "OPEN",
2117 updatedAt: "2026-08-24T00:00:00Z",
2118 ...stack,
2119 },
2120 });
2121 const decoded = expectSuccess(
2122 decodePullRequestSummariesJson(
2123 JSON.stringify({
2124 data: {
2125 s0: pullRequest(7, { stack: null, stackEntry: null }),
2126 s1: pullRequest(8, {
2127 stack: { number: 3, size: 2, baseRefName: "main" },
2128 stackEntry: { position: 2 },
2129 }),
2130 },
2131 }),
2132 ),
2133 );
2134 expect(decoded.get(0)?.stack).toBeNull();
2135 expect(decoded.get(1)?.stack).toEqual({ number: 3, size: 2, base: "main", position: 2 });
2136 });
2137});
2138
2139describe("pull request watch fingerprints", () => {
2140 it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => {
2141 const document = buildPullRequestWatchFingerprintsGraphQlQuery([
2142 { repository: "pingdotgg/t3code", number: 7 },
2143 { repository: "pingdotgg/lakebed", number: 8 },
2144 ]);
2145 expect(document?.query).toContain(
2146 "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)",
2147 );
2148 expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)");
2149 expect(document?.variables).toEqual({
2150 w0_owner: "pingdotgg",
2151 w0_name: "t3code",
2152 w0_number: 7,
2153 w1_owner: "pingdotgg",
2154 w1_name: "lakebed",
2155 w1_number: 8,
2156 });
2157 expect(
2158 buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]),
2159 ).toBeNull();
2160 });
2161
2162 it("moves status with checks and remarks with comments, each without the other", () => {
2163 const pullRequest = (input: {
2164 readonly running: number;
2165 readonly failed: number;
2166 readonly commentEditedAt: string | null;
2167 readonly reviews: number;
2168 }) => ({
2169 state: "OPEN",
2170 mergeable: "MERGEABLE",
2171 headRefOid: "abc123",
2172 comments: { totalCount: 1, nodes: [{ lastEditedAt: input.commentEditedAt }] },
2173 reviews: { totalCount: input.reviews, nodes: [] },
2174 reviewThreads: { totalCount: 0 },
2175 commits: {
2176 nodes: [
2177 {
2178 commit: {
2179 statusCheckRollup: {
2180 contexts: {
2181 checkRunCountsByState: [
2182 { state: "IN_PROGRESS", count: input.running },
2183 { state: "FAILURE", count: input.failed },
2184 { state: "SUCCESS", count: 0 },
2185 ],
2186 statusContextCountsByState: null,
2187 },
2188 },
2189 },
2190 },
2191 ],
2192 },
2193 });
2194 const decode = (...pullRequests: ReadonlyArray<object | null>) => {
2195 const decoded = decodePullRequestWatchFingerprintsJson(
2196 JSON.stringify({
2197 data: Object.fromEntries(
2198 pullRequests.map((value, index) => [`w${index}`, { pullRequest: value }]),
2199 ),
2200 }),
2201 );
2202 if (!Result.isSuccess(decoded)) throw new Error("fingerprints did not decode");
2203 return decoded.success;
2204 };
2205 const base = { running: 2, failed: 0, commentEditedAt: null, reviews: 1 };
2206 const fingerprint = (input: Parameters<typeof pullRequest>[0]) =>
2207 decode(pullRequest(input)).get(0)!;
2208 const before = fingerprint(base);
2209 const checkFinished = fingerprint({ ...base, running: 1, failed: 1 });
2210 const summaryEdited = fingerprint({ ...base, commentEditedAt: "2026-10-05T23:47:43Z" });
2211 const replied = fingerprint({ ...base, reviews: 2 });
2212
2213 expect(checkFinished.status).not.toBe(before.status);
2214 expect(checkFinished.remarks).toBe(before.remarks);
2215 expect(summaryEdited.remarks).not.toBe(before.remarks);
2216 expect(summaryEdited.status).toBe(before.status);
2217 expect(replied.remarks).not.toBe(before.remarks);
2218 // A pull request GitHub had no answer for is left for a full read.
2219 expect(decode(null, pullRequest(base)).has(0)).toBe(false);
2220 });
2221});