import { ProjectId, type ThreadPullRequestLink, type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { legacyLinkedPullRequestOf, legacyThreadPullRequestKey, threadPullRequestSearchTerms, resolveThreadCurrentPullRequest, resolveThreadPullRequestChains, resolveThreadPullRequestBadge, threadPullRequestKeysEqual, } from "./threadPullRequests.ts"; function snapshot(input: Partial = {}): ThreadPullRequestSnapshot { return { state: "open", title: "Change", headBranch: "feature", baseBranch: "main", isDraft: false, updatedAt: "2026-01-01T00:00:00.000Z", syncedAt: "2026-01-01T00:00:00.000Z", ...input, }; } function link( number: number, input: Partial> = {}, ): ThreadPullRequestLink { return { host: "github.com", repository: "pingdotgg/t3code", number, url: `https://github.com/pingdotgg/t3code/pull/${number}`, source: "manual", linkedAt: `2026-01-01T00:00:${String(number).padStart(2, "0")}.000Z`, snapshot: null, stack: null, ...input, }; } describe("threadPullRequestKeysEqual", () => { it("ignores host and repository case", () => { expect( threadPullRequestKeysEqual( { host: "GitHub.com", repository: "PingDotGG/t3code", number: 1 }, { host: "github.com", repository: "pingdotgg/t3code", number: 1 }, ), ).toBe(true); expect( threadPullRequestKeysEqual( { host: "github.com", repository: "pingdotgg/t3code", number: 1 }, { host: "gitlab.com", repository: "pingdotgg/t3code", number: 1 }, ), ).toBe(false); }); }); describe("resolveThreadCurrentPullRequest", () => { it("returns null with no visible links", () => { expect(resolveThreadCurrentPullRequest([])).toBeNull(); expect(resolveThreadCurrentPullRequest([link(1, { source: "stack-dismissed" })])).toBeNull(); }); it("treats an unsynced link as open", () => { expect(resolveThreadCurrentPullRequest([link(1)])).toMatchObject({ kind: "single", link: { number: 1 }, }); }); it("prefers the single open link over terminal ones", () => { const current = resolveThreadCurrentPullRequest([ link(1, { snapshot: snapshot({ state: "merged" }) }), link(2, { snapshot: snapshot({ state: "open" }) }), link(3, { snapshot: snapshot({ state: "closed" }) }), ]); expect(current).toMatchObject({ kind: "single", link: { number: 2 } }); }); it("reports a stack when several links are open and puts the highest layer on top", () => { const stack = { kind: "native" as const, id: "s1", number: 1, url: "https://github.com/pingdotgg/t3code/stacks/1", base: "main", layers: [ { number: 10, headBranch: "a", state: "open" as const }, { number: 11, headBranch: "b", state: "open" as const }, ], }; const current = resolveThreadCurrentPullRequest([ link(11, { snapshot: snapshot(), stack }), link(10, { snapshot: snapshot(), stack }), ]); expect(current).toMatchObject({ kind: "stack", top: { number: 11 } }); if (current?.kind === "stack") { expect(current.open.map((entry) => entry.number)).toEqual([11, 10]); } }); it("orders an open set without stack data by most recent link", () => { const current = resolveThreadCurrentPullRequest([link(1), link(2)]); expect(current).toMatchObject({ kind: "stack", top: { number: 2 } }); }); it("falls back to the most recently updated terminal link", () => { const current = resolveThreadCurrentPullRequest([ link(1, { snapshot: snapshot({ state: "merged", updatedAt: "2026-01-03T00:00:00.000Z" }) }), link(2, { snapshot: snapshot({ state: "closed", updatedAt: "2026-01-02T00:00:00.000Z" }) }), ]); expect(current).toMatchObject({ kind: "single", link: { number: 1 } }); }); }); describe("legacyLinkedPullRequestOf", () => { const identity = { canonicalKey: "github.com/pingdotgg/t3code", provider: "github", displayName: "pingdotgg/t3code", locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "https://github.com/pingdotgg/t3code.git", }, }; it("projects only links the owning project can route without a host", () => { expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, identity)).toEqual({ projectId: "project-1", repository: "pingdotgg/t3code", number: 7, url: "https://github.com/pingdotgg/t3code/pull/7", }); expect(legacyLinkedPullRequestOf([], "project-1" as never, identity)).toBeNull(); }); it.each([ { host: "github.enterprise.test", url: "https://github.enterprise.test/pingdotgg/t3code/pull/7", }, { repository: "acme/other", url: "https://github.com/acme/other/pull/7" }, ])("omits an unsafe legacy route %j", (foreign) => { expect( legacyLinkedPullRequestOf([link(7, foreign)], "project-1" as never, identity), ).toBeNull(); expect( legacyLinkedPullRequestOf([link(7, foreign), link(8)], "project-1" as never, identity) ?.number, ).toBe(8); }); it.each([ "dev.azure.com/org-a/project/_git/web", "ssh.dev.azure.com/v3/org-a/project/web", "org-a.visualstudio.com/DefaultCollection/project/_git/web", ])("projects Azure links through the legacy selector for %s", (canonicalKey) => { const azureIdentity = { ...identity, provider: "azure-devops", canonicalKey, displayName: canonicalKey.slice(canonicalKey.indexOf("/") + 1), name: "web", }; const own = link(7, { host: "dev.azure.com", repository: "org-a/project/_git/web", url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", }); const foreign = link(7, { host: "dev.azure.com", repository: "web", url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7", linkedAt: "2026-01-02T00:00:00.000Z", }); for (const repository of ["web", "org-a/project/_git/web"]) { expect( legacyLinkedPullRequestOf( [{ ...own, repository }, foreign], "project-1" as never, azureIdentity, ), ).toEqual({ projectId: "project-1", repository: "web", number: 7, url: own.url }); } expect(legacyLinkedPullRequestOf([foreign], "project-1" as never, azureIdentity)).toBeNull(); expect(legacyThreadPullRequestKey({ ...own, repository: "web" })).toEqual({ host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7, }); expect( threadPullRequestKeysEqual(own, { host: "org-a.visualstudio.com", repository: "DefaultCollection/project/_git/web", number: 7, }), ).toBe(true); }); it("does not guess when the project identity is unavailable", () => { expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, null)).toBeNull(); }); }); describe("resolveThreadPullRequestChains", () => { it("chains links by base → head within a repository, bottom to top", () => { const chains = resolveThreadPullRequestChains([ link(3, { snapshot: snapshot({ headBranch: "c", baseBranch: "b" }) }), link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "main" }) }), link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }), link(9, { snapshot: snapshot({ headBranch: "solo", baseBranch: "main" }) }), ]); expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([ ["derived", [1, 2, 3]], ["derived", [9]], ]); }); it("uses the native stack order when the host provides one", () => { const stack = { kind: "native" as const, id: "s1", number: 1, url: "https://github.com/pingdotgg/t3code/stacks/1", base: "main", layers: [ { number: 5, headBranch: "a", state: "merged" as const }, { number: 6, headBranch: "b", state: "open" as const }, ], }; const chains = resolveThreadPullRequestChains([ link(6, { snapshot: snapshot({ headBranch: "b", baseBranch: "main" }), stack }), link(5, { snapshot: snapshot({ state: "merged", headBranch: "a", baseBranch: "main" }), stack, }), link(8), ]); expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([ ["native", [5, 6]], ["derived", [8]], ]); }); }); describe("chain selection and badge state", () => { it.each([ ["open", false, "open", false, "open"], ["closed", false, "closed", false, "closed"], ["open", true, "open", true, "draft"], ["open", false, "open", true, "open"], ["closed", false, "open", true, "open"], ["merged", false, "merged", false, "merged"], ["merged", false, "closed", true, "closed"], ] as const)( "aggregates %s (draft %s) and %s (draft %s) as %s", (firstState, firstDraft, secondState, secondDraft, state) => { for (const stacked of [false, true]) { const links = [ link(1, { snapshot: snapshot({ state: firstState, isDraft: firstDraft, headBranch: "base" }), }), link(2, { snapshot: snapshot({ state: secondState, isDraft: secondDraft, baseBranch: stacked ? "base" : "main", }), }), link(3, { source: "stack-dismissed" }), ]; expect(resolveThreadPullRequestBadge(links)).toEqual( stacked ? { kind: "stack", layers: 2, state } : { kind: "pull-request", others: 1, state }, ); } }, ); it.each(["open", "merged", "closed"] as const)( "targets the top of a derived %s chain despite a later bottom update and link", (state) => { const bottom = link(2, { snapshot: snapshot({ state, headBranch: "base", updatedAt: "2026-02-01T00:00:00.000Z" }), }); const top = link(1, { snapshot: snapshot({ state, headBranch: "top", baseBranch: "base" }), }); expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject( state === "open" ? { kind: "stack", top: { number: 1 }, open: [top, bottom] } : { kind: "single", link: { number: 1 } }, ); expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({ kind: "stack", layers: 2, state, }); }, ); it("keeps the top native layer after completion and treats an unsynced layer as open", () => { const stack = { kind: "native" as const, id: "native-1", number: 1, url: "https://github.com/pingdotgg/t3code/stacks/1", base: "main", layers: [ { number: 2, headBranch: "base", state: "merged" as const }, { number: 1, headBranch: "top", state: "merged" as const }, ], }; const bottom = link(2, { stack, snapshot: snapshot({ state: "merged", updatedAt: "2026-02-01T00:00:00.000Z" }), }); const top = link(1, { stack, snapshot: snapshot({ state: "merged" }) }); expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject({ kind: "single", link: { number: 1 }, }); expect(resolveThreadPullRequestBadge([bottom, { ...top, snapshot: null }])).toEqual({ kind: "stack", layers: 2, state: "open", }); }); it("uses aggregate state and distinguishes unrelated work from a stack", () => { const bottom = link(1, { snapshot: snapshot({ state: "merged", headBranch: "base" }) }); const top = link(2, { snapshot: snapshot({ state: "closed", baseBranch: "base" }) }); expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({ kind: "stack", layers: 2, state: "closed", }); expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({ kind: "pull-request", others: 2, state: "open", }); expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull(); }); it("keeps branch matching case-sensitive while ignoring repository case", () => { const bottom = link(1, { repository: "PingDotGG/T3code", snapshot: snapshot({ headBranch: "Base" }), }); const top = link(2, { snapshot: snapshot({ headBranch: "top", baseBranch: "base" }) }); expect(resolveThreadPullRequestChains([bottom, top])).toHaveLength(2); expect( resolveThreadPullRequestChains([ bottom, { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) }, ])[0]?.layers, ).toEqual([bottom, { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) }]); }); it("preserves cyclic links without presenting a guessed stack order", () => { const links = [ link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "b" }) }), link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }), ]; expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual( links.map((entry) => [entry]), ); expect(resolveThreadCurrentPullRequest(links)).toMatchObject({ kind: "stack", top: { number: 2 }, }); expect(resolveThreadPullRequestBadge(links)).toEqual({ kind: "pull-request", others: 1, state: "open", }); }); it("does not guess a parent when a head branch was reused", () => { const links = [ link(1, { snapshot: snapshot({ state: "merged", headBranch: "reused" }) }), link(2, { snapshot: snapshot({ headBranch: "reused" }) }), link(3, { snapshot: snapshot({ headBranch: "top", baseBranch: "reused" }) }), ]; expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual( links.map((entry) => [entry]), ); }); }); describe("threadPullRequestSearchTerms", () => { it("includes completed and unsynced links but excludes dismissed links", () => { const terms = threadPullRequestSearchTerms({ pullRequests: [ link(12, { snapshot: snapshot({ title: "Fix login", state: "merged" }) }), link(34), link(56, { source: "stack-dismissed" }), ], }); expect(terms).toContain("#12"); expect(terms).toContain("pingdotgg/t3code#12"); expect(terms).toContain("https://github.com/pingdotgg/t3code/pull/12"); expect(terms).toContain("Fix login"); expect(terms).toContain("#34"); expect(terms.join(" ")).not.toContain("56"); }); }); it("searches the legacy projection when old environments decode to an empty links list", () => { const linkedPullRequest = { projectId: ProjectId.make("project"), repository: "pingdotgg/t3code", number: 12, url: "https://github.com/pingdotgg/t3code/pull/12", }; expect(threadPullRequestSearchTerms({ pullRequests: [], linkedPullRequest })).toContain("#12"); expect( threadPullRequestSearchTerms({ pullRequests: [link(34)], linkedPullRequest }), ).not.toContain("#12"); });