import type { RepositoryIdentity, SourceControlProviderKind, ThreadLinkedPullRequest, ThreadPullRequestKey, ThreadPullRequestLink, } from "@t3tools/contracts"; import { pullRequestHostOf } from "@t3tools/contracts"; import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; /** Normalize stored link identity, including Azure's SSH and browser host aliases. */ export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey { const canonical = canonicalRepositoryKey( `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, ); const separator = canonical.indexOf("/"); return { host: canonical.slice(0, separator), repository: canonical.slice(separator + 1), number: key.number, }; } /** Legacy Azure selectors omit the organization and project; recover those from the PR URL. */ export function legacyThreadPullRequestKey( linked: Pick, fallbackHost?: string, ): ThreadPullRequestKey { const parsed = parseChangeRequestUrl(linked.url); if (parsed !== null && parsed.number === linked.number) { const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); if (canonical.startsWith("dev.azure.com/")) { return normalizeThreadPullRequestKey(parsed); } } let host = fallbackHost; if (host === undefined) { try { host = new URL(linked.url).hostname; } catch { host = "unknown"; } } return { host: host.trim().toLowerCase() || "unknown", repository: linked.repository.trim().toLowerCase(), number: linked.number, }; } /** Identity comparison for links: host-level, case-insensitive on host and repository. */ export function threadPullRequestKeysEqual( left: ThreadPullRequestKey, right: ThreadPullRequestKey, ): boolean { return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); } export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string { const normalized = normalizeThreadPullRequestKey(key); return `${normalized.host}/${normalized.repository}#${normalized.number}`; } /** Links a user should see. Tombstoned stack members stay in the array only so the * sync reactor does not re-add them. */ export function visibleThreadPullRequests( links: ReadonlyArray, ): ReadonlyArray { return links.filter((link) => link.source !== "stack-dismissed"); } function isOpen(link: ThreadPullRequestLink): boolean { // Unsynced links are treated as open: they were just linked, and hiding them // behind a terminal PR until the first sync would make the link look lost. return link.snapshot === null || link.snapshot.state === "open"; } function latestUpdatedAt(link: ThreadPullRequestLink): number { const value = link.snapshot?.updatedAt ?? link.linkedAt; const ms = Date.parse(value); return Number.isNaN(ms) ? 0 : ms; } /** The single pull request a one-slot surface (sidebar badge, tab icon, copy link) shows. */ export type ThreadCurrentPullRequest = | { readonly kind: "single"; readonly link: ThreadPullRequestLink } | { readonly kind: "stack"; readonly open: ReadonlyArray; /** Highest layer of the open set; the one "View PR" and copy-link target. */ readonly top: ThreadPullRequestLink; }; /** * Prefer open work and the highest open layer within a chain. A completed chain still * points at its top; unrelated terminal links use the most recently updated request. */ export function resolveThreadCurrentPullRequest( links: ReadonlyArray, ): ThreadCurrentPullRequest | null { const visible = visibleThreadPullRequests(links); if (visible.length === 0) return null; const open = visible.filter(isOpen); if (open.length === 1) return { kind: "single", link: open[0]! }; const chains = resolveThreadPullRequestChains(visible); if (open.length > 1) { const openChains = chains .map((chain) => chain.layers.toReversed().filter(isOpen)) .filter((layers) => layers.length > 0) .sort( (left, right) => Math.max(...right.map((link) => Date.parse(link.linkedAt))) - Math.max(...left.map((link) => Date.parse(link.linkedAt))), ); const ordered = openChains.flat(); return { kind: "stack", open: ordered, top: ordered[0]! }; } if (chains.length === 1) { return { kind: "single", link: chains[0]!.layers.at(-1)! }; } const terminal = [...visible].sort( (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), ); return { kind: "single", link: terminal[0]! }; } /** The one link a legacy `linkedPullRequest` consumer should see, or null. */ export function resolveThreadCurrentPullRequestLink( links: ReadonlyArray, ): ThreadPullRequestLink | null { const current = resolveThreadCurrentPullRequest(links); if (current === null) return null; return current.kind === "single" ? current.link : current.top; } /** Legacy clients can only route links belonging to the thread's own repository. */ export function legacyLinkedPullRequestOf( links: ReadonlyArray, projectId: ThreadLinkedPullRequest["projectId"], identity: RepositoryIdentity | null | undefined, ): ThreadLinkedPullRequest | null { if (!identity) return null; const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind); const repository = sourceControlRepositorySelector(identity); if (repository === null) return null; const azureKey = identity.provider === "azure-devops" ? canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) : null; const link = resolveThreadCurrentPullRequestLink( links.filter((link) => { if (azureKey !== null) { const key = legacyThreadPullRequestKey(link, link.host); return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey; } return ( link.host.toLowerCase() === host.toLowerCase() && link.repository.toLowerCase() === repository.toLowerCase() ); }), ); if (link === null) return null; return { projectId, repository: azureKey === null ? link.repository : repository, number: link.number, url: link.url, }; } export interface ThreadPullRequestChain { readonly kind: "native" | "derived"; /** Bottom to top. */ readonly layers: ReadonlyArray; } /** * Groups a thread's links into stacks. Native stacks come from the host and win; the rest * are chained by matching one link's base branch to another's head branch within the same * repository. A link that chains to nothing is a one-layer chain. */ export function resolveThreadPullRequestChains( links: ReadonlyArray, ): ReadonlyArray { const visible = visibleThreadPullRequests(links); const chains: Array = []; const placed = new Set(); const nativeStacks = new Map>(); for (const link of visible) { if (link.stack === null) continue; const stackKey = `${link.host.toLowerCase()}/${link.repository.toLowerCase()}#stack:${link.stack.id}`; const members = nativeStacks.get(stackKey) ?? []; members.push(link); nativeStacks.set(stackKey, members); } for (const members of nativeStacks.values()) { const order = new Map(members[0]!.stack!.layers.map((layer, index) => [layer.number, index])); members.sort((left, right) => (order.get(left.number) ?? 0) - (order.get(right.number) ?? 0)); for (const member of members) placed.add(threadPullRequestKeyOf(member)); chains.push({ kind: "native", layers: members }); } const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); const branchKey = (link: ThreadPullRequestLink, branch: string) => `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`; // Reused head names cannot identify a parent unambiguously. const byHead = new Map(); for (const link of remaining) { if (link.snapshot === null) continue; const key = branchKey(link, link.snapshot.headBranch); byHead.set(key, byHead.has(key) ? null : link); } const hasChild = new Set(); for (const link of remaining) { if (link.snapshot === null) continue; const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); if (parent != null && parent !== link) hasChild.add(threadPullRequestKeyOf(parent)); } // Walk from each top (a link nothing builds on) down its base chain. for (const top of remaining) { if (hasChild.has(threadPullRequestKeyOf(top))) continue; const layers: Array = []; let cursor: ThreadPullRequestLink | undefined = top; while (cursor !== undefined && !placed.has(threadPullRequestKeyOf(cursor))) { placed.add(threadPullRequestKeyOf(cursor)); layers.unshift(cursor); cursor = cursor.snapshot === null ? undefined : (byHead.get(branchKey(cursor, cursor.snapshot.baseBranch)) ?? undefined); } if (layers.length > 0) chains.push({ kind: "derived", layers }); } // Cycles have no top. Keep those links visible without inventing a stack order. for (const link of remaining) { if (!placed.has(threadPullRequestKeyOf(link))) { chains.push({ kind: "derived", layers: [link] }); } } return chains; } export type ThreadPullRequestBadge = { readonly state: "open" | "closed" | "merged" | "draft"; } & ( | { readonly kind: "stack"; readonly layers: number; } | { readonly kind: "pull-request"; readonly others: number } ); /** Aggregate visible links' state for both stacks and unrelated linked counts. */ export function resolveThreadPullRequestBadge( pullRequests: ReadonlyArray | undefined, ): ThreadPullRequestBadge | null { const visible = visibleThreadPullRequests(pullRequests ?? []); if (visible.length === 0) return null; const states = visible.map((link) => link.snapshot?.state ?? "open"); const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) ? "draft" : states.includes("open") ? "open" : states.every((entry) => entry === "merged") ? "merged" : "closed"; const chains = resolveThreadPullRequestChains(visible); if (visible.length > 1 && chains.length === 1) { return { kind: "stack", layers: visible.length, state }; } return { kind: "pull-request", others: visible.length - 1, state }; } /** Search terms for visible PR links, including the legacy single-link projection. */ export function threadPullRequestSearchTerms(thread: { readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; }): string[] { if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) { return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ `#${link.number}`, `${link.repository}#${link.number}`, link.url, link.snapshot?.title ?? "", ]); } const legacy = thread.linkedPullRequest; return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : []; }