1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { resolvePlanFollowUpSubmission } from "../../proposedPlan";
3import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
4import {
5 ProjectId,
6 PullRequestAction,
7 type PullRequestCheck,
8 type PullRequestComment,
9 type PullRequestDetail,
10 type PullRequestDetailView,
11 type PullRequestRef,
12 type PullRequestReviewThread,
13 type PullRequestState,
14 type RepositoryIdentity,
15 type ThreadPullRequestLink,
16} from "@t3tools/contracts";
17import { describe, expect, it } from "vite-plus/test";
18import { formatInlineContextReference } from "~/lib/composerContextReferences";
19import { buildMessageContext, reviewCommentContextReference } from "~/lib/composerContextRecords";
20
21import {
22 buildAddSelectionToAgentHandoff,
23 buildAskAboutGuideStopHandoff,
24 classifyPullRequestChecks,
25 groupPullRequestChecks,
26 describePullRequestChecks,
27 resolveThreadPanelPullRequestAction,
28 buildAskAboutPullRequestHandoff,
29 buildExplainPullRequestHandoff,
30 buildPullRequestReferenceContext,
31 buildFixFindingHandoff,
32 canDeletePullRequestBranch,
33 buildFixFindingsHandoff,
34 groupPullRequestTimelineConversations,
35 handoffPrompt,
36 handoffReviewComments,
37 stripPullRequestHandoffReferences,
38 isPullRequestVerdictStale,
39 isStackedPullRequestBase,
40 loadingPullRequestCheckoutCommand,
41 pullRequestPanelContext,
42 threadPullRequestPanelTarget,
43 latestPullRequestReviewOutcomes,
44 newestPullRequestCommitAt,
45 mergePullRequestThreadComments,
46 orderPullRequestComments,
47 pullRequestActionMenuHasGroup,
48 pullRequestActionNeedsHostRefresh,
49 pullRequestCheckoutCommand,
50 pullRequestFindingKey,
51 pullRequestReviewOutcome,
52 readableFailure,
53 readPullRequestDetailSnapshot,
54 resolveDisplayedPullRequestDetail,
55 resolvePullRequestReferenceHost,
56 resolvePullRequestPrimaryControl,
57 allowsSinglePullRequestMerge,
58 shouldRefreshPullRequestActivity,
59 resolveBaseFreshness,
60 resolvePullRequestMergeMethod,
61 buildPullRequestTimeline,
62 editPullRequestThreadComment,
63 writePullRequestDetailSnapshot,
64} from "./pullRequestDetail.logic";
65import type { ReviewCommentContext } from "~/reviewCommentContext";
66
67it("groups checks needing attention before running and completed checks without losing any", () => {
68 const checks = (
69 [
70 "success",
71 "pending",
72 "failure",
73 "skipped",
74 "action-required",
75 "cancelled",
76 "neutral",
77 "pending",
78 ] as const
79 ).map((status, index) => ({ name: `check-${index}`, status, description: null, url: null }));
80 const grouped = groupPullRequestChecks(checks);
81 expect(grouped.attention.map((check) => check.name)).toEqual(["check-2", "check-4", "check-5"]);
82 expect(grouped.running.map((check) => check.name)).toEqual(["check-1", "check-7"]);
83 expect(grouped.completed.map((check) => check.name)).toEqual(["check-0", "check-3", "check-6"]);
84 expect(checks[0]?.status).toBe("success");
85 expect(groupPullRequestChecks([])).toEqual({ attention: [], running: [], completed: [] });
86});
87
88describe("pull request checkout commands", () => {
89 it.each([
90 ["github", "feature", null, "gh pr checkout 42"],
91 ["gitlab", "feature", null, "glab mr checkout 42"],
92 ["forgejo", "feature", null, null],
93 ["azure-devops", "feature", null, "az repos pr checkout --id 42"],
94 [
95 "bitbucket",
96 "feature/checkout",
97 "maria/t3code",
98 "git clone --single-branch --branch feature/checkout https://bitbucket.org/maria/t3code.git t3code-pr-42",
99 ],
100 ["unknown", "feature", null, null],
101 ] as const)("builds the %s command", (provider, branch, repository, expected) => {
102 expect(
103 pullRequestCheckoutCommand(SourceControlProviderKind.make(provider), 42, branch, repository),
104 ).toBe(expected);
105 });
106 it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => {
107 expect(
108 pullRequestCheckoutCommand(
109 SourceControlProviderKind.make("forgejo"),
110 42,
111 "feature",
112 null,
113 "https://forgejo.local:3000/git/maria/repo",
114 ),
115 ).toBe(
116 "git fetch 'https://forgejo.local:3000/git/maria/repo' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
117 );
118 });
119 it("quotes shell metacharacters in Forgejo repository URLs", () => {
120 expect(
121 pullRequestCheckoutCommand(
122 SourceControlProviderKind.make("forgejo"),
123 42,
124 "feature",
125 null,
126 "https://forgejo.local/maria/repo'$(echo nope)",
127 ),
128 ).toBe(
129 "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
130 );
131 });
132
133 const reference = (host?: string): PullRequestRef => ({
134 projectId: ProjectId.make("project-1"),
135 ...(host === undefined ? {} : { host }),
136 repository: "acme/web",
137 number: 42,
138 });
139 const identity = (provider: string, canonicalKey: string): RepositoryIdentity => ({
140 canonicalKey,
141 locator: {
142 source: "git-remote",
143 remoteName: "origin",
144 remoteUrl: "git@github.com:acme/web.git",
145 },
146 provider,
147 });
148
149 it("uses a public host when no repository identity is available", () => {
150 expect(loadingPullRequestCheckoutCommand(reference("github.com"), undefined)).toBe(
151 "gh pr checkout 42",
152 );
153 expect(loadingPullRequestCheckoutCommand(reference("gitlab.com"), null)).toBe(
154 "glab mr checkout 42",
155 );
156 });
157
158 it("uses a matching enterprise identity and rejects an explicit host mismatch", () => {
159 const enterprise = identity("github", "github.example.test/acme/web");
160 expect(loadingPullRequestCheckoutCommand(reference("github.example.test"), enterprise)).toBe(
161 "gh pr checkout 42",
162 );
163 expect(loadingPullRequestCheckoutCommand(reference("github.com"), enterprise)).toBeNull();
164 });
165
166 it("does not infer a number-only command without a trusted provider", () => {
167 expect(loadingPullRequestCheckoutCommand(reference(), undefined)).toBeNull();
168 expect(
169 loadingPullRequestCheckoutCommand(
170 reference("github.com"),
171 identity("gitlab", "gitlab.com/acme/web"),
172 ),
173 ).toBeNull();
174 });
175});
176
177const TIMELINE_SOURCE: Pick<
178 PullRequestDetailView,
179 "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt"
180> = {
181 createdAt: "2026-07-01T00:00:00Z",
182 author: { login: "octocat", name: null, avatarUrl: null },
183 commits: [
184 { oid: "1baf7bdcafe", messageHeadline: "add the page", committedDate: "2026-07-02T00:00:00Z" },
185 ],
186 comments: [
187 {
188 id: "c1",
189 kind: "issue-comment",
190 author: { login: "bilal", name: null, avatarUrl: null },
191 body: "looks good",
192 createdAt: "2026-07-03T00:00:00Z",
193 url: null,
194 path: null,
195 reviewState: null,
196 },
197 ],
198 mergedAt: null,
199 closedAt: null,
200};
201
202describe("pull request merge method", () => {
203 it("uses the current choice, then the project default, then the last choice", () => {
204 expect(
205 resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"),
206 ).toBe("squash");
207 expect(
208 resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"),
209 ).toBe("rebase");
210 expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe(
211 "rebase",
212 );
213 expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash");
214 });
215});
216
217describe("pull request activity refresh", () => {
218 const first = {
219 key: "project:acme/web#7",
220 updatedAt: "2026-08-13T13:00:00Z",
221 };
222
223 it("refreshes activity only after the same pull request changes", () => {
224 expect(
225 shouldRefreshPullRequestActivity(first, {
226 ...first,
227 updatedAt: "2026-08-13T13:01:00Z",
228 }),
229 ).toBe(true);
230 });
231
232 it("does not duplicate the first activity read or carry a revision across pull requests", () => {
233 expect(shouldRefreshPullRequestActivity(null, first)).toBe(false);
234 expect(shouldRefreshPullRequestActivity(first, first)).toBe(false);
235 expect(
236 shouldRefreshPullRequestActivity(first, {
237 key: "project:acme/web#8",
238 updatedAt: "2026-08-13T13:01:00Z",
239 }),
240 ).toBe(false);
241 });
242});
243describe("review thread comment pages", () => {
244 it("appends new comments once and keeps refreshed base comments", () => {
245 expect(
246 mergePullRequestThreadComments(
247 [
248 { id: "c1", body: "refreshed" },
249 { id: "c2", body: "already in base" },
250 ],
251 [
252 { id: "c2", body: "stale page copy" },
253 { id: "c3", body: "next page" },
254 ],
255 ),
256 ).toEqual([
257 { id: "c1", body: "refreshed" },
258 { id: "c2", body: "already in base" },
259 { id: "c3", body: "next page" },
260 ]);
261 });
262
263 it("keeps a loaded comment after its body is edited", () => {
264 const loaded = [
265 { id: "c2", body: "old body" },
266 { id: "c3", body: "another loaded comment" },
267 ];
268
269 expect(editPullRequestThreadComment(loaded, "c2", "saved body")).toEqual([
270 { id: "c2", body: "saved body" },
271 { id: "c3", body: "another loaded comment" },
272 ]);
273 });
274});
275
276describe("pull request action menu", () => {
277 it("keeps the group divider when auto-merge is the only action", () => {
278 expect(pullRequestActionMenuHasGroup(false, true, false)).toBe(true);
279 });
280});
281
282describe("pull request primary control", () => {
283 const open = {
284 state: "open" as const,
285 isDraft: false,
286 mergeability: "mergeable" as const,
287 checksState: "passing" as const,
288 autoMergeEnabled: false,
289 hasMergeMethod: true,
290 canMerge: true,
291 canMarkReady: true,
292 canEnableAutoMerge: true,
293 };
294
295 it("moves pending and failing checks to auto-merge", () => {
296 expect(resolvePullRequestPrimaryControl({ ...open, checksState: "pending" })).toBe(
297 "enable-auto-merge",
298 );
299 expect(resolvePullRequestPrimaryControl({ ...open, checksState: "failing" })).toBe(
300 "enable-auto-merge",
301 );
302 });
303
304 it("does not offer auto-merge while the host state is unknown", () => {
305 expect(
306 resolvePullRequestPrimaryControl({
307 ...open,
308 checksState: "pending",
309 autoMergeEnabled: undefined,
310 }),
311 ).toBe("merge");
312 });
313
314 it("keeps armed and terminal states in the merge button slot", () => {
315 expect(resolvePullRequestPrimaryControl({ ...open, autoMergeEnabled: true })).toBe(
316 "auto-merge-armed",
317 );
318 expect(resolvePullRequestPrimaryControl({ ...open, state: "merged" })).toBe("merged");
319 expect(resolvePullRequestPrimaryControl({ ...open, state: "closed" })).toBe("closed");
320 });
321
322 it("keeps conflicts and drafts actionable before merge", () => {
323 expect(resolvePullRequestPrimaryControl({ ...open, mergeability: "conflicting" })).toBe(
324 "resolve",
325 );
326 expect(resolvePullRequestPrimaryControl({ ...open, isDraft: true })).toBe("ready");
327 });
328});
329
330describe("stacked pull request classification", () => {
331 it("requires a known default branch", () => {
332 expect(isStackedPullRequestBase("main", [{ name: "main", isDefault: false }])).toBe(false);
333 });
334
335 it("recognizes local and remote forms of the default branch", () => {
336 expect(
337 isStackedPullRequestBase("main", [{ name: "main", isDefault: true, isRemote: false }]),
338 ).toBe(false);
339 expect(
340 isStackedPullRequestBase("main", [
341 { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" },
342 ]),
343 ).toBe(false);
344 });
345
346 it("classifies a non-default base as stacked once the default is known", () => {
347 expect(
348 isStackedPullRequestBase("feature-base", [
349 { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" },
350 ]),
351 ).toBe(true);
352 });
353
354 it("does not mistake a nested branch suffix for the default branch", () => {
355 expect(
356 isStackedPullRequestBase("main", [
357 {
358 name: "origin/feature/main",
359 isDefault: true,
360 isRemote: true,
361 remoteName: "origin",
362 },
363 ]),
364 ).toBe(true);
365 expect(
366 isStackedPullRequestBase("1.0", [{ name: "release/1.0", isDefault: true, isRemote: false }]),
367 ).toBe(true);
368 });
369});
370
371describe("ordering comments", () => {
372 it("reverses the chronological list for newest first, and leaves oldest first alone", () => {
373 const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }];
374 expect(orderPullRequestComments(comments, "newest")).toEqual([
375 { createdAt: "c" },
376 { createdAt: "b" },
377 { createdAt: "a" },
378 ]);
379 expect(orderPullRequestComments(comments, "oldest")).toEqual(comments);
380 // The source array is chronological input, not a mutation target.
381 expect(comments).toEqual([{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }]);
382 });
383});
384
385describe("review verdicts", () => {
386 it("reads the same three verdicts however a host spells them", () => {
387 expect(pullRequestReviewOutcome("APPROVED")).toBe("approved");
388 expect(pullRequestReviewOutcome("approved")).toBe("approved");
389 expect(pullRequestReviewOutcome("CHANGES_REQUESTED")).toBe("changes-requested");
390 expect(pullRequestReviewOutcome("changes_requested")).toBe("changes-requested");
391 expect(pullRequestReviewOutcome("DISMISSED")).toBe("dismissed");
392 });
393
394 it("is not a verdict where the review only carried remarks", () => {
395 expect(pullRequestReviewOutcome("COMMENTED")).toBeNull();
396 expect(pullRequestReviewOutcome("PENDING")).toBeNull();
397 expect(pullRequestReviewOutcome(null)).toBeNull();
398 });
399
400 it("keeps each reviewer's last word, whatever order the host returned them in", () => {
401 const review = (
402 id: string,
403 login: string,
404 reviewState: string,
405 createdAt: string,
406 ): PullRequestComment => ({
407 id,
408 kind: "review",
409 author: { login, name: null, avatarUrl: null },
410 body: "",
411 createdAt,
412 url: null,
413 path: null,
414 reviewState,
415 });
416
417 expect(
418 latestPullRequestReviewOutcomes([
419 review("r3", "bilal", "APPROVED", "2026-07-03T00:00:00Z"),
420 review("r1", "bilal", "CHANGES_REQUESTED", "2026-07-01T00:00:00Z"),
421 review("r2", "octocat", "CHANGES_REQUESTED", "2026-07-02T00:00:00Z"),
422 // Not a verdict, so it neither adds a reviewer nor overwrites one.
423 review("r4", "octocat", "COMMENTED", "2026-07-04T00:00:00Z"),
424 ]).map((entry) => [entry.actor?.login, entry.outcome]),
425 ).toEqual([
426 ["bilal", "approved"],
427 ["octocat", "changes-requested"],
428 ]);
429 });
430
431 it("keeps two deleted accounts apart rather than counting them as one reviewer", () => {
432 expect(
433 latestPullRequestReviewOutcomes([
434 {
435 ...TIMELINE_SOURCE.comments[0]!,
436 id: "r1",
437 kind: "review",
438 author: null,
439 reviewState: "APPROVED",
440 createdAt: "2026-07-01T00:00:00Z",
441 },
442 {
443 ...TIMELINE_SOURCE.comments[0]!,
444 id: "r2",
445 kind: "review",
446 author: null,
447 reviewState: "APPROVED",
448 createdAt: "2026-07-02T00:00:00Z",
449 },
450 ]),
451 ).toHaveLength(2);
452 });
453
454 it("gives every entry a key that separates the reviewers it kept apart", () => {
455 const entries = latestPullRequestReviewOutcomes([
456 {
457 ...TIMELINE_SOURCE.comments[0]!,
458 id: "r1",
459 kind: "review",
460 author: null,
461 reviewState: "APPROVED",
462 createdAt: "2026-07-01T00:00:00Z",
463 },
464 {
465 ...TIMELINE_SOURCE.comments[0]!,
466 id: "r2",
467 kind: "review",
468 author: null,
469 reviewState: "APPROVED",
470 createdAt: "2026-07-01T00:00:00Z",
471 },
472 ]);
473 // Same author (none) and the same instant, so only the review's own id tells them apart.
474 expect(new Set(entries.map((entry) => entry.key)).size).toBe(2);
475 });
476
477 it("calls a verdict stale once commits land after it, and current before that", () => {
478 const commits = [
479 { oid: "c0ffee", messageHeadline: "later work", committedDate: "2026-07-05T00:00:00Z" },
480 ];
481 const review = (createdAt: string): PullRequestComment => ({
482 ...TIMELINE_SOURCE.comments[0]!,
483 kind: "review",
484 reviewState: "APPROVED",
485 createdAt,
486 });
487
488 expect(
489 latestPullRequestReviewOutcomes([review("2026-07-01T00:00:00Z")], commits)[0]?.stale,
490 ).toBe(true);
491 expect(
492 latestPullRequestReviewOutcomes([review("2026-07-06T00:00:00Z")], commits)[0]?.stale,
493 ).toBe(false);
494 // Nothing to be overtaken by, so nothing is stale.
495 expect(latestPullRequestReviewOutcomes([review("2026-07-01T00:00:00Z")], [])[0]?.stale).toBe(
496 false,
497 );
498 });
499
500 it("measures staleness against the newest commit, not the last one listed", () => {
501 expect(
502 newestPullRequestCommitAt([
503 { oid: "a", messageHeadline: "", committedDate: "2026-07-09T00:00:00Z" },
504 { oid: "b", messageHeadline: "", committedDate: "2026-07-02T00:00:00Z" },
505 ]),
506 ).toBe("2026-07-09T00:00:00Z");
507 expect(newestPullRequestCommitAt([])).toBeNull();
508 });
509
510 it("orders instants rather than their text, so a UTC offset cannot invert them", () => {
511 // 01:00+02:00 is 23:00 the previous day, so as text it sorts after the Z stamp and in time
512 // it falls well before it.
513 expect(
514 newestPullRequestCommitAt([
515 { oid: "a", messageHeadline: "", committedDate: "2026-07-05T00:30:00Z" },
516 { oid: "b", messageHeadline: "", committedDate: "2026-07-05T01:00:00+02:00" },
517 ]),
518 ).toBe("2026-07-05T00:30:00Z");
519 expect(isPullRequestVerdictStale("2026-07-05T00:30:00Z", "2026-07-05T01:00:00+02:00")).toBe(
520 false,
521 );
522 // A timestamp nothing can parse is not a position, so it settles nothing either way.
523 expect(isPullRequestVerdictStale("2026-07-01T00:00:00Z", "not a date")).toBe(false);
524 expect(
525 newestPullRequestCommitAt([{ oid: "a", messageHeadline: "", committedDate: "not a date" }]),
526 ).toBeNull();
527 });
528
529 it("shows nothing for a reviewer whose verdict was dismissed", () => {
530 expect(
531 latestPullRequestReviewOutcomes([
532 {
533 ...TIMELINE_SOURCE.comments[0]!,
534 kind: "review",
535 reviewState: "APPROVED",
536 createdAt: "2026-07-01T00:00:00Z",
537 },
538 {
539 ...TIMELINE_SOURCE.comments[0]!,
540 id: "c2",
541 kind: "review",
542 reviewState: "DISMISSED",
543 createdAt: "2026-07-02T00:00:00Z",
544 },
545 ]),
546 ).toEqual([]);
547 });
548});
549
550describe("pull request timeline", () => {
551 it("orders creation, commits and comments newest first", () => {
552 // What happened last is what the reader opening the tab is asking about.
553 expect(buildPullRequestTimeline(TIMELINE_SOURCE).map((event) => event.id)).toEqual([
554 "c1",
555 "1baf7bdcafe",
556 "created",
557 ]);
558 });
559
560 it("carries the comment url, and leaves the events the host cannot address without one", () => {
561 const events = buildPullRequestTimeline({
562 ...TIMELINE_SOURCE,
563 comments: [{ ...TIMELINE_SOURCE.comments[0]!, url: "https://example.test/pull/1#c1" }],
564 });
565 expect(events.map((event) => event.url)).toEqual([
566 "https://example.test/pull/1#c1",
567 null,
568 null,
569 ]);
570 });
571
572 it("carries actors and review context into the presentation model", () => {
573 const commitAuthors = [
574 { login: "octocat", name: null, avatarUrl: "https://example.test/octocat.png" },
575 { login: "pair", name: "Pair Author", avatarUrl: null },
576 ];
577 const events = buildPullRequestTimeline({
578 ...TIMELINE_SOURCE,
579 commits: [{ ...TIMELINE_SOURCE.commits[0]!, authors: commitAuthors }],
580 comments: [
581 {
582 ...TIMELINE_SOURCE.comments[0]!,
583 kind: "review",
584 path: "src/app.ts",
585 reviewState: "APPROVED",
586 },
587 ],
588 });
589
590 expect(events.find((event) => event.kind === "commit")?.commitAuthors).toEqual(commitAuthors);
591 expect(events.find((event) => event.kind === "review")).toMatchObject({
592 actor: TIMELINE_SOURCE.comments[0]?.author,
593 path: "src/app.ts",
594 reviewState: "APPROVED",
595 });
596 });
597
598 it("carries each commit's line counts into its timeline event", () => {
599 const events = buildPullRequestTimeline({
600 ...TIMELINE_SOURCE,
601 commits: [{ ...TIMELINE_SOURCE.commits[0]!, additions: 12, deletions: 4 }],
602 });
603
604 expect(events.find((event) => event.kind === "commit")).toMatchObject({
605 additions: 12,
606 deletions: 4,
607 });
608 });
609
610 it("drops a body that is nothing but a bot's HTML comment, and keeps one that says more", () => {
611 const events = buildPullRequestTimeline({
612 ...TIMELINE_SOURCE,
613 comments: [
614 { ...TIMELINE_SOURCE.comments[0]!, body: "<!-- MURMUR_IGNORE -->" },
615 {
616 ...TIMELINE_SOURCE.comments[0]!,
617 id: "c2",
618 body: "<!-- summarize by coderabbit.ai -->\nNeeds a test.",
619 createdAt: "2026-07-04T00:00:00Z",
620 },
621 ],
622 });
623 expect(events.find((event) => event.id === "c1")?.body).toBeNull();
624 // Kept whole: the renderer drops the marker itself, and stripping it here would also
625 // strip an HTML comment a reviewer quoted inside a code fence.
626 expect(events.find((event) => event.id === "c2")?.body).toBe(
627 "<!-- summarize by coderabbit.ai -->\nNeeds a test.",
628 );
629 });
630
631 it("calls a comment markdown and a commit headline plain text", () => {
632 const events = buildPullRequestTimeline(TIMELINE_SOURCE);
633 // A headline reading `fix: drop *legacy* path` is not asking for emphasis.
634 expect(events.map((event) => [event.title.startsWith("Commit"), event.markdown])).toEqual(
635 expect.arrayContaining([[true, false]]),
636 );
637 expect(events.find((event) => event.id === "c1")?.markdown).toBe(true);
638 });
639
640 it("reports a merge rather than the close GitHub records alongside it", () => {
641 const events = buildPullRequestTimeline({
642 ...TIMELINE_SOURCE,
643 mergedAt: "2026-07-04T00:00:00Z",
644 closedAt: "2026-07-04T00:00:00Z",
645 });
646 // Newest first, so the terminal event opens the list rather than ending it.
647 expect(events[0]?.id).toBe("merged");
648 expect(events.some((event) => event.id === "closed")).toBe(false);
649 });
650
651 it("groups conversation sections without crossing commits or PR updates", () => {
652 const events = buildPullRequestTimeline({
653 ...TIMELINE_SOURCE,
654 comments: [
655 {
656 ...TIMELINE_SOURCE.comments[0]!,
657 id: "new-comment-1",
658 createdAt: "2026-07-04T00:00:00Z",
659 },
660 {
661 ...TIMELINE_SOURCE.comments[0]!,
662 id: "new-comment-2",
663 createdAt: "2026-07-03T00:00:00Z",
664 },
665 {
666 ...TIMELINE_SOURCE.comments[0]!,
667 id: "old-comment-1",
668 createdAt: "2026-07-01T12:00:00Z",
669 },
670 {
671 ...TIMELINE_SOURCE.comments[0]!,
672 id: "old-comment-2",
673 createdAt: "2026-07-01T06:00:00Z",
674 },
675 ],
676 mergedAt: "2026-07-05T00:00:00Z",
677 });
678
679 const rows = groupPullRequestTimelineConversations(events);
680 expect(
681 rows.map((row) =>
682 row.kind === "comments"
683 ? [row.kind, ...row.events.map((event) => event.id)]
684 : [row.kind, row.event.id],
685 ),
686 ).toEqual([
687 ["event", "merged"],
688 ["comments", "new-comment-1", "new-comment-2"],
689 ["event", "1baf7bdcafe"],
690 ["comments", "old-comment-1", "old-comment-2"],
691 ["event", "created"],
692 ]);
693 });
694
695 it("keeps a verdict out of the collapsed conversation it was submitted in", () => {
696 const events = buildPullRequestTimeline({
697 ...TIMELINE_SOURCE,
698 comments: [
699 { ...TIMELINE_SOURCE.comments[0]!, id: "chatter-1", createdAt: "2026-07-05T00:00:00Z" },
700 {
701 ...TIMELINE_SOURCE.comments[0]!,
702 id: "approval",
703 kind: "review",
704 body: "",
705 reviewState: "APPROVED",
706 createdAt: "2026-07-04T00:00:00Z",
707 },
708 { ...TIMELINE_SOURCE.comments[0]!, id: "chatter-2", createdAt: "2026-07-03T00:00:00Z" },
709 // A review without a verdict is ordinary conversation and still groups.
710 {
711 ...TIMELINE_SOURCE.comments[0]!,
712 id: "remark",
713 kind: "review",
714 reviewState: "COMMENTED",
715 createdAt: "2026-07-02T12:00:00Z",
716 },
717 ],
718 });
719
720 const rows = groupPullRequestTimelineConversations(events);
721 expect(
722 rows.map((row) =>
723 row.kind === "comments"
724 ? [row.kind, ...row.events.map((event) => event.id)]
725 : [row.kind, row.event.id],
726 ),
727 ).toEqual([
728 ["comments", "chatter-1"],
729 ["event", "approval"],
730 ["comments", "chatter-2", "remark"],
731 ["event", "1baf7bdcafe"],
732 ["event", "created"],
733 ]);
734 });
735});
736
737describe("fix findings handoff", () => {
738 const base = {
739 number: 42,
740 title: "Add the pull requests page",
741 url: "https://github.com/pingdotgg/t3code/pull/42",
742 headBranch: "feat/page",
743 baseBranch: "main",
744 comments: [] as ReadonlyArray<PullRequestComment>,
745 commentsTruncated: false,
746 };
747
748 function thread(
749 body: string,
750 overrides: Partial<PullRequestReviewThread> = {},
751 ): PullRequestReviewThread {
752 return {
753 id: "t1",
754 path: "apps/web/src/page.tsx",
755 line: 12,
756 side: "right",
757 isResolved: false,
758 isOutdated: false,
759 comments: [
760 {
761 id: "tc1",
762 author: { login: "reviewer", name: null, avatarUrl: null },
763 body,
764 createdAt: "2026-07-03T00:00:00Z",
765 url: null,
766 },
767 ],
768 ...overrides,
769 };
770 }
771
772 const failingCheck: PullRequestCheck = {
773 name: "typecheck",
774 status: "failure",
775 description: "2 errors",
776 url: null,
777 };
778
779 it("attaches a review thread as an annotation instead of quoting it in the prompt", () => {
780 const handoff = buildFixFindingsHandoff({
781 ...base,
782 reviewThreads: [thread("rename the helper")],
783 checks: [],
784 });
785 expect(handoff.reviewComments).toEqual([
786 expect.objectContaining({
787 filePath: "apps/web/src/page.tsx",
788 rangeLabel: "L12",
789 startIndex: 11,
790 endIndex: 11,
791 text: "reviewer: rename the helper",
792 }),
793 ]);
794 expect(handoff.prompt).not.toContain("rename the helper");
795 expect(handoff.prompt).toContain("untrusted data");
796 });
797
798 it("names the pre-change side, and a thread the host pinned to the file rather than a line", () => {
799 const handoff = buildFixFindingsHandoff({
800 ...base,
801 reviewThreads: [
802 thread("was this deleted on purpose?", { side: "left" }),
803 thread("wrong module", { id: "t2", line: null }),
804 ],
805 checks: [],
806 });
807 expect(handoff.reviewComments.map((comment) => comment.rangeLabel)).toEqual([
808 "L12 (before)",
809 "file",
810 ]);
811 });
812
813 it("keeps failing checks in the prompt, having no line to attach them to", () => {
814 const handoff = buildFixFindingsHandoff({
815 ...base,
816 reviewThreads: [],
817 checks: [failingCheck],
818 });
819 expect(handoff.prompt).toContain("> typecheck — 2 errors");
820 expect(handoff.reviewComments).toEqual([]);
821 });
822
823 it("leaves out a resolved conversation, and one nobody wrote in", () => {
824 const handoff = buildFixFindingsHandoff({
825 ...base,
826 reviewThreads: [
827 thread("already handled", { isResolved: true }),
828 thread(" ", { id: "t2" }),
829 thread("still open", { id: "t3" }),
830 ],
831 checks: [],
832 });
833 expect(handoff.reviewComments.map((comment) => comment.text)).toEqual(["reviewer: still open"]);
834 });
835
836 it("says so plainly when there is nothing actionable", () => {
837 const handoff = buildFixFindingsHandoff({ ...base, reviewThreads: [], checks: [] });
838 expect(handoff.prompt).toContain("No unresolved review findings were returned");
839 expect(handoff.reviewComments).toEqual([]);
840 });
841
842 it("bounds a hostile review body instead of attaching it whole", () => {
843 const handoff = buildFixFindingsHandoff({
844 ...base,
845 reviewThreads: [thread("x".repeat(5_000))],
846 checks: [],
847 });
848 expect(handoff.reviewComments[0]?.text).toHaveLength(1_000);
849 expect(handoff.reviewComments[0]?.text.endsWith("...")).toBe(true);
850 });
851
852 it("keeps the newest threads and the failing checks when it has to cut", () => {
853 const handoff = buildFixFindingsHandoff({
854 ...base,
855 reviewThreads: Array.from({ length: 25 }, (_, index) =>
856 thread(`finding ${index}`, { id: `t${index}` }),
857 ),
858 checks: [failingCheck],
859 });
860 // Oldest threads are dropped rather than the current failure and the recent feedback.
861 const texts = handoff.reviewComments.map((comment) => comment.text);
862 expect(texts).toHaveLength(19);
863 expect(texts.at(-1)).toBe("reviewer: finding 24");
864 expect(texts).not.toContain("reviewer: finding 0");
865 expect(handoff.prompt).toContain("typecheck");
866 expect(handoff.prompt).toContain("6 further findings were omitted");
867 });
868});
869
870describe("findings that cannot be attached", () => {
871 const base = {
872 number: 42,
873 title: "Add the pull requests page",
874 url: "https://github.com/pingdotgg/t3code/pull/42",
875 headBranch: "feat/page",
876 baseBranch: "main",
877 reviewThreads: [] as ReadonlyArray<PullRequestReviewThread>,
878 checks: [] as ReadonlyArray<PullRequestCheck>,
879 commentsTruncated: false,
880 };
881
882 const review: PullRequestComment = {
883 id: "r1",
884 kind: "review",
885 author: { login: "julius", name: null, avatarUrl: null },
886 body: "This breaks SSO auth, revert the middleware change.",
887 createdAt: "2026-07-01T00:00:00Z",
888 url: null,
889 path: null,
890 reviewState: "CHANGES_REQUESTED",
891 };
892
893 it("carries a review submitted with words but no line, which has nothing to attach to", () => {
894 const handoff = buildFixFindingsHandoff({ ...base, comments: [review] });
895
896 // It has no file and no line, so it travels the way a failing check does rather than
897 // being dropped for lacking somewhere to point.
898 expect(handoff.reviewComments).toEqual([]);
899 expect(handoff.prompt).toContain("revert the middleware change");
900 expect(handoff.prompt).not.toContain("No unresolved review findings");
901 });
902
903 it("carries a host's line comments when it reports no threads at all", () => {
904 // Azure DevOps has no diff to pin a conversation to, so every remark arrives this way.
905 const handoff = buildFixFindingsHandoff({
906 ...base,
907 comments: [{ ...review, id: "a1", kind: "review-comment", path: "src/app.ts" }],
908 });
909
910 expect(handoff.prompt).toContain("src/app.ts");
911 expect(handoff.prompt).toContain("revert the middleware change");
912 });
913
914 it("does not repeat a remark that was already attached as a thread", () => {
915 const attachedId = "t1c1";
916 const handoff = buildFixFindingsHandoff({
917 ...base,
918 reviewThreads: [
919 {
920 id: "t1",
921 path: "src/app.ts",
922 line: 12,
923 side: "right",
924 isResolved: false,
925 isOutdated: false,
926 comments: [
927 {
928 id: attachedId,
929 author: { login: "julius", name: null, avatarUrl: null },
930 body: "rename the helper",
931 createdAt: "2026-07-01T00:00:00Z",
932 url: null,
933 },
934 ],
935 },
936 ],
937 comments: [{ ...review, id: attachedId, kind: "review-comment", body: "rename the helper" }],
938 });
939
940 expect(handoff.reviewComments).toHaveLength(1);
941 expect(handoff.prompt).not.toContain("rename the helper");
942 });
943});
944
945describe("one finding handed over on its own", () => {
946 const base = {
947 number: 42,
948 title: "Add the pull requests page",
949 url: "https://github.com/pingdotgg/t3code/pull/42",
950 headBranch: "feat/page",
951 baseBranch: "main",
952 };
953
954 const reviewThread: PullRequestReviewThread = {
955 id: "t1",
956 path: "apps/web/src/page.tsx",
957 line: 12,
958 side: "right",
959 isResolved: true,
960 isOutdated: false,
961 comments: [
962 {
963 id: "tc1",
964 author: { login: "reviewer", name: null, avatarUrl: null },
965 body: "rename the helper",
966 createdAt: "2026-07-03T00:00:00Z",
967 url: null,
968 },
969 ],
970 };
971
972 it("attaches a thread as its own annotation, resolved or not", () => {
973 // The bulk handoff skips resolved threads as finished work. Pressing the button on one is
974 // an explicit request for that thread, so it is not second-guessed.
975 const handoff = buildFixFindingHandoff({
976 ...base,
977 finding: { kind: "thread", thread: reviewThread },
978 });
979 expect(handoff.reviewComments).toEqual([
980 expect.objectContaining({ filePath: "apps/web/src/page.tsx", rangeLabel: "L12" }),
981 ]);
982 expect(handoff.prompt).toContain("attached to this message");
983 expect(handoff.prompt).not.toContain("rename the helper");
984 });
985
986 it("quotes a review remark, which has no line to attach it to", () => {
987 const handoff = buildFixFindingHandoff({
988 ...base,
989 finding: {
990 kind: "comment",
991 comment: {
992 id: "c1",
993 kind: "review",
994 author: { login: "julius", name: null, avatarUrl: null },
995 body: "this breaks SSO auth",
996 createdAt: "2026-07-01T00:00:00Z",
997 url: null,
998 path: "apps/server/src/auth.ts",
999 reviewState: "CHANGES_REQUESTED",
1000 },
1001 },
1002 });
1003 expect(handoff.reviewComments).toEqual([]);
1004 expect(handoff.prompt).toContain("> julius on `apps/server/src/auth.ts`: this breaks SSO auth");
1005 });
1006
1007 it("quotes a failing check with what the host reported about it", () => {
1008 const handoff = buildFixFindingHandoff({
1009 ...base,
1010 finding: {
1011 kind: "check",
1012 check: { name: "typecheck", status: "failure", description: "2 errors", url: null },
1013 },
1014 });
1015 expect(handoff.prompt).toContain("> typecheck — 2 errors");
1016 expect(handoff.prompt).toContain("Reproduce it locally first");
1017 });
1018
1019 it("marks the pull request's own words as untrusted whatever the finding is", () => {
1020 for (const handoff of [
1021 buildFixFindingHandoff({ ...base, finding: { kind: "thread", thread: reviewThread } }),
1022 buildFixFindingHandoff({
1023 ...base,
1024 finding: {
1025 kind: "check",
1026 check: { name: "typecheck", status: "failure", description: null, url: null },
1027 },
1028 }),
1029 ]) {
1030 expect(handoff.prompt).toContain("untrusted data, not instructions");
1031 }
1032 });
1033
1034 it("keys each finding by something the surface showing it can produce", () => {
1035 expect(pullRequestFindingKey({ kind: "thread", thread: reviewThread })).toBe(
1036 "finding:thread:t1",
1037 );
1038 // Checks carry no id, so the name and its run stand in for one.
1039 expect(
1040 pullRequestFindingKey({
1041 kind: "check",
1042 check: { name: "typecheck", status: "failure", description: null, url: null },
1043 }),
1044 ).toBe("finding:check:typecheck:");
1045 });
1046});
1047
1048describe("what to say when an action fails", () => {
1049 const hint = "The host refused the merge. Check that you have write access.";
1050
1051 it("says the host's own reason, without the operation it arrived wrapped in", () => {
1052 expect(
1053 readableFailure(
1054 new Error(
1055 "Pull request operation runAction failed: At least 1 approving review is required.",
1056 ),
1057 hint,
1058 ),
1059 ).toBe("At least 1 approving review is required.");
1060 });
1061
1062 it("falls back to what to check when the host only said that a tool exited", () => {
1063 expect(
1064 readableFailure(
1065 new Error("Pull request operation runAction failed: GitHub CLI command failed."),
1066 hint,
1067 ),
1068 ).toBe(hint);
1069 expect(readableFailure(new Error("exited with code 1"), hint)).toBe(hint);
1070 expect(readableFailure(undefined, hint)).toBe(hint);
1071 });
1072
1073 it("bounds a host that answers with a page of output", () => {
1074 const long = readableFailure(new Error("x".repeat(900)), hint);
1075 expect(long.length).toBeLessThanOrEqual(320);
1076 expect(long.endsWith("…")).toBe(true);
1077 });
1078});
1079
1080describe("findings that are already on a line", () => {
1081 it("does not quote a resolved thread's comment as a remark with nowhere to hang", () => {
1082 const resolved: PullRequestReviewThread = {
1083 id: "t-resolved",
1084 path: "apps/web/src/page.tsx",
1085 line: 4,
1086 side: "right",
1087 isResolved: true,
1088 isOutdated: false,
1089 comments: [
1090 {
1091 id: "settled",
1092 author: { login: "reviewer", name: null, avatarUrl: null },
1093 body: "this was already fixed",
1094 createdAt: "2026-07-02T00:00:00Z",
1095 url: null,
1096 },
1097 ],
1098 };
1099 const handoff = buildFixFindingsHandoff({
1100 number: 42,
1101 title: "Add the pull requests page",
1102 url: "https://github.com/pingdotgg/t3code/pull/42",
1103 headBranch: "feat/page",
1104 baseBranch: "main",
1105 reviewThreads: [resolved],
1106 // The conversation carries every thread's comments now, resolved ones included.
1107 comments: [
1108 {
1109 id: "settled",
1110 kind: "review-comment",
1111 author: { login: "reviewer", name: null, avatarUrl: null },
1112 body: "this was already fixed",
1113 createdAt: "2026-07-02T00:00:00Z",
1114 url: null,
1115 path: "apps/web/src/page.tsx",
1116 reviewState: null,
1117 },
1118 ],
1119 checks: [],
1120 commentsTruncated: false,
1121 });
1122 expect(handoff.prompt).not.toContain("this was already fixed");
1123 expect(handoff.reviewComments).toEqual([]);
1124 });
1125});
1126
1127describe("asking about a change rather than working on it", () => {
1128 const base = {
1129 number: 42,
1130 title: "Add the pull requests page",
1131 url: "https://github.com/pingdotgg/t3code/pull/42",
1132 headBranch: "feat/page",
1133 baseBranch: "main",
1134 state: "open" as const,
1135 isDraft: false,
1136 };
1137
1138 it.each(["", "Please consider "])("preserves PR plan feedback with prose %j", (prose) => {
1139 const comment = buildPullRequestReferenceContext(base);
1140 const draftText = prose + formatInlineContextReference(reviewCommentContextReference(comment));
1141 const submission = resolvePlanFollowUpSubmission({ draftText, planMarkdown: "# Plan" });
1142 const context = buildMessageContext({
1143 terminalContexts: [],
1144 previewAnnotations: [],
1145 reviewComments: [comment],
1146 });
1147 expect(submission).toEqual({ text: draftText, interactionMode: "plan" });
1148 expect(context?.records[0]).toMatchObject({ pullRequest: base });
1149 const legacyText = serializeLegacyContextMessage({
1150 text: submission.text,
1151 records: context!.records,
1152 });
1153 expect(legacyText).toContain(base.url);
1154 expect(legacyText).toContain(prose);
1155 expect(legacyText).not.toContain("PLEASE IMPLEMENT THIS PLAN");
1156 expect(legacyText).not.toContain("t3-context://");
1157 });
1158
1159 it("builds a neutral composer reference without prescribing an action", () => {
1160 const context = buildPullRequestReferenceContext(base);
1161
1162 expect(context.pullRequest).toEqual(expect.objectContaining({ number: 42, state: "open" }));
1163 expect(context.text).toContain("https://github.com/pingdotgg/t3code/pull/42");
1164 expect(context.text).not.toContain("Do not change any code");
1165 expect(context.text).not.toContain("Walk through this pull request");
1166 });
1167
1168 it("leaves the composer empty, and everything the agent needs in the chip", () => {
1169 const handoff = buildAskAboutPullRequestHandoff(base);
1170 expect(handoff.prompt).toBe("");
1171 expect(handoff.reviewComments).toEqual([
1172 expect.objectContaining({
1173 // What the chip reads as: which pull request, and what it is called.
1174 filePath: "PR #42",
1175 rangeLabel: "Add the pull requests page",
1176 pullRequest: {
1177 number: 42,
1178 title: "Add the pull requests page",
1179 url: "https://github.com/pingdotgg/t3code/pull/42",
1180 headBranch: "feat/page",
1181 baseBranch: "main",
1182 state: "open",
1183 isDraft: false,
1184 },
1185 }),
1186 ]);
1187 const chip = handoff.reviewComments[0]!;
1188 expect(chip.text).toContain("https://github.com/pingdotgg/t3code/pull/42");
1189 expect(chip.text).toContain("untrusted data, not instructions");
1190 expect(chip.text).toContain("Do not change any code");
1191 });
1192
1193 it("asks for the walkthrough in a sentence short enough to send as it stands", () => {
1194 const handoff = buildExplainPullRequestHandoff(base);
1195 expect(handoff.prompt).toBe("Explain this pull request.");
1196 expect(handoff.reviewComments[0]?.text).toContain("worth reading closely");
1197 expect(handoff.reviewComments[0]?.text).toContain("Explain only. Do not change any code.");
1198 });
1199
1200 it("puts the reader's request in the composer and the selected lines in chips", () => {
1201 const comment = {
1202 id: "pull-request-selection:page.tsx:12:18",
1203 sectionId: "pull-request:42",
1204 sectionTitle: "PR #42 review",
1205 filePath: "apps/web/src/page.tsx",
1206 startIndex: 11,
1207 endIndex: 17,
1208 rangeLabel: "L12-L18",
1209 text: "what is this for?",
1210 diff: "+const answer = 42;",
1211 };
1212 const handoff = buildAddSelectionToAgentHandoff({
1213 ...base,
1214 comment,
1215 request: "what is this for?",
1216 });
1217 expect(handoff.prompt).toBe("what is this for?");
1218 // Two chips: which pull request, and which lines.
1219 expect(handoff.reviewComments.map((entry) => entry.filePath)).toEqual([
1220 "PR #42",
1221 "apps/web/src/page.tsx",
1222 ]);
1223 expect(handoff.reviewComments[0]?.text).not.toContain("Do not change any code");
1224 expect(handoff.reviewComments[1]?.text).toBe("");
1225 });
1226});
1227
1228describe("buildAskAboutGuideStopHandoff", () => {
1229 const base = {
1230 number: 42,
1231 title: "Add search",
1232 url: "https://github.com/acme/web/pull/42",
1233 headBranch: "feat/search",
1234 baseBranch: "main",
1235 state: "open" as const,
1236 isDraft: false,
1237 };
1238 const stop = {
1239 index: 1,
1240 title: "The index is built on write",
1241 summary: "Writes now update the index.",
1242 attention: "A tradeoff: writes get slower.",
1243 excerpts: [
1244 { path: "src/index.ts", side: "new" as const, start: 10, end: 20 },
1245 { path: "src/old.ts", side: "old" as const, start: 3, end: 5 },
1246 ],
1247 };
1248
1249 it("carries the stop beside the pull request and asks only for an answer", () => {
1250 const handoff = buildAskAboutGuideStopHandoff({ ...base, stop, question: "Why on write?" });
1251 expect(handoff.prompt).toBe("Why on write?");
1252 expect(handoff.reviewComments.map((entry) => entry.filePath)).toEqual([
1253 "PR #42",
1254 "Guide stop 2",
1255 ]);
1256 expect(handoff.reviewComments[0]?.text).toContain("Do not change any code");
1257 const chip = handoff.reviewComments[1]?.text ?? "";
1258 expect(chip).toContain("Writes now update the index.");
1259 expect(chip).toContain("A tradeoff: writes get slower.");
1260 expect(chip).toContain("- src/index.ts lines 10-20\n");
1261 expect(chip).toContain("- src/old.ts lines 3-5 (removed; numbered as in the base branch)");
1262 });
1263
1264 it("leaves the question to the reader when none was suggested", () => {
1265 const handoff = buildAskAboutGuideStopHandoff({
1266 ...base,
1267 stop: { ...stop, attention: null, excerpts: [] },
1268 question: "",
1269 });
1270 expect(handoff.prompt).toBe("");
1271 expect(handoff.reviewComments[1]?.text).not.toContain("judge");
1272 expect(handoff.reviewComments[1]?.text).not.toContain("lines it showed");
1273 });
1274});
1275
1276describe("a second ask into the same composer", () => {
1277 const chip = (id: string): ReviewCommentContext => ({
1278 id,
1279 sectionId: "pull-request:42",
1280 sectionTitle: "PR #42",
1281 filePath: "PR #42",
1282 startIndex: 0,
1283 endIndex: 0,
1284 rangeLabel: "Add the pull requests page",
1285 text: "",
1286 diff: "",
1287 });
1288
1289 it("replaces what the last one left, chips included", () => {
1290 const next = handoffReviewComments(
1291 [chip("pull-request-context:41"), chip("pull-request-selection:page.tsx:1:2")],
1292 [chip("pull-request-context:42")],
1293 );
1294 expect(next.map((comment) => comment.id)).toEqual(["pull-request-context:42"]);
1295 });
1296
1297 it("keeps a reader's own pull request reference when a later handoff lands", () => {
1298 const own = buildPullRequestReferenceContext({
1299 number: 42,
1300 title: "Add the pull requests page",
1301 url: "https://github.com/pingdotgg/t3code/pull/42",
1302 headBranch: "feature",
1303 baseBranch: "main",
1304 state: "open" as const,
1305 isDraft: false,
1306 });
1307 const prompt = `Look at this. ${formatInlineContextReference(reviewCommentContextReference(own))} `;
1308
1309 expect(stripPullRequestHandoffReferences(prompt, [own])).toBe(prompt);
1310 expect(
1311 handoffReviewComments([own], [chip("pull-request-context:42")]).map((comment) => comment.id),
1312 ).toEqual([own.id, "pull-request-context:42"]);
1313 });
1314
1315 it("empties what the last ask left, so the two are never sent as one question", () => {
1316 const handed = "Explain this pull request.";
1317 expect(handoffPrompt({ prompt: handed, lastHandoffPrompt: handed }, "")).toBe("");
1318 });
1319
1320 it("removes the previous handoff chip before replacing its prompt", () => {
1321 const previous = chip("pull-request-context:42");
1322 const prompt = `Explain this pull request. ${formatInlineContextReference(
1323 reviewCommentContextReference(previous),
1324 )} `;
1325 expect(stripPullRequestHandoffReferences(prompt, [previous])).toBe(
1326 "Explain this pull request.",
1327 );
1328 });
1329
1330 it("keeps a handoff reference when the next action deliberately repeats it", () => {
1331 const previous = chip("pull-request-context:42");
1332 const prompt = formatInlineContextReference(reviewCommentContextReference(previous));
1333 expect(stripPullRequestHandoffReferences(prompt, [previous], new Set([previous.id]))).toBe(
1334 prompt,
1335 );
1336 });
1337
1338 it("replaces the last ask's prompt with this one's", () => {
1339 const handed = "Explain this pull request.";
1340 expect(handoffPrompt({ prompt: handed, lastHandoffPrompt: handed }, "Why the cache key?")).toBe(
1341 "Why the cache key?",
1342 );
1343 });
1344
1345 it("leaves a sentence the reader typed themselves where it is", () => {
1346 expect(
1347 handoffPrompt({ prompt: "check the migration first", lastHandoffPrompt: undefined }, ""),
1348 ).toBe("check the migration first");
1349 });
1350
1351 it("keeps what the reader wrote next to the chip an earlier ask left", () => {
1352 // The chip is still there, but these words are not the ones the hand-off wrote.
1353 expect(
1354 handoffPrompt({ prompt: "why is the cache keyed on the branch?", lastHandoffPrompt: "" }, ""),
1355 ).toBe("why is the cache keyed on the branch?");
1356 });
1357
1358 it("keeps an edit the reader made to the sentence they were handed", () => {
1359 expect(
1360 handoffPrompt(
1361 {
1362 prompt: "Explain this pull request, the caching especially.",
1363 lastHandoffPrompt: "Explain this pull request.",
1364 },
1365 "",
1366 ),
1367 ).toBe("Explain this pull request, the caching especially.");
1368 });
1369
1370 it("puts an ask under what the reader typed rather than over it", () => {
1371 expect(
1372 handoffPrompt(
1373 { prompt: "check the migration first", lastHandoffPrompt: undefined },
1374 "Explain this pull request.",
1375 ),
1376 ).toBe("check the migration first\n\nExplain this pull request.");
1377 });
1378
1379 it("replaces only its own sentence under text the reader typed", () => {
1380 expect(
1381 handoffPrompt(
1382 {
1383 prompt: "check the migration first\n\nExplain this pull request.",
1384 lastHandoffPrompt: "Explain this pull request.",
1385 },
1386 "Why the cache key?",
1387 ),
1388 ).toBe("check the migration first\n\nWhy the cache key?");
1389 });
1390
1391 it("takes back only its own sentence when the next ask is empty", () => {
1392 expect(
1393 handoffPrompt(
1394 {
1395 prompt: "check the migration first\n\nExplain this pull request.",
1396 lastHandoffPrompt: "Explain this pull request.",
1397 },
1398 "",
1399 ),
1400 ).toBe("check the migration first");
1401 });
1402
1403 it("keeps the lines the reader marked up in the thread themselves", () => {
1404 const own = chip("file-comment:3");
1405 const next = handoffReviewComments(
1406 [own, chip("pull-request-finding:t1")],
1407 [chip("pull-request-context:42")],
1408 );
1409 expect(next.map((comment) => comment.id)).toEqual([
1410 "file-comment:3",
1411 "pull-request-context:42",
1412 ]);
1413 });
1414});
1415
1416describe("how the branch stands against its base", () => {
1417 const detail = (overrides: Record<string, unknown> = {}) =>
1418 ({
1419 state: "open",
1420 mergeability: "mergeable",
1421 baseComparison: "behind",
1422 behindBy: 12,
1423 capabilities: { updateMethods: ["merge", "rebase"] },
1424 viewerPermissions: { updateMethods: ["merge", "rebase"] },
1425 ...overrides,
1426 }) as Parameters<typeof resolveBaseFreshness>[0];
1427
1428 it("offers both ways where the host and the reader both allow them", () => {
1429 expect(resolveBaseFreshness(detail())).toEqual({ behindBy: 12, methods: ["merge", "rebase"] });
1430 });
1431
1432 it("says nothing about a branch that is already current", () => {
1433 expect(resolveBaseFreshness(detail({ baseComparison: "up-to-date" }))).toBeNull();
1434 });
1435
1436 it("says nothing where the host could not compare, rather than claiming it is current", () => {
1437 expect(resolveBaseFreshness(detail({ baseComparison: "unknown" }))).toBeNull();
1438 expect(resolveBaseFreshness(detail({ baseComparison: undefined }))).toBeNull();
1439 });
1440
1441 it("leaves a conflicting branch to the conflicts row", () => {
1442 expect(resolveBaseFreshness(detail({ mergeability: "conflicting" }))).toBeNull();
1443 });
1444
1445 it("says nothing where the host has no merge verdict yet", () => {
1446 expect(resolveBaseFreshness(detail({ mergeability: "unknown" }))).toBeNull();
1447 });
1448
1449 it("says nothing about a merged or closed pull request", () => {
1450 expect(resolveBaseFreshness(detail({ state: "merged" }))).toBeNull();
1451 expect(resolveBaseFreshness(detail({ state: "closed" }))).toBeNull();
1452 });
1453
1454 it("narrows to what this reader may actually take", () => {
1455 expect(
1456 resolveBaseFreshness(detail({ viewerPermissions: { updateMethods: ["merge"] } }))?.methods,
1457 ).toEqual(["merge"]);
1458 });
1459
1460 it("still reports the news where the reader may take none of it", () => {
1461 // Somebody reading another account's pull request is told why it is blocked without being
1462 // offered a button the host would refuse.
1463 expect(resolveBaseFreshness(detail({ viewerPermissions: {} }))).toEqual({
1464 behindBy: 12,
1465 methods: [],
1466 });
1467 });
1468
1469 it("reports a count only where the host counted", () => {
1470 expect(resolveBaseFreshness(detail({ behindBy: undefined }))?.behindBy).toBeNull();
1471 });
1472});
1473
1474describe("pull request panel context beside a thread", () => {
1475 // Shapes copied from real threads: a thread that opened a stack holds the top layer as a
1476 // manual link and every lower layer as a "stack" link, with the legacy field pointing at
1477 // whichever one the server chose. Snapshots are null until the sync reactor's first pass.
1478 const link = (
1479 number: number,
1480 overrides: Partial<ThreadPullRequestLink> = {},
1481 ): ThreadPullRequestLink => ({
1482 host: "github.com",
1483 repository: "pingdotgg/t3code",
1484 number,
1485 url: `https://github.com/pingdotgg/t3code/pull/${number}`,
1486 source: "manual",
1487 linkedAt: "2026-09-09T00:00:00Z",
1488 snapshot: null,
1489 stack: null,
1490 ...overrides,
1491 });
1492 const surface = (
1493 number: number,
1494 overrides: Partial<Parameters<typeof pullRequestPanelContext>[1]> = {},
1495 ) => ({
1496 projectId: "proj-a",
1497 host: "github.com",
1498 repository: "pingdotgg/t3code",
1499 number,
1500 ...overrides,
1501 });
1502 const stackThread = {
1503 projectId: "proj-a",
1504 pullRequests: [
1505 link(10856),
1506 link(10832, { source: "stack" }),
1507 link(10677, { source: "stack" }),
1508 link(10854, { source: "stack" }),
1509 link(10855, { source: "stack" }),
1510 ],
1511 linkedPullRequest: {
1512 projectId: "proj-a",
1513 repository: "pingdotgg/t3code",
1514 number: 10856,
1515 url: "https://github.com/pingdotgg/t3code/pull/10856",
1516 },
1517 };
1518
1519 it("treats every layer of the thread's stack as its own, not only the one the legacy field names", () => {
1520 for (const number of [10856, 10832, 10677, 10854, 10855]) {
1521 expect(pullRequestPanelContext(stackThread, surface(number)), `#${number}`).toBe("thread");
1522 }
1523 });
1524
1525 it("does not let the legacy field decide when the thread holds a link list", () => {
1526 // Every prior regression flipped here: a server-side change to which link the legacy field
1527 // resolves to must not turn the thread's own second link into a checkout-able stranger.
1528 const thread = {
1529 projectId: "proj-a",
1530 pullRequests: [link(11101, { source: "created" }), link(11105, { source: "stack" })],
1531 linkedPullRequest: {
1532 projectId: "proj-a",
1533 repository: "pingdotgg/t3code",
1534 number: 11105,
1535 url: "https://github.com/pingdotgg/t3code/pull/11105",
1536 },
1537 };
1538 expect(pullRequestPanelContext(thread, surface(11101))).toBe("thread");
1539 expect(pullRequestPanelContext(thread, surface(11105))).toBe("thread");
1540 expect(pullRequestPanelContext({ ...thread, linkedPullRequest: null }, surface(11101))).toBe(
1541 "thread",
1542 );
1543 });
1544
1545 it("is the page for a pull request the thread is not linked to", () => {
1546 expect(pullRequestPanelContext(stackThread, surface(12320))).toBe("page");
1547 expect(pullRequestPanelContext(stackThread, surface(10856, { repository: "acme/web" }))).toBe(
1548 "page",
1549 );
1550 });
1551
1552 it("is the page under another project's checkout of the same repository", () => {
1553 expect(pullRequestPanelContext(stackThread, surface(10856, { projectId: "proj-b" }))).toBe(
1554 "page",
1555 );
1556 });
1557
1558 it("recognizes an unsynced manual link, and matches host and repository case-insensitively", () => {
1559 const thread = { projectId: "proj-a", pullRequests: [link(7, { host: "GitHub.com" })] };
1560 expect(pullRequestPanelContext(thread, surface(7, { repository: "PingDotGG/T3Code" }))).toBe(
1561 "thread",
1562 );
1563 expect(pullRequestPanelContext(thread, surface(7, { host: undefined }))).toBe("thread");
1564 expect(pullRequestPanelContext(thread, surface(7, { host: "gitlab.com" }))).toBe("page");
1565 });
1566
1567 it("ignores a dismissed stack member the reader chose not to see", () => {
1568 const thread = {
1569 projectId: "proj-a",
1570 pullRequests: [link(1), link(2, { source: "stack-dismissed" })],
1571 };
1572 expect(pullRequestPanelContext(thread, surface(2))).toBe("page");
1573 });
1574
1575 it("falls back to the legacy fields only for a thread with no link list", () => {
1576 const legacy = {
1577 projectId: "proj-a",
1578 repository: "pingdotgg/t3code",
1579 number: 3,
1580 url: "https://github.com/pingdotgg/t3code/pull/3",
1581 };
1582 expect(
1583 pullRequestPanelContext({ projectId: "proj-a", linkedPullRequest: legacy }, surface(3)),
1584 ).toBe("thread");
1585 expect(
1586 pullRequestPanelContext({ projectId: "proj-a", branchPullRequest: legacy }, surface(3)),
1587 ).toBe("thread");
1588 expect(pullRequestPanelContext({ projectId: "proj-a", pullRequests: [] }, surface(3))).toBe(
1589 "page",
1590 );
1591 expect(pullRequestPanelContext({ projectId: null }, surface(3))).toBe("page");
1592 });
1593
1594 describe("the Pull request entry's target", () => {
1595 const legacy = (number: number) => ({
1596 projectId: ProjectId.make("proj-a"),
1597 repository: "pingdotgg/t3code",
1598 number,
1599 url: `https://github.com/pingdotgg/t3code/pull/${number}`,
1600 });
1601
1602 it("opens a linked pull request the legacy field never named, ahead of the branch PR", () => {
1603 expect(
1604 threadPullRequestPanelTarget({
1605 projectId: "proj-a",
1606 pullRequests: [link(15046, { source: "agent" })],
1607 linkedPullRequest: null,
1608 branchPullRequest: legacy(30),
1609 }),
1610 ).toEqual({ ...legacy(15046), host: "github.com" });
1611 });
1612
1613 it("reuses the legacy reference only for the same pull request on the same host", () => {
1614 const linkedPullRequest = legacy(15046);
1615 const onHost = (host: string) =>
1616 threadPullRequestPanelTarget({
1617 projectId: "proj-a",
1618 pullRequests: [link(15046, { host, url: `https://${host}/pingdotgg/t3code/pull/15046` })],
1619 linkedPullRequest,
1620 });
1621 expect(onHost("github.com")).toBe(linkedPullRequest);
1622 expect(onHost("github.example.com")).toMatchObject({ host: "github.example.com" });
1623 });
1624
1625 it("falls back to the branch pull request when the thread holds no link", () => {
1626 expect(
1627 threadPullRequestPanelTarget({ projectId: "proj-a", branchPullRequest: legacy(3) }),
1628 ).toEqual(legacy(3));
1629 expect(threadPullRequestPanelTarget({ projectId: "proj-a", pullRequests: [] })).toBeNull();
1630 });
1631 });
1632});
1633
1634describe("which actions need the host read again after they run", () => {
1635 it("classifies every action the contract knows about", () => {
1636 // Imported from the contract rather than hand-listed, so a new PullRequestAction fails this
1637 // test until somebody decides which side of the diff it belongs on.
1638 expect(PullRequestAction.literals.map(pullRequestActionNeedsHostRefresh)).toEqual(
1639 PullRequestAction.literals.map(
1640 (action) => action === "update-branch" || action === "approve-workflows",
1641 ),
1642 );
1643 });
1644
1645 it("sends update-branch back to the host, having moved the head commit", () => {
1646 expect(pullRequestActionNeedsHostRefresh("update-branch")).toBe(true);
1647 });
1648
1649 it("leaves every action that only changes metadata to the cheaper detail refresh", () => {
1650 for (const action of [
1651 "ready",
1652 "draft",
1653 "close",
1654 "reopen",
1655 "enable-auto-merge",
1656 "disable-auto-merge",
1657 "merge",
1658 "revert",
1659 "delete-branch",
1660 ] as const) {
1661 expect(pullRequestActionNeedsHostRefresh(action)).toBe(false);
1662 }
1663 });
1664});
1665
1666describe("when the source branch can be deleted", () => {
1667 const branchDetail = (
1668 state: PullRequestState,
1669 overrides: { deleteBranch?: boolean; allowed?: boolean } = {},
1670 ) => ({
1671 state,
1672 capabilities: {
1673 deleteBranch: overrides.deleteBranch ?? true,
1674 actions: ["delete-branch" as const],
1675 },
1676 viewerPermissions: {
1677 actions: overrides.allowed === false ? [] : (["delete-branch"] as const),
1678 },
1679 });
1680
1681 it("offers it only after the pull request is merged or closed", () => {
1682 expect(canDeletePullRequestBranch(branchDetail("merged"))).toBe(true);
1683 expect(canDeletePullRequestBranch(branchDetail("closed"))).toBe(true);
1684 expect(canDeletePullRequestBranch(branchDetail("open"))).toBe(false);
1685 });
1686
1687 it("needs both the host capability and the viewer's permission", () => {
1688 expect(canDeletePullRequestBranch(branchDetail("merged", { deleteBranch: false }))).toBe(false);
1689 expect(canDeletePullRequestBranch(branchDetail("merged", { allowed: false }))).toBe(false);
1690 });
1691});
1692
1693describe("the compact row's single action slot", () => {
1694 const check = (status: PullRequestCheck["status"]): PullRequestCheck => ({
1695 name: "ci",
1696 status,
1697 description: null,
1698 url: null,
1699 });
1700 const openDetail = (
1701 overrides: Partial<Parameters<typeof resolveThreadPanelPullRequestAction>[0] & object> = {},
1702 ) =>
1703 ({
1704 state: "open",
1705 isDraft: false,
1706 mergeability: "mergeable",
1707 capabilities: {
1708 actions: ["merge", "ready", "draft", "close", "reopen"],
1709 mergeMethods: ["merge", "squash"],
1710 } as unknown as PullRequestDetailView["capabilities"],
1711 viewerPermissions: {
1712 actions: ["merge", "ready", "draft", "close", "reopen"],
1713 } as unknown as PullRequestDetailView["viewerPermissions"],
1714 mergeCapabilities: { merge: true, squash: true, rebase: false },
1715 checks: [check("success")],
1716 ...overrides,
1717 }) as NonNullable<Parameters<typeof resolveThreadPanelPullRequestAction>[0]>;
1718
1719 it("offers Merge only for a clean pull request whose checks pass", () => {
1720 expect(resolveThreadPanelPullRequestAction(openDetail())).toBe("merge");
1721 expect(resolveThreadPanelPullRequestAction(openDetail({ checks: [] }))).toBe("merge");
1722 });
1723
1724 it("holds the slot while checks run rather than offering a merge that races them", () => {
1725 expect(
1726 resolveThreadPanelPullRequestAction(
1727 openDetail({ checks: [check("success"), check("pending")] }),
1728 ),
1729 ).toBeNull();
1730 expect(
1731 resolveThreadPanelPullRequestAction(openDetail({ checks: [check("action-required")] })),
1732 ).toBeNull();
1733 });
1734
1735 it("ranks conflicts above everything, then draft, then failing checks", () => {
1736 expect(
1737 resolveThreadPanelPullRequestAction(
1738 openDetail({ mergeability: "conflicting", isDraft: true, checks: [check("failure")] }),
1739 ),
1740 ).toBe("resolve");
1741 expect(
1742 resolveThreadPanelPullRequestAction(
1743 openDetail({ isDraft: true, checks: [check("failure")] }),
1744 ),
1745 ).toBe("ready");
1746 expect(
1747 resolveThreadPanelPullRequestAction(
1748 openDetail({ checks: [check("failure"), check("pending")] }),
1749 ),
1750 ).toBe("fix");
1751 });
1752
1753 it("offers nothing the viewer may not do, and nothing on settled pull requests", () => {
1754 expect(
1755 resolveThreadPanelPullRequestAction(
1756 openDetail({
1757 viewerPermissions: {
1758 actions: [],
1759 } as unknown as PullRequestDetailView["viewerPermissions"],
1760 }),
1761 ),
1762 ).toBeNull();
1763 expect(resolveThreadPanelPullRequestAction(openDetail({ state: "merged" }))).toBeNull();
1764 expect(resolveThreadPanelPullRequestAction(null)).toBeNull();
1765 });
1766
1767 it("describes every live facet of the checks at once", () => {
1768 expect(describePullRequestChecks([])).toBe("No checks reported");
1769 expect(describePullRequestChecks([check("success"), check("success")])).toBe(
1770 "All checks passed",
1771 );
1772 expect(describePullRequestChecks([check("success"), check("skipped")])).toBe("1 of 2 passing");
1773 expect(
1774 describePullRequestChecks([
1775 ...Array.from({ length: 7 }, () => check("pending")),
1776 ...Array.from({ length: 8 }, () => check("success")),
1777 check("failure"),
1778 ]),
1779 ).toBe("7 of 16 running · 1 failed");
1780 expect(describePullRequestChecks([check("failure"), check("success")])).toBe("1 of 2 failing");
1781 expect(describePullRequestChecks([check("action-required")])).toBe("1 of 1 awaiting action");
1782 expect(describePullRequestChecks([check("action-required"), check("failure")])).toBe(
1783 "1 of 2 awaiting action · 1 failed",
1784 );
1785 });
1786
1787 it("reads the checks as one word, failing outranking running", () => {
1788 expect(classifyPullRequestChecks([])).toBe("none");
1789 expect(classifyPullRequestChecks([check("success"), check("skipped")])).toBe("passing");
1790 expect(classifyPullRequestChecks([check("success"), check("pending")])).toBe("pending");
1791 expect(classifyPullRequestChecks([check("pending"), check("cancelled")])).toBe("failing");
1792 });
1793});
1794
1795describe("cached pull request detail", () => {
1796 const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 };
1797 const detail = (overrides: Partial<PullRequestDetail> = {}): PullRequestDetail =>
1798 ({
1799 provider: SourceControlProviderKind.make("github"),
1800 capabilities: {
1801 diff: true,
1802 comment: true,
1803 actions: ["merge"],
1804 mergeMethods: ["merge"],
1805 search: true,
1806 review: {
1807 inlineComment: true,
1808 reply: true,
1809 resolve: true,
1810 verdicts: ["comment", "approve", "request-changes"],
1811 },
1812 reviewers: { request: true, listCandidates: true },
1813 },
1814 viewerPermissions: {
1815 actions: ["merge"],
1816 comment: true,
1817 resolve: true,
1818 verdicts: ["comment", "approve", "request-changes"],
1819 requestReviewers: true,
1820 },
1821 projectId: "project-1",
1822 projectTitle: "web",
1823 workspaceRoot: "/repo",
1824 repository: "acme/web",
1825 number: 7,
1826 title: "Cache the title",
1827 body: "who made it",
1828 url: "https://github.com/acme/web/pull/7",
1829 author: { login: "octocat", name: null, avatarUrl: "https://avatars.example/octocat" },
1830 state: "open",
1831 isDraft: false,
1832 mergeability: "mergeable",
1833 additions: 12,
1834 deletions: 3,
1835 changedFiles: 2,
1836 headBranch: "feat/cache",
1837 baseBranch: "main",
1838 createdAt: "2026-07-01T00:00:00.000Z",
1839 updatedAt: "2026-07-02T00:00:00.000Z",
1840 mergedAt: null,
1841 closedAt: null,
1842 reviewers: [],
1843 labels: [],
1844 checks: [],
1845 mergeCapabilities: { merge: true, squash: true, rebase: true },
1846 ...overrides,
1847 }) as PullRequestDetail;
1848
1849 const makeStorage = () => {
1850 const held = new Map<string, string>();
1851 return {
1852 getItem: (key: string) => held.get(key) ?? null,
1853 setItem: (key: string, value: string) => void held.set(key, value),
1854 };
1855 };
1856
1857 it("hydrates the last title, author, and counts so a reopen does not ghost the tab", () => {
1858 const storage = makeStorage();
1859 writePullRequestDetailSnapshot(storage, "env-1", reference, detail());
1860 const snapshot = readPullRequestDetailSnapshot(storage, "env-1", reference);
1861 expect(snapshot?.title).toBe("Cache the title");
1862 expect(snapshot?.author?.login).toBe("octocat");
1863 expect(snapshot?.additions).toBe(12);
1864 expect(snapshot?.deletions).toBe(3);
1865 });
1866
1867 it("reuses a host-qualified snapshot when reopening a thread link without a host", () => {
1868 const storage = makeStorage();
1869 writePullRequestDetailSnapshot(
1870 storage,
1871 "env-1",
1872 { ...reference, host: "github.com" },
1873 detail(),
1874 );
1875 const resolved = resolvePullRequestReferenceHost(reference, {
1876 canonicalKey: "github.com/acme/web",
1877 locator: {
1878 source: "git-remote",
1879 remoteName: "origin",
1880 remoteUrl: "https://github.com/acme/web.git",
1881 },
1882 provider: SourceControlProviderKind.make("github"),
1883 });
1884 expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(
1885 "Cache the title",
1886 );
1887 const explicit = { ...reference, host: "github.example.com" };
1888 expect(
1889 resolvePullRequestReferenceHost(explicit, {
1890 canonicalKey: "github.com/acme/web",
1891 locator: {
1892 source: "git-remote",
1893 remoteName: "origin",
1894 remoteUrl: "https://github.com/acme/web.git",
1895 },
1896 }),
1897 ).toBe(explicit);
1898 });
1899
1900 it("leaves server-resolved Azure SSH references unchanged", () => {
1901 expect(
1902 resolvePullRequestReferenceHost(reference, {
1903 canonicalKey: "ssh.dev.azure.com/v3/org/project/web",
1904 locator: {
1905 source: "git-remote",
1906 remoteName: "origin",
1907 remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web",
1908 },
1909 provider: SourceControlProviderKind.make("azure-devops"),
1910 }),
1911 ).toBe(reference);
1912 expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference);
1913 });
1914
1915 it("hydrates legacy hostless snapshots only for the matching host", () => {
1916 const storage = makeStorage();
1917 writePullRequestDetailSnapshot(storage, "env-1", reference, detail());
1918 expect(
1919 readPullRequestDetailSnapshot(storage, "env-1", { ...reference, host: "github.com" })?.title,
1920 ).toBe("Cache the title");
1921 expect(
1922 readPullRequestDetailSnapshot(storage, "env-1", {
1923 ...reference,
1924 host: "github.example.com",
1925 }),
1926 ).toBeNull();
1927 });
1928
1929 it("keeps Forgejo ports isolated when recovering legacy snapshots", () => {
1930 const storage = makeStorage();
1931 const cached = detail({
1932 provider: SourceControlProviderKind.make("forgejo"),
1933 url: "https://forge.example:8443/acme/web/pulls/7",
1934 });
1935 writePullRequestDetailSnapshot(storage, "env-1", reference, cached);
1936 const resolved = { ...reference, host: "forge.example:8443" };
1937 expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(cached.title);
1938 expect(
1939 readPullRequestDetailSnapshot(storage, "env-1", {
1940 ...reference,
1941 host: "forge.example:9443",
1942 }),
1943 ).toBeNull();
1944 expect(
1945 readPullRequestDetailSnapshot(storage, "env-1", {
1946 ...reference,
1947 host: "forge.example",
1948 }),
1949 ).toBeNull();
1950 });
1951
1952 it.each(["github", "gitlab"] as const)(
1953 "retains portless %s snapshot identities for custom web ports",
1954 (providerName) => {
1955 const provider = SourceControlProviderKind.make(providerName);
1956 const storage = makeStorage();
1957 const host = `${provider}.example.com`;
1958 const hosted = { ...reference, host };
1959 const cached = detail({
1960 provider,
1961 url: `https://${host}:8443/acme/web/${provider === "github" ? "pull" : "-/merge_requests"}/7`,
1962 });
1963 writePullRequestDetailSnapshot(storage, "env-1", hosted, cached);
1964 expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)?.title).toBe(cached.title);
1965 },
1966 );
1967
1968 it("keeps a cached tab painted while the live read replaces the counts", () => {
1969 const cached = detail();
1970 const live = detail({ additions: 40, deletions: 9, title: "Cache the title" });
1971 expect(resolveDisplayedPullRequestDetail({ live, cached, reference })?.additions).toBe(40);
1972 expect(resolveDisplayedPullRequestDetail({ live: null, cached, reference })?.additions).toBe(
1973 12,
1974 );
1975 });
1976
1977 it("does not paint another change request's snapshot", () => {
1978 expect(
1979 resolveDisplayedPullRequestDetail({
1980 live: null,
1981 cached: detail({ number: 8 }),
1982 reference,
1983 }),
1984 ).toBeNull();
1985 expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull();
1986 });
1987
1988 it("isolates stored and displayed details between hosts with the same repository and number", () => {
1989 const storage = makeStorage();
1990 const publicRef = { ...reference, host: "github.com" };
1991 const enterpriseRef = { ...reference, host: "ghe.example.com" };
1992 const publicDetail = detail();
1993 const enterpriseDetail = detail({
1994 title: "Enterprise change",
1995 url: "https://ghe.example.com/acme/web/pull/7",
1996 });
1997 writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail);
1998 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
1999 writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, enterpriseDetail);
2000 expect(readPullRequestDetailSnapshot(storage, "env-1", publicRef)?.title).toBe(
2001 publicDetail.title,
2002 );
2003 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)?.title).toBe(
2004 enterpriseDetail.title,
2005 );
2006 expect(
2007 resolveDisplayedPullRequestDetail({
2008 live: null,
2009 cached: publicDetail,
2010 reference: enterpriseRef,
2011 }),
2012 ).toBeNull();
2013 expect(
2014 resolveDisplayedPullRequestDetail({
2015 live: null,
2016 cached: enterpriseDetail,
2017 reference: enterpriseRef,
2018 }),
2019 ).toBe(enterpriseDetail);
2020 writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, publicDetail);
2021 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
2022 });
2023
2024 it("shrugs off corrupt storage and no storage at all", () => {
2025 const storage = makeStorage();
2026 storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json");
2027 expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull();
2028 expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull();
2029 const hosted = { ...reference, host: "github.com" };
2030 writePullRequestDetailSnapshot(storage, "env-1", hosted, detail({ url: "invalid url" }));
2031 expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)).toBeNull();
2032 });
2033});
2034
2035describe("single-PR merge compatibility during stack discovery", () => {
2036 it.each([
2037 [false, true, false, null, true],
2038 [false, false, true, null, true],
2039 [true, false, true, null, false],
2040 [true, false, false, "Lookup failed", false],
2041 [true, true, false, null, false],
2042 [true, false, false, null, true],
2043 ] as const)(
2044 "capability=%s stack=%s pending=%s error=%s permits=%s",
2045 (supportsStackActions, hasStack, stackPending, stackError, allowed) => {
2046 expect(
2047 allowsSinglePullRequestMerge({ supportsStackActions, hasStack, stackPending, stackError }),
2048 ).toBe(allowed);
2049 },
2050 );
2051});