1import type { ThreadPullRequestLink } from "@t3tools/contracts";
2
3import { visibleThreadPullRequests } from "./threadPullRequests.ts";
4
5export interface ComposerPullRequestMatch {
6 readonly number: number;
7 readonly projectId: string;
8 readonly repository: string;
9 readonly updatedAt: string;
10}
11
12/**
13 * Suggestion rows for the pull requests a thread links, from any repository. Built from the
14 * synced snapshot, so a link the server has not read yet is left out until it has one.
15 */
16export function composerPullRequestsFromLinks(
17 links: ReadonlyArray<ThreadPullRequestLink>,
18 projectId: string,
19) {
20 return visibleThreadPullRequests(links).flatMap((link) =>
21 link.snapshot === null
22 ? []
23 : [
24 {
25 projectId,
26 repository: link.repository,
27 number: link.number,
28 url: link.url,
29 title: link.snapshot.title,
30 headBranch: link.snapshot.headBranch,
31 baseBranch: link.snapshot.baseBranch,
32 state: link.snapshot.state,
33 isDraft: link.snapshot.isDraft,
34 updatedAt: link.snapshot.updatedAt ?? link.linkedAt,
35 },
36 ],
37 );
38}
39
40/**
41 * Pull requests matching the numeric fragment typed after `#`, de-duplicated. `entries` are kept
42 * to the project's repository; `linked` rows (the thread's own pull requests) may come from any
43 * repository. An exact number match always outranks a substring match so the result limit can
44 * never drop the pull request the user typed in full; then linked rows lead, newest first.
45 */
46export function filterComposerPullRequestMatches<
47 Entry extends ComposerPullRequestMatch,
48 Linked extends ComposerPullRequestMatch = never,
49>(input: {
50 readonly entries: ReadonlyArray<Entry>;
51 readonly linked?: ReadonlyArray<Linked>;
52 readonly projectId: string;
53 readonly repository: string;
54 readonly query: string;
55 readonly limit: number;
56}): ReadonlyArray<Entry | Linked> {
57 const repository = input.repository.trim().toLowerCase();
58 const keyOf = (entry: ComposerPullRequestMatch) =>
59 `${entry.repository.trim().toLowerCase()}#${entry.number}`;
60 const linked = (input.linked ?? []).filter((entry) => String(entry.number).includes(input.query));
61 const linkedKeys = new Set(linked.map(keyOf));
62 const matchingEntries = input.entries.filter(
63 (entry) =>
64 entry.projectId === input.projectId &&
65 entry.repository.trim().toLowerCase() === repository &&
66 String(entry.number).includes(input.query),
67 );
68 // Project rows go first so a linked pull request the listing also has keeps the fresher row.
69 const uniqueEntries = new Map<string, Entry | Linked>();
70 for (const entry of [...matchingEntries, ...linked]) {
71 if (!uniqueEntries.has(keyOf(entry))) {
72 uniqueEntries.set(keyOf(entry), entry);
73 }
74 }
75 const isExactMatch = (entry: ComposerPullRequestMatch) => String(entry.number) === input.query;
76 const isLinked = (entry: ComposerPullRequestMatch) => linkedKeys.has(keyOf(entry));
77 // `.sort()` on a copy, not `.toSorted()`: this runs on Hermes, which has no ES2023 array
78 // methods, and reaching for one here crashed the composer as the suggestions loaded.
79 return [...uniqueEntries.values()]
80 .sort(
81 (left, right) =>
82 Number(isExactMatch(right)) - Number(isExactMatch(left)) ||
83 Number(isLinked(right)) - Number(isLinked(left)) ||
84 right.updatedAt.localeCompare(left.updatedAt),
85 )
86 .slice(0, input.limit);
87}