import { SourceControlProviderKind } from "@t3tools/contracts"; import { resolvePlanFollowUpSubmission } from "../../proposedPlan"; import { GitCommandError, ProjectId, type PullRequestCheck, type PullRequestComment, type PullRequestDetail, type PullRequestDetailView, type PullRequestRef, type PullRequestReviewThread, type PullRequestState, type RepositoryIdentity, type ThreadPullRequestLink, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { formatInlineContextReference } from "~/lib/composerContextReferences"; import { buildMessageContext, reviewCommentContextReference } from "~/lib/composerContextRecords"; import { buildAddSelectionToAgentHandoff, buildExplainPullRequestHandoff, buildResolveConflictsPrompt, resolveThreadPanelPullRequestAction, buildPullRequestReferenceContext, buildFixFindingHandoff, canDeletePullRequestBranch, buildFixFindingsHandoff, groupPullRequestTimelineConversations, filterPullRequestConversationItems, pagePullRequestConversationItems, pullRequestConversationItemComments, pullRequestConversationItems, handoffPrompt, handoffReviewComments, stripPullRequestHandoffReferences, isPullRequestVerdictStale, isStackedPullRequestBase, loadingPullRequestCheckoutCommand, pullRequestPanelContext, threadPullRequestPanelTarget, latestPullRequestReviewOutcomes, newestPullRequestCommitAt, mergePullRequestThreadComments, orderPullRequestComments, pullRequestCheckoutCommand, checkoutFailureDescription, readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestReferenceHost, resolvePullRequestPrimaryControl, allowsSinglePullRequestMerge, shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, writePullRequestDetailSnapshot, readableFailure, } from "./pullRequestDetail.logic"; import type { ReviewCommentContext } from "~/reviewCommentContext"; describe("pull request checkout commands", () => { it("quotes shell metacharacters in Forgejo repository URLs", () => { expect( pullRequestCheckoutCommand( SourceControlProviderKind.make("forgejo"), 42, "feature", null, "https://forgejo.local/maria/repo'$(echo nope)", ), ).toBe( "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD", ); }); const reference = (host?: string): PullRequestRef => ({ projectId: ProjectId.make("project-1"), ...(host === undefined ? {} : { host }), repository: "acme/web", number: 42, }); const identity = (provider: string, canonicalKey: string): RepositoryIdentity => ({ canonicalKey, locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:acme/web.git", }, provider: SourceControlProviderKind.make(provider), }); it("uses a matching enterprise identity and rejects an explicit host mismatch", () => { const enterprise = identity("github", "github.example.test/acme/web"); expect(loadingPullRequestCheckoutCommand(reference("github.example.test"), enterprise)).toBe( "gh pr checkout 42", ); expect(loadingPullRequestCheckoutCommand(reference("github.com"), enterprise)).toBeNull(); }); // regression: #12721 loading layout showed a checkout command guessed from the PR number alone it("does not infer a number-only command without a trusted provider", () => { expect(loadingPullRequestCheckoutCommand(reference(), undefined)).toBeNull(); expect( loadingPullRequestCheckoutCommand( reference("github.com"), identity("gitlab", "gitlab.com/acme/web"), ), ).toBeNull(); }); }); const TIMELINE_SOURCE: Pick< PullRequestDetailView, "provider" | "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" > = { provider: SourceControlProviderKind.make("github"), createdAt: "2026-07-01T00:00:00Z", author: { login: "octocat", name: null, avatarUrl: null }, commits: [ { oid: "1baf7bdcafe", messageHeadline: "add the page", committedDate: "2026-07-02T00:00:00Z" }, ], comments: [ { id: "c1", kind: "issue-comment", author: { login: "bilal", name: null, avatarUrl: null }, body: "looks good", createdAt: "2026-07-03T00:00:00Z", url: null, path: null, reviewState: null, }, ], mergedAt: null, closedAt: null, }; describe("pull request activity refresh", () => { const first = { key: "project:acme/web#7", updatedAt: "2026-08-13T13:00:00Z", }; it("does not duplicate the first activity read or carry a revision across pull requests", () => { expect(shouldRefreshPullRequestActivity(null, first)).toBe(false); expect(shouldRefreshPullRequestActivity(first, first)).toBe(false); expect( shouldRefreshPullRequestActivity(first, { key: "project:acme/web#8", updatedAt: "2026-08-13T13:01:00Z", }), ).toBe(false); }); }); describe("review thread comment pages", () => { it("appends new comments once and keeps refreshed base comments", () => { expect( mergePullRequestThreadComments( [ { id: "c1", body: "refreshed" }, { id: "c2", body: "already in base" }, ], [ { id: "c2", body: "stale page copy" }, { id: "c3", body: "next page" }, ], ), ).toEqual([ { id: "c1", body: "refreshed" }, { id: "c2", body: "already in base" }, { id: "c3", body: "next page" }, ]); }); }); describe("pull request primary control", () => { const open = { state: "open" as const, isDraft: false, mergeability: "mergeable" as const, checksState: "passing" as const, autoMergeEnabled: false, hasMergeMethod: true, canMerge: true, canMarkReady: true, canEnableAutoMerge: true, }; // regression: #9188 pending/failing checks offered Merge instead of auto-merge it("moves pending and failing checks to auto-merge", () => { expect(resolvePullRequestPrimaryControl({ ...open, checksState: "pending" })).toBe( "enable-auto-merge", ); expect(resolvePullRequestPrimaryControl({ ...open, checksState: "failing" })).toBe( "enable-auto-merge", ); }); it("does not offer auto-merge while the host state is unknown", () => { expect( resolvePullRequestPrimaryControl({ ...open, checksState: "pending", autoMergeEnabled: undefined, }), ).toBe("merge"); }); }); describe("stacked pull request classification", () => { it("does not mistake a nested branch suffix for the default branch", () => { expect( isStackedPullRequestBase("main", [ { name: "origin/feature/main", isDefault: true, isRemote: true, remoteName: "origin", }, ]), ).toBe(true); expect( isStackedPullRequestBase("1.0", [{ name: "release/1.0", isDefault: true, isRemote: false }]), ).toBe(true); }); }); describe("review verdicts", () => { it("keeps each reviewer's last word, whatever order the host returned them in", () => { const review = ( id: string, login: string, reviewState: string, createdAt: string, ): PullRequestComment => ({ id, kind: "review", author: { login, name: null, avatarUrl: null }, body: "", createdAt, url: null, path: null, reviewState, }); expect( latestPullRequestReviewOutcomes([ review("r3", "bilal", "APPROVED", "2026-07-03T00:00:00Z"), review("r1", "bilal", "CHANGES_REQUESTED", "2026-07-01T00:00:00Z"), review("r2", "octocat", "CHANGES_REQUESTED", "2026-07-02T00:00:00Z"), review("r4", "octocat", "COMMENTED", "2026-07-04T00:00:00Z"), ]).map((entry) => [entry.actor?.login, entry.outcome]), ).toEqual([ ["bilal", "approved"], ["octocat", "changes-requested"], ]); }); it("calls a verdict stale once commits land after it, and current before that", () => { const commits = [ { oid: "c0ffee", messageHeadline: "later work", committedDate: "2026-07-05T00:00:00Z" }, ]; const review = (createdAt: string): PullRequestComment => ({ ...TIMELINE_SOURCE.comments[0]!, kind: "review", reviewState: "APPROVED", createdAt, }); expect( latestPullRequestReviewOutcomes([review("2026-07-01T00:00:00Z")], commits)[0]?.stale, ).toBe(true); expect( latestPullRequestReviewOutcomes([review("2026-07-06T00:00:00Z")], commits)[0]?.stale, ).toBe(false); expect(latestPullRequestReviewOutcomes([review("2026-07-01T00:00:00Z")], [])[0]?.stale).toBe( false, ); }); it("orders instants rather than their text, so a UTC offset cannot invert them", () => { expect( newestPullRequestCommitAt([ { oid: "a", messageHeadline: "", committedDate: "2026-07-05T00:30:00Z" }, { oid: "b", messageHeadline: "", committedDate: "2026-07-05T01:00:00+02:00" }, ]), ).toBe("2026-07-05T00:30:00Z"); expect(isPullRequestVerdictStale("2026-07-05T00:30:00Z", "2026-07-05T01:00:00+02:00")).toBe( false, ); expect(isPullRequestVerdictStale("2026-07-01T00:00:00Z", "not a date")).toBe(false); expect( newestPullRequestCommitAt([{ oid: "a", messageHeadline: "", committedDate: "not a date" }]), ).toBeNull(); }); it("shows nothing for a reviewer whose verdict was dismissed", () => { expect( latestPullRequestReviewOutcomes([ { ...TIMELINE_SOURCE.comments[0]!, kind: "review", reviewState: "APPROVED", createdAt: "2026-07-01T00:00:00Z", }, { ...TIMELINE_SOURCE.comments[0]!, id: "c2", kind: "review", reviewState: "DISMISSED", createdAt: "2026-07-02T00:00:00Z", }, ]), ).toEqual([]); }); }); describe("pull request timeline", () => { it("orders host timestamps by instant across offsets and fractional seconds", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, createdAt: "2026-07-05T01:00:00+02:00", commits: [{ oid: "commit", messageHeadline: "", committedDate: "2026-07-05T00:30:00Z" }], comments: [{ ...TIMELINE_SOURCE.comments[0]!, createdAt: "2026-07-05T00:30:00.5Z" }], mergedAt: null, closedAt: null, }); expect(events.map((event) => event.id)).toEqual(["c1", "commit", "created"]); }); it("drops a body that is nothing but a bot's HTML comment, and keeps one that says more", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, comments: [ { ...TIMELINE_SOURCE.comments[0]!, body: "" }, { ...TIMELINE_SOURCE.comments[0]!, id: "c2", body: "\nNeeds a test.", createdAt: "2026-07-04T00:00:00Z", }, ], }); expect(events.find((event) => event.id === "c1")?.body).toBeNull(); expect(events.find((event) => event.id === "c2")?.body).toBe( "\nNeeds a test.", ); }); it("reports a merge rather than the close GitHub records alongside it", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, mergedAt: "2026-07-04T00:00:00Z", closedAt: "2026-07-04T00:00:00Z", }); expect(events[0]?.id).toBe("merged"); expect(events.some((event) => event.id === "closed")).toBe(false); }); it("names the change request in its host's own term", () => { const titles = (provider: string) => buildPullRequestTimeline({ ...TIMELINE_SOURCE, provider: SourceControlProviderKind.make(provider), closedAt: "2026-07-04T00:00:00Z", }) .filter((event) => event.kind === "opened" || event.kind === "closed") .map((event) => event.title); expect(titles("github")).toEqual(["Pull request closed", "opened this pull request"]); expect(titles("gitlab")).toEqual(["Merge request closed", "opened this merge request"]); }); it("groups conversation sections without crossing commits or PR updates", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, comments: [ { ...TIMELINE_SOURCE.comments[0]!, id: "new-comment-1", createdAt: "2026-07-04T00:00:00Z", }, { ...TIMELINE_SOURCE.comments[0]!, id: "new-comment-2", createdAt: "2026-07-03T00:00:00Z", }, { ...TIMELINE_SOURCE.comments[0]!, id: "old-comment-1", createdAt: "2026-07-01T12:00:00Z", }, { ...TIMELINE_SOURCE.comments[0]!, id: "old-comment-2", createdAt: "2026-07-01T06:00:00Z", }, ], mergedAt: "2026-07-05T00:00:00Z", }); const rows = groupPullRequestTimelineConversations(events); expect( rows.map((row) => row.kind === "comments" ? [row.kind, ...row.events.map((event) => event.id)] : [row.kind, row.event.id], ), ).toEqual([ ["event", "merged"], ["comments", "new-comment-1", "new-comment-2"], ["event", "1baf7bdcafe"], ["comments", "old-comment-1", "old-comment-2"], ["event", "created"], ]); }); it("keeps a verdict out of the collapsed conversation it was submitted in", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, comments: [ { ...TIMELINE_SOURCE.comments[0]!, id: "chatter-1", createdAt: "2026-07-05T00:00:00Z" }, { ...TIMELINE_SOURCE.comments[0]!, id: "approval", kind: "review", body: "", reviewState: "APPROVED", createdAt: "2026-07-04T00:00:00Z", }, { ...TIMELINE_SOURCE.comments[0]!, id: "chatter-2", createdAt: "2026-07-03T00:00:00Z" }, { ...TIMELINE_SOURCE.comments[0]!, id: "remark", kind: "review", reviewState: "COMMENTED", createdAt: "2026-07-02T12:00:00Z", }, ], }); const rows = groupPullRequestTimelineConversations(events); expect( rows.map((row) => row.kind === "comments" ? [row.kind, ...row.events.map((event) => event.id)] : [row.kind, row.event.id], ), ).toEqual([ ["comments", "chatter-1"], ["event", "approval"], ["comments", "chatter-2", "remark"], ["event", "1baf7bdcafe"], ["event", "created"], ]); }); }); describe("review thread conversations", () => { const bot = { login: "review[bot]", name: null, avatarUrl: null, isBot: true }; const comment = (id: string, day: number): PullRequestComment => ({ ...TIMELINE_SOURCE.comments[0]!, id, kind: "review-comment", body: `Current ${id}`, createdAt: `2026-07-0${day}T12:00:00Z`, url: `https://example.test/pull/1#${id}`, path: "src/a.ts", }); const finding = { ...comment("finding", 2), author: bot }; const reply = { ...comment("reply", 4), reactions: [ { content: "thumbs-up" as const, count: 2, actors: ["author"], viewerHasReacted: true }, ], }; const lastReply = comment("last-reply", 6); const loose = TIMELINE_SOURCE.comments[0]!; const comments = [finding, loose, reply, lastReply]; const thread: PullRequestReviewThread = { id: "thread", path: "src/a.ts", line: 7, side: "right", isResolved: false, isOutdated: true, comments: [lastReply, { ...finding, body: "Stale finding" }, reply], }; it("keeps a timeline finding and its replies in one row at the finding time", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, comments, reviewThreads: [thread], }); expect(events.map((event) => event.id)).toEqual(["c1", "finding", "1baf7bdcafe", "created"]); expect(events[1]).toMatchObject({ id: finding.id, at: finding.createdAt, body: finding.body, url: finding.url, thread: { path: thread.path, line: 7, isOutdated: true, replies: [ { id: reply.id, body: reply.body, url: reply.url, reactions: reply.reactions }, { id: lastReply.id, body: lastReply.body, url: lastReply.url }, ], }, }); expect(groupPullRequestTimelineConversations(events).map((row) => row.kind)).toEqual([ "comments", "event", "event", "event", ]); expect(events[0]).toEqual(buildPullRequestTimeline(TIMELINE_SOURCE)[0]); }); it("orders and pages whole Summary conversations while retaining editable comment copies", () => { const earlier = { ...loose, id: "earlier", createdAt: "2026-07-01T00:00:00Z" }; const items = pullRequestConversationItems([earlier, ...comments], [thread]); expect(orderPullRequestComments(items, "oldest").map((item) => item.id)).toEqual([ "earlier", "thread", "c1", ]); expect(orderPullRequestComments(items, "newest").map((item) => item.id)).toEqual([ "c1", "thread", "earlier", ]); const page = pagePullRequestConversationItems(items, 2); expect(page.hidden).toBe(1); const grouped = page.shown[0]!; expect(grouped.createdAt).toBe(finding.createdAt); expect(pullRequestConversationItemComments(grouped)).toEqual([finding, reply, lastReply]); expect(pullRequestConversationItemComments(grouped)[0]).toBe(finding); expect(pullRequestConversationItemComments(grouped)[1]).toBe(reply); expect(pagePullRequestConversationItems(items, 1)).toEqual({ shown: [items[2]], hidden: 2 }); expect(pullRequestConversationItemComments(items[2]!)).toEqual([loose]); expect(pullRequestConversationItems(comments, []).map((item) => item.id)).toEqual( comments.map((entry) => entry.id), ); }); it("filters whole conversations when any member matches, including resolved and dismissed reviews", () => { const botOnly = { ...comment("bot-only", 7), author: bot }; const dismissed = { ...comment("dismissed", 8), kind: "review" as const, reviewState: "DISMISSED", }; const items = pullRequestConversationItems( [...comments, botOnly, dismissed], [thread, { ...thread, id: "bot-thread", comments: [botOnly] }], ); const kept = filterPullRequestConversationItems(items, { bots: false, resolved: false }); expect(kept.map((item) => item.id)).toEqual(["thread", "c1"]); expect(pullRequestConversationItemComments(kept[0]!)).toEqual([finding, reply, lastReply]); expect(filterPullRequestConversationItems(items, { bots: true, resolved: true })).toEqual( items, ); const resolved = pullRequestConversationItems(comments, [{ ...thread, isResolved: true }]); expect( filterPullRequestConversationItems(resolved, { bots: true, resolved: false }).map( (item) => item.id, ), ).toEqual(["c1"]); expect(filterPullRequestConversationItems(resolved, { bots: false, resolved: true })).toEqual( resolved, ); }); it("keeps bot review verdicts when bot comments are hidden", () => { const review = (id: string, reviewState: string) => ({ ...comment(id, 9), author: bot, kind: "review" as const, reviewState, }); const items = pullRequestConversationItems( [ review("approved", "APPROVED"), review("changes", "CHANGES_REQUESTED"), review("commented", "COMMENTED"), ], [], ); expect( filterPullRequestConversationItems(items, { bots: false, resolved: false }).map( (item) => item.id, ), ).toEqual(["approved", "changes"]); }); it("distinguishes unloaded replies from complete threads, even when the host only supplies a cursor", () => { const timeline = (reviewThread: PullRequestReviewThread) => buildPullRequestTimeline({ ...TIMELINE_SOURCE, comments, reviewThreads: [reviewThread], }).find((event) => event.id === finding.id)?.thread; expect(timeline({ ...thread, commentCount: 5 })).toMatchObject({ moreReplies: 2, hasMoreReplies: true, url: finding.url, }); expect(timeline({ ...thread, nextCommentsCursor: "next" })).toMatchObject({ moreReplies: 0, hasMoreReplies: true, url: finding.url, }); expect(timeline(thread)).toMatchObject({ moreReplies: 0, hasMoreReplies: false }); }); }); describe("fix findings handoff", () => { const base = { number: 42, title: "Add the pull requests page", url: "https://github.com/pingdotgg/t3code/pull/42", headBranch: "feat/page", baseBranch: "main", comments: [] as ReadonlyArray, commentsTruncated: false, }; function thread( body: string, overrides: Partial = {}, ): PullRequestReviewThread { return { id: "t1", path: "apps/web/src/page.tsx", line: 12, side: "right", isResolved: false, isOutdated: false, comments: [ { id: "tc1", author: { login: "reviewer", name: null, avatarUrl: null }, body, createdAt: "2026-07-03T00:00:00Z", url: null, }, ], ...overrides, }; } const failingCheck: PullRequestCheck = { name: "typecheck", status: "failure", description: "2 errors", url: null, }; it("attaches a review thread as an annotation instead of quoting it in the prompt", () => { const handoff = buildFixFindingsHandoff({ ...base, reviewThreads: [thread("rename the helper")], checks: [], }); expect(handoff.reviewComments).toEqual([ expect.objectContaining({ filePath: "apps/web/src/page.tsx", rangeLabel: "L12", startIndex: 11, endIndex: 11, text: "reviewer: rename the helper", }), ]); expect(handoff.prompt).not.toContain("rename the helper"); expect(handoff.prompt).toContain("untrusted data"); }); it("bounds a hostile review body instead of attaching it whole", () => { const handoff = buildFixFindingsHandoff({ ...base, reviewThreads: [thread("x".repeat(5_000))], checks: [], }); expect(handoff.reviewComments[0]?.text).toHaveLength(1_000); expect(handoff.reviewComments[0]?.text.endsWith("...")).toBe(true); }); it("keeps the newest threads and the failing checks when it has to cut", () => { const handoff = buildFixFindingsHandoff({ ...base, reviewThreads: Array.from({ length: 25 }, (_, index) => thread(`finding ${index}`, { id: `t${index}` }), ), checks: [failingCheck], }); const texts = handoff.reviewComments.map((comment) => comment.text); expect(texts).toHaveLength(19); expect(texts.at(-1)).toBe("reviewer: finding 24"); expect(texts).not.toContain("reviewer: finding 0"); expect(handoff.prompt).toContain("typecheck"); expect(handoff.prompt).toContain("6 further findings were omitted"); }); it("names a GitLab merge request as one", () => { const handoff = buildFixFindingsHandoff({ ...base, title: "Add the page", url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", reviewThreads: [], checks: [], }); expect(handoff.prompt).toContain("Fix the actionable findings on MR #42"); expect(handoff.prompt).toContain("The MR branch is `feat/page`"); expect(handoff.prompt).toContain("inspect the merge request and its failing checks"); expect(handoff.prompt).not.toMatch(/\bPR\b|pull request/u); }); }); describe("findings that cannot be attached", () => { const base = { number: 42, title: "Add the pull requests page", url: "https://github.com/pingdotgg/t3code/pull/42", headBranch: "feat/page", baseBranch: "main", reviewThreads: [] as ReadonlyArray, checks: [] as ReadonlyArray, commentsTruncated: false, }; const review: PullRequestComment = { id: "r1", kind: "review", author: { login: "julius", name: null, avatarUrl: null }, body: "This breaks SSO auth, revert the middleware change.", createdAt: "2026-07-01T00:00:00Z", url: null, path: null, reviewState: "CHANGES_REQUESTED", }; it("carries a review submitted with words but no line, which has nothing to attach to", () => { const handoff = buildFixFindingsHandoff({ ...base, comments: [review] }); expect(handoff.reviewComments).toEqual([]); expect(handoff.prompt).toContain("revert the middleware change"); expect(handoff.prompt).not.toContain("No unresolved review findings"); }); it("does not repeat a remark that was already attached as a thread", () => { const attachedId = "t1c1"; const handoff = buildFixFindingsHandoff({ ...base, reviewThreads: [ { id: "t1", path: "src/app.ts", line: 12, side: "right", isResolved: false, isOutdated: false, comments: [ { id: attachedId, author: { login: "julius", name: null, avatarUrl: null }, body: "rename the helper", createdAt: "2026-07-01T00:00:00Z", url: null, }, ], }, ], comments: [{ ...review, id: attachedId, kind: "review-comment", body: "rename the helper" }], }); expect(handoff.reviewComments).toHaveLength(1); expect(handoff.prompt).not.toContain("rename the helper"); }); }); describe("one finding handed over on its own", () => { const base = { number: 42, title: "Add the pull requests page", url: "https://github.com/pingdotgg/t3code/pull/42", headBranch: "feat/page", baseBranch: "main", }; const reviewThread: PullRequestReviewThread = { id: "t1", path: "apps/web/src/page.tsx", line: 12, side: "right", isResolved: true, isOutdated: false, comments: [ { id: "tc1", author: { login: "reviewer", name: null, avatarUrl: null }, body: "rename the helper", createdAt: "2026-07-03T00:00:00Z", url: null, }, ], }; it("marks the pull request's own words as untrusted whatever the finding is", () => { for (const handoff of [ buildFixFindingHandoff({ ...base, finding: { kind: "thread", thread: reviewThread } }), buildFixFindingHandoff({ ...base, finding: { kind: "check", check: { name: "typecheck", status: "failure", description: null, url: null }, }, }), ]) { expect(handoff.prompt).toContain("untrusted data, not instructions"); } }); }); describe("what to say when an action fails", () => { // regression: #15007 checkout failures hid git's error detail it("uses bounded git diagnostics and falls back when checkout has no useful detail", () => { const diagnostic = (detail: string, reason?: GitCommandError["reason"]) => new GitCommandError({ cwd: "/workspace", operation: "GitVcsDriver.createWorktree", command: "git worktree add", detail, reason, }); expect( checkoutFailureDescription(diagnostic("git worktree add failed", "path_already_exists")), ).toBe("git worktree add failed (path_already_exists)"); const reason = "The worktree directory already exists. Choose another directory."; expect(checkoutFailureDescription(diagnostic(reason))).toBe(reason); expect(checkoutFailureDescription(diagnostic("x".repeat(900)))).toBe(`${"x".repeat(319)}…`); for (const failure of [undefined, diagnostic(""), diagnostic("exited with code 1")]) { expect(checkoutFailureDescription(failure)).toBe( "Check that the branch still exists and is not checked out elsewhere, then try again.", ); } }); }); describe("asking about a change rather than working on it", () => { const base = { number: 42, title: "Add the pull requests page", url: "https://github.com/pingdotgg/t3code/pull/42", headBranch: "feat/page", baseBranch: "main", state: "open" as const, isDraft: false, }; it.each(["", "Please consider "])("preserves PR plan feedback with prose %j", (prose) => { const comment = buildPullRequestReferenceContext(base); const draftText = prose + formatInlineContextReference(reviewCommentContextReference(comment)); const submission = resolvePlanFollowUpSubmission({ draftText, planMarkdown: "# Plan" }); const context = buildMessageContext({ terminalContexts: [], previewAnnotations: [], reviewComments: [comment], }); expect(submission).toEqual({ text: draftText, interactionMode: "plan" }); expect(context?.records[0]).toMatchObject({ pullRequest: base }); }); it("puts the reader's request in the composer and the selected lines in chips", () => { const comment = { id: "pull-request-selection:page.tsx:12:18", sectionId: "pull-request:42", sectionTitle: "PR #42 review", filePath: "apps/web/src/page.tsx", startIndex: 11, endIndex: 17, rangeLabel: "L12-L18", text: "what is this for?", diff: "+const answer = 42;", }; const handoff = buildAddSelectionToAgentHandoff({ ...base, comment, request: "what is this for?", }); expect(handoff.prompt).toBe("what is this for?"); expect(handoff.reviewComments.map((entry) => entry.filePath)).toEqual([ "PR #42", "apps/web/src/page.tsx", ]); expect(handoff.reviewComments[0]?.text).not.toContain("Do not change any code"); expect(handoff.reviewComments[1]?.text).toBe(""); }); it("explains a GitLab merge request in its own term", () => { const handoff = buildExplainPullRequestHandoff({ ...base, title: "Add the page", url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", }); expect(handoff.prompt).toBe("Explain this merge request."); expect(handoff.reviewComments[0]?.text).toContain("The merge request is #42"); expect(handoff.reviewComments[0]?.text).not.toContain("pull request"); expect( buildResolveConflictsPrompt({ ...base, url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", }), ).toMatch(/^MR #42 /u); }); }); describe("a second ask into the same composer", () => { const chip = (id: string): ReviewCommentContext => ({ id, sectionId: "pull-request:42", sectionTitle: "PR #42", filePath: "PR #42", startIndex: 0, endIndex: 0, rangeLabel: "Add the pull requests page", text: "", diff: "", }); it("replaces what the last one left, chips included", () => { const next = handoffReviewComments( [chip("pull-request-context:41"), chip("pull-request-selection:page.tsx:1:2")], [chip("pull-request-context:42")], ); expect(next.map((comment) => comment.id)).toEqual(["pull-request-context:42"]); }); it("keeps a reader's own pull request reference when a later handoff lands", () => { const own = buildPullRequestReferenceContext({ number: 42, title: "Add the pull requests page", url: "https://github.com/pingdotgg/t3code/pull/42", headBranch: "feature", baseBranch: "main", state: "open" as const, isDraft: false, }); const prompt = `Look at this. ${formatInlineContextReference(reviewCommentContextReference(own))} `; expect(stripPullRequestHandoffReferences(prompt, [own])).toBe(prompt); expect( handoffReviewComments([own], [chip("pull-request-context:42")]).map((comment) => comment.id), ).toEqual([own.id, "pull-request-context:42"]); }); it("keeps an edit the reader made to the sentence they were handed", () => { expect( handoffPrompt( { prompt: "Explain this pull request, the caching especially.", lastHandoffPrompt: "Explain this pull request.", }, "", ), ).toBe("Explain this pull request, the caching especially."); }); it("replaces only its own sentence under text the reader typed", () => { expect( handoffPrompt( { prompt: "check the migration first\n\nExplain this pull request.", lastHandoffPrompt: "Explain this pull request.", }, "Why the cache key?", ), ).toBe("check the migration first\n\nWhy the cache key?"); }); it("keeps the lines the reader marked up in the thread themselves", () => { const own = chip("file-comment:3"); const next = handoffReviewComments( [own, chip("pull-request-finding:t1")], [chip("pull-request-context:42")], ); expect(next.map((comment) => comment.id)).toEqual([ "file-comment:3", "pull-request-context:42", ]); }); }); describe("how the branch stands against its base", () => { const detail = (overrides: Record = {}) => ({ state: "open", mergeability: "mergeable", baseComparison: "behind", behindBy: 12, capabilities: { updateMethods: ["merge", "rebase"] }, viewerPermissions: { updateMethods: ["merge", "rebase"] }, ...overrides, }) as Parameters[0]; it("says nothing where the host could not compare, rather than claiming it is current", () => { expect(resolveBaseFreshness(detail({ baseComparison: "unknown" }))).toBeNull(); expect(resolveBaseFreshness(detail({ baseComparison: undefined }))).toBeNull(); }); it("narrows to what this reader may actually take", () => { expect( resolveBaseFreshness(detail({ viewerPermissions: { updateMethods: ["merge"] } }))?.methods, ).toEqual(["merge"]); }); }); describe("pull request panel context beside a thread", () => { const link = ( number: number, overrides: Partial = {}, ): ThreadPullRequestLink => ({ host: "github.com", repository: "pingdotgg/t3code", number, url: `https://github.com/pingdotgg/t3code/pull/${number}`, source: "manual", linkedAt: "2026-09-09T00:00:00Z", snapshot: null, stack: null, ...overrides, }); const surface = ( number: number, overrides: Partial[1]> = {}, ) => ({ projectId: "proj-a", host: "github.com", repository: "pingdotgg/t3code", number, ...overrides, }); const stackThread = { projectId: "proj-a", pullRequests: [ link(10856), link(10832, { source: "stack" }), link(10677, { source: "stack" }), link(10854, { source: "stack" }), link(10855, { source: "stack" }), ], linkedPullRequest: { projectId: "proj-a", repository: "pingdotgg/t3code", number: 10856, url: "https://github.com/pingdotgg/t3code/pull/10856", }, }; it("treats every layer of the thread's stack as its own, not only the one the legacy field names", () => { for (const number of [10856, 10832, 10677, 10854, 10855]) { expect(pullRequestPanelContext(stackThread, surface(number)), `#${number}`).toBe("thread"); } }); it("does not let the legacy field decide when the thread holds a link list", () => { const thread = { projectId: "proj-a", pullRequests: [link(11101, { source: "created" }), link(11105, { source: "stack" })], linkedPullRequest: { projectId: "proj-a", repository: "pingdotgg/t3code", number: 11105, url: "https://github.com/pingdotgg/t3code/pull/11105", }, }; expect(pullRequestPanelContext(thread, surface(11101))).toBe("thread"); expect(pullRequestPanelContext(thread, surface(11105))).toBe("thread"); expect(pullRequestPanelContext({ ...thread, linkedPullRequest: null }, surface(11101))).toBe( "thread", ); }); // regression: #12320 PR panel actions escaped the current thread it("is the page for a pull request the thread is not linked to", () => { expect(pullRequestPanelContext(stackThread, surface(12320))).toBe("page"); expect(pullRequestPanelContext(stackThread, surface(10856, { repository: "acme/web" }))).toBe( "page", ); }); // regression: #12320 PR panel actions escaped the current thread it("is the page under another project's checkout of the same repository", () => { expect(pullRequestPanelContext(stackThread, surface(10856, { projectId: "proj-b" }))).toBe( "page", ); }); it("recognizes an unsynced manual link, and matches host and repository case-insensitively", () => { const thread = { projectId: "proj-a", pullRequests: [link(7, { host: "GitHub.com" })] }; expect(pullRequestPanelContext(thread, surface(7, { repository: "PingDotGG/T3Code" }))).toBe( "thread", ); expect(pullRequestPanelContext(thread, surface(7, { host: undefined }))).toBe("thread"); expect(pullRequestPanelContext(thread, surface(7, { host: "gitlab.com" }))).toBe("page"); }); it("ignores a dismissed stack member the reader chose not to see", () => { const thread = { projectId: "proj-a", pullRequests: [link(1), link(2, { source: "stack-dismissed" })], }; expect(pullRequestPanelContext(thread, surface(2))).toBe("page"); }); it("falls back to the legacy fields only for a thread with no link list", () => { const legacy = { projectId: "proj-a", repository: "pingdotgg/t3code", number: 3, url: "https://github.com/pingdotgg/t3code/pull/3", }; expect( pullRequestPanelContext({ projectId: "proj-a", linkedPullRequest: legacy }, surface(3)), ).toBe("thread"); expect( pullRequestPanelContext({ projectId: "proj-a", branchPullRequest: legacy }, surface(3)), ).toBe("thread"); expect(pullRequestPanelContext({ projectId: "proj-a", pullRequests: [] }, surface(3))).toBe( "page", ); expect(pullRequestPanelContext({ projectId: null }, surface(3))).toBe("page"); }); describe("the Pull request entry's target", () => { const legacy = (number: number) => ({ projectId: ProjectId.make("proj-a"), repository: "pingdotgg/t3code", number, url: `https://github.com/pingdotgg/t3code/pull/${number}`, }); it("opens a linked pull request the legacy field never named, ahead of the branch PR", () => { expect( threadPullRequestPanelTarget({ projectId: "proj-a", pullRequests: [link(15046, { source: "agent" })], linkedPullRequest: null, branchPullRequest: legacy(30), }), ).toEqual({ ...legacy(15046), host: "github.com" }); }); it("reuses the legacy reference only for the same pull request on the same host", () => { const linkedPullRequest = legacy(15046); const onHost = (host: string) => threadPullRequestPanelTarget({ projectId: "proj-a", pullRequests: [link(15046, { host, url: `https://${host}/pingdotgg/t3code/pull/15046` })], linkedPullRequest, }); expect(onHost("github.com")).toBe(linkedPullRequest); expect(onHost("github.example.com")).toMatchObject({ host: "github.example.com" }); }); // regression: #15061 Pull request panel entry ignored the branch PR it("falls back to the branch pull request when the thread holds no link", () => { expect( threadPullRequestPanelTarget({ projectId: "proj-a", branchPullRequest: legacy(3) }), ).toEqual(legacy(3)); expect(threadPullRequestPanelTarget({ projectId: "proj-a", pullRequests: [] })).toBeNull(); }); }); }); describe("when the source branch can be deleted", () => { const branchDetail = ( state: PullRequestState, overrides: { deleteBranch?: boolean; allowed?: boolean } = {}, ) => ({ state, capabilities: { deleteBranch: overrides.deleteBranch ?? true, actions: ["delete-branch" as const], }, viewerPermissions: { actions: overrides.allowed === false ? [] : (["delete-branch"] as const), }, }); it("needs both the host capability and the viewer's permission", () => { expect(canDeletePullRequestBranch(branchDetail("merged", { deleteBranch: false }))).toBe(false); expect(canDeletePullRequestBranch(branchDetail("merged", { allowed: false }))).toBe(false); }); }); describe("the compact row's single action slot", () => { const check = (status: PullRequestCheck["status"]): PullRequestCheck => ({ name: "ci", status, description: null, url: null, }); const openDetail = ( overrides: Partial[0] & object> = {}, ) => ({ state: "open", isDraft: false, mergeability: "mergeable", capabilities: { actions: ["merge", "ready", "draft", "close", "reopen"], mergeMethods: ["merge", "squash"], } as unknown as PullRequestDetailView["capabilities"], viewerPermissions: { actions: ["merge", "ready", "draft", "close", "reopen"], } as unknown as PullRequestDetailView["viewerPermissions"], mergeCapabilities: { merge: true, squash: true, rebase: false }, checks: [check("success")], ...overrides, }) as NonNullable[0]>; it("holds the slot while checks run rather than offering a merge that races them", () => { expect( resolveThreadPanelPullRequestAction( openDetail({ checks: [check("success"), check("pending")] }), ), ).toBeNull(); expect( resolveThreadPanelPullRequestAction(openDetail({ checks: [check("action-required")] })), ).toBeNull(); }); it("ranks conflicts above everything, then draft, then failing checks", () => { expect( resolveThreadPanelPullRequestAction( openDetail({ mergeability: "conflicting", isDraft: true, checks: [check("failure")] }), ), ).toBe("resolve"); expect( resolveThreadPanelPullRequestAction( openDetail({ isDraft: true, checks: [check("failure")] }), ), ).toBe("ready"); expect( resolveThreadPanelPullRequestAction( openDetail({ checks: [check("failure"), check("pending")] }), ), ).toBe("fix"); }); it("offers nothing the viewer may not do, and nothing on settled pull requests", () => { expect( resolveThreadPanelPullRequestAction( openDetail({ viewerPermissions: { actions: [], } as unknown as PullRequestDetailView["viewerPermissions"], }), ), ).toBeNull(); expect(resolveThreadPanelPullRequestAction(openDetail({ state: "merged" }))).toBeNull(); expect(resolveThreadPanelPullRequestAction(null)).toBeNull(); }); }); describe("cached pull request detail", () => { const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 }; const detail = (overrides: Partial = {}): PullRequestDetail => ({ provider: SourceControlProviderKind.make("github"), capabilities: { diff: true, comment: true, actions: ["merge"], mergeMethods: ["merge"], search: true, review: { inlineComment: true, reply: true, resolve: true, verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, }, viewerPermissions: { actions: ["merge"], comment: true, resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, }, projectId: "project-1", projectTitle: "web", workspaceRoot: "/repo", repository: "acme/web", number: 7, title: "Cache the title", body: "who made it", url: "https://github.com/acme/web/pull/7", author: { login: "octocat", name: null, avatarUrl: "https://avatars.example/octocat" }, state: "open", isDraft: false, mergeability: "mergeable", additions: 12, deletions: 3, changedFiles: 2, headBranch: "feat/cache", baseBranch: "main", createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-02T00:00:00.000Z", mergedAt: null, closedAt: null, reviewers: [], labels: [], checks: [], mergeCapabilities: { merge: true, squash: true, rebase: true }, ...overrides, }) as PullRequestDetail; const makeStorage = () => { const held = new Map(); return { getItem: (key: string) => held.get(key) ?? null, setItem: (key: string, value: string) => void held.set(key, value), removeItem: (key: string) => void held.delete(key), size: () => held.size, }; }; it("keeps only the 20 most recently saved pull requests", () => { const storage = makeStorage(); const numbered = (number: number) => ({ ...reference, number }); for (let number = 1; number <= 21; number += 1) { writePullRequestDetailSnapshot(storage, "env-1", numbered(number), detail({ number })); } expect(readPullRequestDetailSnapshot(storage, "env-1", numbered(1))).toBeNull(); expect(readPullRequestDetailSnapshot(storage, "env-1", numbered(2))?.number).toBe(2); expect(readPullRequestDetailSnapshot(storage, "env-1", numbered(21))?.number).toBe(21); expect(storage.size()).toBe(21); }); it("saving a pull request again keeps it as the newest without growing the list", () => { const storage = makeStorage(); const numbered = (number: number) => ({ ...reference, number }); for (let number = 1; number <= 20; number += 1) { writePullRequestDetailSnapshot(storage, "env-1", numbered(number), detail({ number })); } writePullRequestDetailSnapshot(storage, "env-1", numbered(1), detail({ number: 1 })); writePullRequestDetailSnapshot(storage, "env-1", numbered(21), detail({ number: 21 })); expect(readPullRequestDetailSnapshot(storage, "env-1", numbered(1))?.number).toBe(1); expect(readPullRequestDetailSnapshot(storage, "env-1", numbered(2))).toBeNull(); expect(storage.size()).toBe(21); }); it("reuses a host-qualified snapshot when reopening a thread link without a host", () => { const storage = makeStorage(); writePullRequestDetailSnapshot( storage, "env-1", { ...reference, host: "github.com" }, detail(), ); const resolved = resolvePullRequestReferenceHost(reference, { canonicalKey: "github.com/acme/web", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "https://github.com/acme/web.git", }, provider: SourceControlProviderKind.make("github"), }); expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe( "Cache the title", ); const explicit = { ...reference, host: "github.example.com" }; expect( resolvePullRequestReferenceHost(explicit, { canonicalKey: "github.com/acme/web", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "https://github.com/acme/web.git", }, }), ).toBe(explicit); }); it("hydrates legacy hostless snapshots only for the matching host", () => { const storage = makeStorage(); writePullRequestDetailSnapshot(storage, "env-1", reference, detail()); expect( readPullRequestDetailSnapshot(storage, "env-1", { ...reference, host: "github.com" })?.title, ).toBe("Cache the title"); expect( readPullRequestDetailSnapshot(storage, "env-1", { ...reference, host: "github.example.com", }), ).toBeNull(); }); it("keeps Forgejo ports isolated when recovering legacy snapshots", () => { const storage = makeStorage(); const cached = detail({ provider: SourceControlProviderKind.make("forgejo"), url: "https://forge.example:8443/acme/web/pulls/7", }); writePullRequestDetailSnapshot(storage, "env-1", reference, cached); const resolved = { ...reference, host: "forge.example:8443" }; expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe(cached.title); expect( readPullRequestDetailSnapshot(storage, "env-1", { ...reference, host: "forge.example:9443", }), ).toBeNull(); expect( readPullRequestDetailSnapshot(storage, "env-1", { ...reference, host: "forge.example", }), ).toBeNull(); }); // regression: #12168 cached PR details were missed across entry points on custom ports it.each(["github", "gitlab"] as const)( "retains legacy portless %s snapshots without mixing explicit web ports", (providerName) => { const provider = SourceControlProviderKind.make(providerName); const storage = makeStorage(); const host = `${provider}.example.com`; const hosted = { ...reference, host }; const cached = detail({ provider, url: `https://${host}:8443/acme/web/${provider === "github" ? "pull" : "-/merge_requests"}/7`, }); writePullRequestDetailSnapshot(storage, "env-1", hosted, cached); expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)?.title).toBe(cached.title); expect( resolveDisplayedPullRequestDetail({ live: null, cached, reference: { ...reference, host: `${host}:8443` }, }), ).toBe(cached); expect( resolveDisplayedPullRequestDetail({ live: null, cached, reference: { ...reference, host: `${host}:9443` }, }), ).toBeNull(); }, ); it("does not paint another change request's snapshot", () => { expect( resolveDisplayedPullRequestDetail({ live: null, cached: detail({ number: 8 }), reference, }), ).toBeNull(); expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull(); }); it("isolates stored and displayed details between hosts with the same repository and number", () => { const storage = makeStorage(); const publicRef = { ...reference, host: "github.com" }; const enterpriseRef = { ...reference, host: "ghe.example.com" }; const publicDetail = detail(); const enterpriseDetail = detail({ title: "Enterprise change", url: "https://ghe.example.com/acme/web/pull/7", }); writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail); expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, enterpriseDetail); expect(readPullRequestDetailSnapshot(storage, "env-1", publicRef)?.title).toBe( publicDetail.title, ); expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)?.title).toBe( enterpriseDetail.title, ); expect( resolveDisplayedPullRequestDetail({ live: null, cached: publicDetail, reference: enterpriseRef, }), ).toBeNull(); expect( resolveDisplayedPullRequestDetail({ live: null, cached: enterpriseDetail, reference: enterpriseRef, }), ).toBe(enterpriseDetail); writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, publicDetail); expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); }); // regression: #9294 corrupt cached PR chrome broke reopening it("shrugs off corrupt storage and no storage at all", () => { const storage = makeStorage(); storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); expect(readPullRequestDetailSnapshot(storage, "env-1", reference)).toBeNull(); expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); const hosted = { ...reference, host: "github.com" }; writePullRequestDetailSnapshot(storage, "env-1", hosted, detail({ url: "invalid url" })); expect(readPullRequestDetailSnapshot(storage, "env-1", hosted)).toBeNull(); }); }); describe("single-PR merge compatibility during stack discovery", () => { it.each([ [false, true, false, null, true], [false, false, true, null, true], [true, false, true, null, false], [true, false, false, "Lookup failed", false], [true, true, false, null, false], [true, false, false, null, true], ] as const)( "capability=%s stack=%s pending=%s error=%s permits=%s", (supportsStackActions, hasStack, stackPending, stackError, allowed) => { expect( allowsSinglePullRequestMerge({ supportsStackActions, hasStack, stackPending, stackError }), ).toBe(allowed); }, ); }); describe("readableFailure", () => { it("replaces a host tool's bare failure with the hint, for every host", () => { for (const message of [ "GitHub CLI failed.", "Azure DevOps API failed", "Forgejo command failed", ]) { expect(readableFailure(new Error(message), "Try again.")).toBe("Try again."); } expect(readableFailure(new Error("Branch is protected."), "Try again.")).toBe( "Branch is protected.", ); }); });