1import type {
2 EnvironmentId,
3 PullRequestListEntry,
4 PullRequestListInput,
5 PullRequestListResult,
6} from "@t3tools/contracts";
7import type { PullRequestListControls } from "../../persistence/mobile-preferences";
8
9export const PR_LIST_PAGE_SIZE = 30;
10export type { PullRequestListControls } from "../../persistence/mobile-preferences";
11export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = {
12 state: "open",
13 involvement: "all",
14 filters: {},
15 sort: "updated",
16};
17
18/** A scope saved on the Scratch folder is no longer offered, so it reads as "All projects". */
19export function withoutScratchProjectScope(
20 controls: PullRequestListControls,
21 projects: readonly { id: string; environmentId: string; isScratch?: true }[],
22): PullRequestListControls {
23 return projects.some(
24 (project) =>
25 project.isScratch &&
26 project.id === controls.projectId &&
27 project.environmentId === controls.projectEnvironmentId,
28 )
29 ? { ...controls, projectId: undefined, projectEnvironmentId: undefined }
30 : controls;
31}
32
33export function pullRequestListKey(
34 item: Pick<PullRequestListEntry, "host" | "repository" | "number">,
35) {
36 return `${item.host.toLowerCase()}:${item.repository.toLowerCase()}:${item.number}`;
37}
38
39export type EnvironmentPullRequestListEntry = PullRequestListEntry & {
40 readonly environmentId: EnvironmentId;
41};
42
43export const scopedPullRequestListKey = (entry: EnvironmentPullRequestListEntry) =>
44 `${entry.environmentId}:${pullRequestListKey(entry)}`;
45
46/** Provider refreshes and diff-count batches recreate entries; unchanged rows can stay mounted. */
47export function pullRequestListRowsAreEqual(
48 previous: EnvironmentPullRequestListEntry,
49 next: EnvironmentPullRequestListEntry,
50) {
51 return (
52 previous === next ||
53 (previous.environmentId === next.environmentId &&
54 previous.projectId === next.projectId &&
55 previous.host === next.host &&
56 previous.repository === next.repository &&
57 previous.number === next.number &&
58 previous.title === next.title &&
59 previous.url === next.url &&
60 previous.state === next.state &&
61 previous.isDraft === next.isDraft &&
62 previous.updatedAt === next.updatedAt &&
63 previous.additions === next.additions &&
64 previous.deletions === next.deletions &&
65 previous.checksState === next.checksState &&
66 previous.reviewDecision === next.reviewDecision &&
67 previous.viewerReviewRequested === next.viewerReviewRequested &&
68 previous.author?.login === next.author?.login &&
69 previous.author?.avatarUrl === next.author?.avatarUrl &&
70 previous.stack?.position === next.stack?.position &&
71 previous.stack?.size === next.stack?.size &&
72 previous.labels.length === next.labels.length &&
73 previous.labels.every(
74 (label, index) =>
75 label.name === next.labels[index]?.name && label.color === next.labels[index]?.color,
76 ))
77 );
78}
79
80/** Keep cursors and viewer identities with the environment that issued them. */
81export function mergeEnvironmentPullRequestPages(
82 sources: readonly { environmentId: EnvironmentId; pages: PullRequestListPages }[],
83) {
84 const viewers: Record<string, string> = {};
85 const localSearchHosts = new Set<string>();
86 const entries: EnvironmentPullRequestListEntry[] = [];
87 const errors = [];
88 for (const { environmentId, pages } of sources) {
89 entries.push(...pages.entries.map((entry) => ({ ...entry, environmentId })));
90 for (const [host, viewer] of Object.entries(pages.head.viewers))
91 viewers[`${environmentId} ${host}`] = viewer;
92 for (const provider of pages.head.providers)
93 if (!provider.searchesOnHost) localSearchHosts.add(`${environmentId} ${provider.host}`);
94 errors.push(...pages.head.errors.map((error) => ({ ...error, environmentId })));
95 }
96 return {
97 entries,
98 viewers,
99 localSearchHosts,
100 errors,
101 continued: sources.some(({ pages }) => pages.continued),
102 };
103}
104
105export function mergePullRequestListEntries(
106 previous: readonly PullRequestListEntry[],
107 incoming: readonly PullRequestListEntry[],
108) {
109 const entries = new Map(previous.map((entry) => [pullRequestListKey(entry), entry]));
110 for (const entry of incoming) {
111 const key = pullRequestListKey(entry);
112 const held = entries.get(key);
113 if (!held || (entry.observedAt ?? 0) >= (held.observedAt ?? 0)) entries.set(key, entry);
114 }
115 return [...entries.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
116}
117
118export interface PullRequestListPages {
119 readonly head: PullRequestListResult;
120 readonly entries: readonly PullRequestListEntry[];
121 readonly nextCursors: PullRequestListResult["nextCursors"];
122 readonly truncated: boolean;
123 readonly limit: number;
124 readonly continued: boolean;
125}
126
127export function pullRequestListHead(
128 previous: PullRequestListPages | null,
129 head: PullRequestListResult,
130): PullRequestListPages {
131 if (previous?.head === head) return previous;
132 return previous?.continued
133 ? { ...previous, head, entries: mergePullRequestListEntries(previous.entries, head.entries) }
134 : {
135 head,
136 entries: head.entries,
137 nextCursors: head.nextCursors,
138 truncated: head.truncated,
139 limit: PR_LIST_PAGE_SIZE,
140 continued: false,
141 };
142}
143
144export function nextPullRequestListInput(
145 pages: PullRequestListPages,
146 input: PullRequestListInput,
147): PullRequestListInput | null {
148 if (Object.keys(pages.nextCursors).length)
149 return { ...input, limit: PR_LIST_PAGE_SIZE, cursors: pages.nextCursors };
150 return pages.truncated && pages.limit < 500
151 ? { ...input, limit: Math.min(pages.limit + PR_LIST_PAGE_SIZE, 500) }
152 : null;
153}
154
155export function appendPullRequestListPage(
156 previous: PullRequestListPages,
157 page: PullRequestListResult,
158 input: PullRequestListInput,
159): PullRequestListPages {
160 return {
161 ...previous,
162 entries: mergePullRequestListEntries(previous.entries, page.entries),
163 nextCursors: page.nextCursors,
164 truncated: page.truncated,
165 limit: input.limit ?? PR_LIST_PAGE_SIZE,
166 continued: true,
167 };
168}
169
170export function pullRequestListFacets(entries: readonly PullRequestListEntry[]) {
171 return {
172 authors: [
173 ...new Set(entries.flatMap((entry) => (entry.author ? [entry.author.login] : []))),
174 ].sort((a, b) => a.localeCompare(b)),
175 labels: [...new Set(entries.flatMap((entry) => entry.labels.map((label) => label.name)))].sort(
176 (a, b) => a.localeCompare(b),
177 ),
178 };
179}
180
181export function filterPullRequestList<
182 Entry extends PullRequestListEntry & { environmentId?: string },
183>(
184 entries: readonly Entry[],
185 controls: PullRequestListControls,
186 viewers: PullRequestListResult["viewers"],
187 query: string,
188 localSearchHosts: ReadonlySet<string>,
189 connectedEnvironmentIds?: ReadonlySet<string>,
190) {
191 const normalize = (value: string) => value.toLowerCase();
192 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
193 const { filters } = controls;
194 const filtered = entries.filter((entry) => {
195 if (controls.state !== "all" && entry.state !== controls.state) return false;
196 if (controls.projectId && entry.projectId !== controls.projectId) return false;
197 if (controls.projectEnvironmentId && entry.environmentId !== controls.projectEnvironmentId)
198 return false;
199 const viewerKey = entry.environmentId ? `${entry.environmentId} ${entry.host}` : entry.host;
200 const viewer = viewers[viewerKey];
201 if (controls.involvement === "reviewing" && !entry.viewerReviewRequested) return false;
202 if (
203 controls.involvement === "authored" &&
204 (!viewer || normalize(entry.author?.login ?? "") !== normalize(viewer))
205 )
206 return false;
207 if ((filters.draft === "only" && !entry.isDraft) || (filters.draft === "hide" && entry.isDraft))
208 return false;
209 if (filters.author) {
210 const author = filters.author === "me" ? viewer : filters.author;
211 if (!author || normalize(entry.author?.login ?? "") !== normalize(author)) return false;
212 }
213 // Hosts without a review rollup still perform their own review filtering.
214 if (filters.review && entry.reviewDecision && entry.reviewDecision !== filters.review)
215 return false;
216 if (filters.review === "none" && entry.reviewDecision) return false;
217 if (filters.checks && entry.checksState && entry.checksState !== filters.checks) return false;
218 const labels = new Set(entry.labels.map((label) => normalize(label.name)));
219 if (filters.labels?.some((group) => !group.some((label) => labels.has(normalize(label)))))
220 return false;
221 if (filters.excludedLabels?.some((label) => labels.has(normalize(label)))) return false;
222 if (words.length && localSearchHosts.has(viewerKey)) {
223 const text =
224 `${entry.repository} #${entry.number} ${entry.title} ${entry.author?.login ?? ""} ${entry.headBranch} ${entry.baseBranch} ${entry.labels.map((label) => label.name).join(" ")}`.toLowerCase();
225 if (!words.every((word) => text.includes(word))) return false;
226 }
227 return true;
228 });
229 // The same repository can be available on multiple machines. Pick its first matching source,
230 // unless that machine is unreachable and another one can answer: its rows may only be cached.
231 const reachable = (entry: Entry) =>
232 !connectedEnvironmentIds ||
233 (entry.environmentId !== undefined && connectedEnvironmentIds.has(entry.environmentId));
234 const unique = new Map<string, Entry>();
235 for (const entry of filtered) {
236 const key = pullRequestListKey(entry);
237 const held = unique.get(key);
238 if (!held || (!reachable(held) && reachable(entry))) unique.set(key, entry);
239 }
240 return [...unique.values()].sort((a, b) =>
241 controls.sort === "created"
242 ? b.createdAt.localeCompare(a.createdAt)
243 : controls.sort === "oldest"
244 ? a.updatedAt.localeCompare(b.updatedAt)
245 : b.updatedAt.localeCompare(a.updatedAt),
246 );
247}
248
249export function pullRequestListReviewStatus(
250 entry: Pick<PullRequestListEntry, "reviewDecision" | "viewerReviewRequested">,
251) {
252 if (entry.reviewDecision === "changes-requested")
253 return {
254 label: "Changes",
255 accessibleLabel: "Changes requested",
256 icon: "person.fill.xmark",
257 tint: "accent-danger-foreground",
258 } as const;
259 if (entry.reviewDecision === "approved")
260 return {
261 label: "Approved",
262 accessibleLabel: "Reviews approved",
263 icon: "person.fill.checkmark",
264 tint: "accent-adaptive-emerald-600-400",
265 } as const;
266 if (entry.viewerReviewRequested)
267 return {
268 label: "Your review",
269 accessibleLabel: "Your review requested",
270 icon: "person.badge.clock",
271 tint: "accent-warning-foreground",
272 } as const;
273 if (entry.reviewDecision === "review-required")
274 return {
275 label: "Review",
276 accessibleLabel: "Review required",
277 icon: "person.badge.clock",
278 tint: "accent-icon-muted",
279 } as const;
280 return null;
281}
282
283export function pullRequestLabelColor(color: string | null) {
284 const hex = color?.replace(/^#/, "");
285 return hex && /^[\da-f]{6}$/i.test(hex) ? `#${hex}` : null;
286}