1import type {
2 OrchestrationV2AppThread,
3 RepositoryIdentity,
4 SourceControlProviderKind,
5 ThreadLinkedPullRequest,
6 ThreadPullRequestKey,
7 ThreadPullRequestLink,
8} from "@t3tools/contracts";
9
10import { pullRequestHostOf } from "@t3tools/contracts";
11import { parseChangeRequestUrl } from "./changeRequestUrl.ts";
12import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts";
13
14type ThreadPullRequestKeySource = ThreadPullRequestKey & {
15 readonly authority?: string;
16 readonly url?: string;
17};
18
19/** Normalize stored links, recovering Forgejo HTTP ports from old links' URLs. */
20export function normalizeThreadPullRequestKey(
21 key: ThreadPullRequestKeySource,
22): ThreadPullRequestKey {
23 const parsed = key.url === undefined ? null : parseChangeRequestUrl(key.url);
24 const authority =
25 key.authority ??
26 (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number
27 ? parsed.authority
28 : undefined);
29 const canonical = canonicalRepositoryKey(
30 `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
31 );
32 const separator = canonical.indexOf("/");
33 return {
34 host: canonical.slice(0, separator),
35 repository: canonical.slice(separator + 1),
36 number: key.number,
37 };
38}
39
40/** Legacy Azure selectors omit the organization and project; recover those from the PR URL. */
41export function legacyThreadPullRequestKey(
42 linked: Pick<ThreadLinkedPullRequest, "repository" | "number" | "url">,
43 fallbackHost?: string,
44): ThreadPullRequestKey {
45 const parsed = parseChangeRequestUrl(linked.url);
46 if (parsed !== null && parsed.number === linked.number) {
47 const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`);
48 if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) {
49 return normalizeThreadPullRequestKey(parsed);
50 }
51 }
52 let host = fallbackHost;
53 if (host === undefined) {
54 try {
55 host = new URL(linked.url).hostname;
56 } catch {
57 host = "unknown";
58 }
59 }
60 return {
61 host: host.trim().toLowerCase() || "unknown",
62 repository: linked.repository.trim().toLowerCase(),
63 number: linked.number,
64 };
65}
66
67/** Identity comparison for links: host-level, case-insensitive on host and repository. */
68export function threadPullRequestKeysEqual(
69 left: ThreadPullRequestKeySource,
70 right: ThreadPullRequestKeySource,
71): boolean {
72 return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right);
73}
74
75export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string {
76 const normalized = normalizeThreadPullRequestKey(key);
77 return `${normalized.host}/${normalized.repository}#${normalized.number}`;
78}
79
80/** Links a user should see. Tombstoned stack members stay in the array only so the
81 * sync reactor does not re-add them. */
82export function visibleThreadPullRequests(
83 links: ReadonlyArray<ThreadPullRequestLink>,
84): ReadonlyArray<ThreadPullRequestLink> {
85 return links.filter((link) => link.source !== "stack-dismissed");
86}
87
88function isOpen(link: ThreadPullRequestLink): boolean {
89 // Unsynced links are treated as open: they were just linked, and hiding them
90 // behind a terminal PR until the first sync would make the link look lost.
91 return link.snapshot === null || link.snapshot.state === "open";
92}
93
94function latestUpdatedAt(link: ThreadPullRequestLink): number {
95 const value = link.snapshot?.updatedAt ?? link.linkedAt;
96 const ms = Date.parse(value);
97 return Number.isNaN(ms) ? 0 : ms;
98}
99
100/** The single pull request a one-slot surface (sidebar badge, tab icon, copy link) shows. */
101export type ThreadCurrentPullRequest =
102 | { readonly kind: "single"; readonly link: ThreadPullRequestLink }
103 | {
104 readonly kind: "stack";
105 readonly open: ReadonlyArray<ThreadPullRequestLink>;
106 /** Highest layer of the open set; the one "View PR" and copy-link target. */
107 readonly top: ThreadPullRequestLink;
108 };
109
110/**
111 * Prefer open work and the highest open layer within a chain. A completed chain still
112 * points at its top; unrelated terminal links use the most recently updated request.
113 */
114export function resolveThreadCurrentPullRequest(
115 links: ReadonlyArray<ThreadPullRequestLink>,
116): ThreadCurrentPullRequest | null {
117 const visible = visibleThreadPullRequests(links);
118 if (visible.length === 0) return null;
119 const open = visible.filter(isOpen);
120 if (open.length === 1) return { kind: "single", link: open[0]! };
121 const chains = resolveThreadPullRequestChains(visible);
122 if (open.length > 1) {
123 // `.reverse()` on a copy, not `.toReversed()`: this runs on Hermes, which has no ES2023
124 // array methods, and a TypeError here is fatal on every mobile launch that renders a stack.
125 const openChains = chains
126 .map((chain) => [...chain.layers].reverse().filter(isOpen))
127 .filter((layers) => layers.length > 0)
128 .sort(
129 (left, right) =>
130 Math.max(...right.map((link) => Date.parse(link.linkedAt))) -
131 Math.max(...left.map((link) => Date.parse(link.linkedAt))),
132 );
133 const ordered = openChains.flat();
134 return { kind: "stack", open: ordered, top: ordered[0]! };
135 }
136 if (chains.length === 1) {
137 return { kind: "single", link: chains[0]!.layers.at(-1)! };
138 }
139 const terminal = [...visible].sort(
140 (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left),
141 );
142 return { kind: "single", link: terminal[0]! };
143}
144
145/** The one link a legacy `linkedPullRequest` consumer should see, or null. */
146export function resolveThreadCurrentPullRequestLink(
147 links: ReadonlyArray<ThreadPullRequestLink>,
148): ThreadPullRequestLink | null {
149 const current = resolveThreadCurrentPullRequest(links);
150 if (current === null) return null;
151 return current.kind === "single" ? current.link : current.top;
152}
153
154/** Legacy clients can only route links belonging to the thread's own repository. */
155export function legacyLinkedPullRequestOf(
156 links: ReadonlyArray<ThreadPullRequestLink>,
157 projectId: ThreadLinkedPullRequest["projectId"],
158 identity: RepositoryIdentity | null | undefined,
159): ThreadLinkedPullRequest | null {
160 if (!identity) return null;
161 // A local-path remote has no host segment and no provider, so there is no host to match.
162 const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind);
163 if (typeof host !== "string") return null;
164 const repository = sourceControlRepositorySelector(identity);
165 if (repository === null) return null;
166 const azureKey =
167 identity.provider === "azure-devops"
168 ? canonicalRepositoryKey(identity.canonicalKey.toLowerCase())
169 : null;
170 const link = resolveThreadCurrentPullRequestLink(
171 links.filter((link) => {
172 if (azureKey !== null) {
173 const key = legacyThreadPullRequestKey(link, link.host);
174 return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey;
175 }
176 const parsed = parseChangeRequestUrl(link.url);
177 if (parsed?.authority !== undefined) {
178 try {
179 const remote = new URL(identity.locator.remoteUrl);
180 if (remote.protocol === "http:" || remote.protocol === "https:") {
181 return (
182 parsed.authority === remote.host && parsed.repository === repository.toLowerCase()
183 );
184 }
185 } catch {
186 // SSH web ports are resolved by the provider's configured login.
187 }
188 return parsed.host === host && parsed.repository === repository.toLowerCase();
189 }
190 return (
191 link.host.toLowerCase() === host.toLowerCase() &&
192 link.repository.toLowerCase() === repository.toLowerCase()
193 );
194 }),
195 );
196 if (link === null) return null;
197 return {
198 projectId,
199 repository: azureKey === null ? link.repository : repository,
200 number: link.number,
201 url: link.url,
202 };
203}
204
205export interface ThreadPullRequestChain {
206 readonly kind: "native" | "derived";
207 /** Bottom to top. */
208 readonly layers: ReadonlyArray<ThreadPullRequestLink>;
209}
210
211/**
212 * Groups a thread's links into stacks. Native stacks come from the host and win; the rest
213 * are chained by matching one link's base branch to another's head branch within the same
214 * repository. A link that chains to nothing is a one-layer chain.
215 */
216export function resolveThreadPullRequestChains(
217 links: ReadonlyArray<ThreadPullRequestLink>,
218): ReadonlyArray<ThreadPullRequestChain> {
219 const visible = visibleThreadPullRequests(links);
220 const chains: Array<ThreadPullRequestChain> = [];
221 const placed = new Set<string>();
222
223 const nativeStacks = new Map<string, Array<ThreadPullRequestLink>>();
224 for (const link of visible) {
225 if (link.stack === null) continue;
226 const key = normalizeThreadPullRequestKey(link);
227 const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`;
228 const members = nativeStacks.get(stackKey) ?? [];
229 members.push(link);
230 nativeStacks.set(stackKey, members);
231 }
232 for (const members of nativeStacks.values()) {
233 const order = new Map(members[0]!.stack!.layers.map((layer, index) => [layer.number, index]));
234 members.sort((left, right) => (order.get(left.number) ?? 0) - (order.get(right.number) ?? 0));
235 for (const member of members) placed.add(threadPullRequestKeyOf(member));
236 chains.push({ kind: "native", layers: members });
237 }
238
239 const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link)));
240 const branchKey = (link: ThreadPullRequestLink, branch: string) => {
241 const key = normalizeThreadPullRequestKey(link);
242 return `${key.host}/${key.repository}:${branch}`;
243 };
244 // Reused head names cannot identify a parent unambiguously.
245 const byHead = new Map<string, ThreadPullRequestLink | null>();
246 for (const link of remaining) {
247 if (link.snapshot === null) continue;
248 const key = branchKey(link, link.snapshot.headBranch);
249 byHead.set(key, byHead.has(key) ? null : link);
250 }
251 const hasChild = new Set<string>();
252 for (const link of remaining) {
253 if (link.snapshot === null) continue;
254 const parent = byHead.get(branchKey(link, link.snapshot.baseBranch));
255 if (parent != null && parent !== link) hasChild.add(threadPullRequestKeyOf(parent));
256 }
257 // Walk from each top (a link nothing builds on) down its base chain.
258 for (const top of remaining) {
259 if (hasChild.has(threadPullRequestKeyOf(top))) continue;
260 const layers: Array<ThreadPullRequestLink> = [];
261 let cursor: ThreadPullRequestLink | undefined = top;
262 while (cursor !== undefined && !placed.has(threadPullRequestKeyOf(cursor))) {
263 placed.add(threadPullRequestKeyOf(cursor));
264 layers.unshift(cursor);
265 cursor =
266 cursor.snapshot === null
267 ? undefined
268 : (byHead.get(branchKey(cursor, cursor.snapshot.baseBranch)) ?? undefined);
269 }
270 if (layers.length > 0) chains.push({ kind: "derived", layers });
271 }
272 // Cycles have no top. Keep those links visible without inventing a stack order.
273 for (const link of remaining) {
274 if (!placed.has(threadPullRequestKeyOf(link))) {
275 chains.push({ kind: "derived", layers: [link] });
276 }
277 }
278 return chains;
279}
280
281export type ThreadPullRequestBadge = {
282 readonly state: "open" | "closed" | "merged" | "draft";
283} & (
284 | {
285 readonly kind: "stack";
286 readonly layers: number;
287 }
288 | { readonly kind: "pull-request"; readonly others: number }
289);
290
291/** Aggregate visible links' state for both stacks and unrelated linked counts. */
292export function resolveThreadPullRequestBadge(
293 pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,
294): ThreadPullRequestBadge | null {
295 const visible = visibleThreadPullRequests(pullRequests ?? []);
296 if (visible.length === 0) return null;
297 const states = visible.map((link) => link.snapshot?.state ?? "open");
298 const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft)
299 ? "draft"
300 : states.includes("open")
301 ? "open"
302 : states.every((entry) => entry === "merged")
303 ? "merged"
304 : "closed";
305 const chains = resolveThreadPullRequestChains(visible);
306 if (visible.length > 1 && chains.length === 1) {
307 return { kind: "stack", layers: visible.length, state };
308 }
309 return { kind: "pull-request", others: visible.length - 1, state };
310}
311
312/** Search terms for visible PR links, the legacy projection, and the branch PR. */
313export function threadPullRequestSearchTerms(thread: {
314 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
315 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
316 readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined;
317}): string[] {
318 const branch = thread.branchPullRequest;
319 const terms = branch
320 ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url]
321 : [];
322 if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) {
323 return terms.concat(
324 visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [
325 `#${link.number}`,
326 `${link.repository}#${link.number}`,
327 link.url,
328 link.snapshot?.title ?? "",
329 ]),
330 );
331 }
332 const legacy = thread.linkedPullRequest;
333 return legacy
334 ? terms.concat([`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url])
335 : terms;
336}
337
338/** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */
339export function threadPullRequestsOf(thread: {
340 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
341 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
342}): ReadonlyArray<ThreadPullRequestLink> {
343 if (thread.pullRequests !== undefined) return thread.pullRequests;
344 const linked = thread.linkedPullRequest;
345 return linked == null
346 ? []
347 : [
348 {
349 ...legacyThreadPullRequestKey(linked),
350 url: linked.url,
351 source: "manual",
352 linkedAt: "1970-01-01T00:00:00.000Z",
353 snapshot: null,
354 stack: null,
355 },
356 ];
357}
358
359/** Apply a single-link sync without copying unrelated PRs into the event history. */
360export function applyThreadPullRequestLinkUpdate(
361 thread: OrchestrationV2AppThread,
362 updated: ThreadPullRequestLink,
363): OrchestrationV2AppThread {
364 return {
365 ...thread,
366 pullRequests: threadPullRequestsOf(thread).map((link) =>
367 threadPullRequestKeysEqual(link, updated) ? updated : link,
368 ),
369 };
370}