Skip to content
Source
Sign inSign up

pullRequestList.ts

502 lines18.2 KB

1import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter";
2import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query";
3import {
4 ProjectId,
5 resolvePullRequestAuthorFilter,
6 type EnvironmentId,
7 type PullRequestDiffStat,
8 type PullRequestListEntry,
9 type PullRequestListInput,
10 type PullRequestListResult,
11 type PullRequestListStatsInput,
12} from "@t3tools/contracts";
13import type { PullRequestListControls } from "../../persistence/mobile-preferences";
14​
15export const PR_LIST_PAGE_SIZE = 30;
16export type { PullRequestListControls } from "../../persistence/mobile-preferences";
17export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = {
18 state: "open",
19 involvement: "all",
20 filters: {},
21 sort: "updated",
22};
23​
24export function withoutScratchProjectScope(
25 controls: PullRequestListControls,
26 projects: readonly { id: string; environmentId: string; isScratch?: true }[],
27): PullRequestListControls {
28 return projects.some(
29 (project) =>
30 project.isScratch &&
31 project.id === controls.projectId &&
32 project.environmentId === controls.projectEnvironmentId,
33 )
34 ? { ...controls, projectId: undefined, projectEnvironmentId: undefined }
35 : controls;
37​
38type FilterProject = Parameters<typeof pullRequestFilterProjects>[0][number];
39​
40export function pullRequestProjectChoices<
41 Project extends FilterProject & { readonly isScratch?: true },
42>(
43 projects: readonly Project[],
44 input: {
45 readonly environmentId: string | null;
46 readonly controls: PullRequestListControls;
47 readonly environmentLabels: ReadonlyMap<EnvironmentId, string>;
48 readonly servesPullRequests: (environmentId: EnvironmentId) => boolean;
49 },
50) {
51 const isSelected = (project: Project) =>
52 project.id === input.controls.projectId &&
53 project.environmentId === input.controls.projectEnvironmentId;
54 return pullRequestFilterProjects(
55 projects.filter(
56 (project) =>
57 !project.isScratch &&
58 (input.environmentId === null || project.environmentId === input.environmentId) &&
59 (input.servesPullRequests(project.environmentId) || isSelected(project)),
60 ),
61 input.environmentLabels,
62 projects.find(isSelected),
63 );
65​
66export function pullRequestListKey(
67 item: Pick<PullRequestListEntry, "host" | "repository" | "number">,
68) {
69 return `${item.host.toLowerCase()}:${item.repository.toLowerCase()}:${item.number}`;
71​
72export type EnvironmentPullRequestListEntry = PullRequestListEntry & {
73 readonly environmentId: EnvironmentId;
74};
75​
76export const scopedPullRequestListKey = (entry: EnvironmentPullRequestListEntry) =>
77 `${entry.environmentId}:${pullRequestListKey(entry)}`;
78​
79export const pullRequestStatKey = (
80 row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">,
81) => `${row.projectId}:${row.repository}:${row.number}`;
82​
83/**
84 * Diff stats an environment has answered, the stats query that answered each, and each row's
85 * `updatedAt` as its list last answered it.
86 */
87export interface PullRequestStatsCache {
88 readonly stats: ReadonlyMap<string, PullRequestDiffStat>;
89 readonly answeredBy: ReadonlyMap<string, PullRequestListStatsInput>;
90 readonly updatedAt: ReadonlyMap<string, string>;
92​
93export const EMPTY_PULL_REQUEST_STATS_CACHE: PullRequestStatsCache = {
94 stats: new Map(),
95 answeredBy: new Map(),
96 updatedAt: new Map(),
97};
98​
99/**
100 * Records the rows a list answered. Stats carry no head, so a row whose `updatedAt` moved (a push,
101 * among other changes) drops its cached stat, and the stats queries that answered it are returned
102 * to refresh: one can still be fresh and would otherwise answer the row's next read with the old
103 * counts.
104 */
105export function withListedPullRequests(
106 cache: PullRequestStatsCache,
107 entries: readonly Pick<
108 PullRequestListEntry,
109 "projectId" | "repository" | "number" | "updatedAt"
110 >[],
111): {
112 readonly cache: PullRequestStatsCache;
113 readonly refresh: readonly PullRequestListStatsInput[];
114} {
115 const moved = entries.filter(
116 (entry) => cache.updatedAt.get(pullRequestStatKey(entry)) !== entry.updatedAt,
117 );
118 if (!moved.length) return { cache, refresh: [] };
119 const stats = new Map(cache.stats);
120 const answeredBy = new Map(cache.answeredBy);
121 const updatedAt = new Map(cache.updatedAt);
122 const refresh = new Set<PullRequestListStatsInput>();
123 for (const entry of moved) {
124 const key = pullRequestStatKey(entry);
125 const query = answeredBy.get(key);
126 if (query) refresh.add(query);
127 stats.delete(key);
128 answeredBy.delete(key);
129 updatedAt.set(key, entry.updatedAt);
130 }
131 return { cache: { stats, answeredBy, updatedAt }, refresh: [...refresh] };
133​
134export function withAnsweredPullRequestStats(
135 cache: PullRequestStatsCache,
136 input: PullRequestListStatsInput,
137 answered: readonly PullRequestDiffStat[],
138): PullRequestStatsCache {
139 const stats = new Map(cache.stats);
140 const answeredBy = new Map(cache.answeredBy);
141 for (const row of answered) {
142 stats.set(pullRequestStatKey(row), row);
143 answeredBy.set(pullRequestStatKey(row), input);
144 }
145 return { ...cache, stats, answeredBy };
147​
148/**
149 * Fills rows that have no diff stat yet from the stats answers on screen. Returns `entries`
150 * itself when no row changes, so the list keeps its identity across renders.
151 */
152export function withShownPullRequestStats(
153 entries: readonly EnvironmentPullRequestListEntry[],
154 answers: readonly {
155 readonly environmentId: EnvironmentId;
156 readonly stats: readonly PullRequestDiffStat[];
157 }[],
158): readonly EnvironmentPullRequestListEntry[] {
159 const shown = new Map(
160 answers.flatMap(({ environmentId, stats }) =>
161 stats.map((row) => [`${environmentId}:${pullRequestStatKey(row)}`, row] as const),
162 ),
163 );
164 if (!shown.size) return entries;
165 let changed = false;
166 const next = entries.map((entry) => {
167 const stat = shown.get(`${entry.environmentId}:${pullRequestStatKey(entry)}`);
168 if (!stat || entry.additions + entry.deletions !== 0) return entry;
169 if (stat.additions + stat.deletions === 0) return entry;
170 changed = true;
171 return { ...entry, additions: stat.additions, deletions: stat.deletions };
172 });
173 return changed ? next : entries;
175​
176export function pullRequestListRowsAreEqual(
177 previous: EnvironmentPullRequestListEntry,
178 next: EnvironmentPullRequestListEntry,
179) {
180 return (
181 previous === next ||
182 (previous.environmentId === next.environmentId &&
183 previous.projectId === next.projectId &&
184 previous.host === next.host &&
185 previous.repository === next.repository &&
186 previous.number === next.number &&
187 previous.title === next.title &&
188 previous.url === next.url &&
189 previous.state === next.state &&
190 previous.isDraft === next.isDraft &&
191 previous.mergeability === next.mergeability &&
192 previous.baseBranch === next.baseBranch &&
193 previous.updatedAt === next.updatedAt &&
194 previous.additions === next.additions &&
195 previous.deletions === next.deletions &&
196 previous.checksState === next.checksState &&
197 previous.reviewDecision === next.reviewDecision &&
198 previous.viewerReviewRequested === next.viewerReviewRequested &&
199 previous.author?.login === next.author?.login &&
200 previous.author?.avatarUrl === next.author?.avatarUrl &&
201 previous.stack?.position === next.stack?.position &&
202 previous.stack?.size === next.stack?.size &&
203 previous.labels.length === next.labels.length &&
204 previous.labels.every(
205 (label, index) =>
206 label.name === next.labels[index]?.name && label.color === next.labels[index]?.color,
207 ))
208 );
210​
211export function mergeEnvironmentPullRequestPages(
212 sources: readonly { environmentId: EnvironmentId; pages: PullRequestListPages }[],
213) {
214 const viewers: Record<string, string> = {};
215 const localSearchHosts = new Set<string>();
216 const entries: EnvironmentPullRequestListEntry[] = [];
217 const errors = [];
218 for (const { environmentId, pages } of sources) {
219 entries.push(...pages.entries.map((entry) => ({ ...entry, environmentId })));
220 for (const [host, viewer] of Object.entries(pages.head.viewers))
221 viewers[`${environmentId} ${host}`] = viewer;
222 for (const provider of pages.head.providers)
223 if (!provider.searchesOnHost) localSearchHosts.add(`${environmentId} ${provider.host}`);
224 errors.push(...pages.head.errors.map((error) => ({ ...error, environmentId })));
225 }
226 return {
227 entries,
228 viewers,
229 localSearchHosts,
230 errors,
231 continued: sources.some(({ pages }) => pages.continued),
232 };
234​
235export function mergePullRequestListEntries(
236 previous: readonly PullRequestListEntry[],
237 incoming: readonly PullRequestListEntry[],
238) {
239 const entries = new Map(previous.map((entry) => [pullRequestListKey(entry), entry]));
240 for (const entry of incoming) {
241 const key = pullRequestListKey(entry);
242 const held = entries.get(key);
243 if (!held || (entry.observedAt ?? 0) >= (held.observedAt ?? 0)) entries.set(key, entry);
244 }
245 return [...entries.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
247​
248export interface PullRequestListPages {
249 readonly head: PullRequestListResult;
250 readonly entries: readonly PullRequestListEntry[];
251 readonly nextCursors: PullRequestListResult["nextCursors"];
252 readonly truncated: boolean;
253 readonly limit: number;
254 readonly continued: boolean;
256​
257export function pullRequestListHead(
258 previous: PullRequestListPages | null,
259 head: PullRequestListResult,
260): PullRequestListPages {
261 if (previous?.head === head) return previous;
262 return previous?.continued
263 ? { ...previous, head, entries: mergePullRequestListEntries(previous.entries, head.entries) }
264 : {
265 head,
266 entries: head.entries,
267 nextCursors: head.nextCursors,
268 truncated: head.truncated,
269 limit: PR_LIST_PAGE_SIZE,
270 continued: false,
271 };
273​
274/** The recorded pages with the current list head on top; before any answer is recorded, the head alone. */
275export function shownPullRequestListPages(
276 recorded: PullRequestListPages | null,
277 head: PullRequestListResult | null,
278): PullRequestListPages | null {
279 return head ? pullRequestListHead(recorded, head) : recorded;
281​
282export function patchPullRequestListEntry(
283 recorded: PullRequestListPages | null,
284 head: PullRequestListResult | null,
285 entry: PullRequestListEntry,
286 patch: Partial<PullRequestListEntry>,
287 observedAt: number,
288): PullRequestListPages | null {
289 const pages = shownPullRequestListPages(recorded, head);
290 return (
291 pages && {
292 ...pages,
293 entries: pages.entries.map((row) =>
294 pullRequestListKey(row) === pullRequestListKey(entry)
295 ? { ...row, ...patch, observedAt }
296 : row,
297 ),
298 }
299 );
301​
302export function pullRequestListInput(
303 controls: PullRequestListControls,
304 query: string,
305): PullRequestListInput {
306 const parsed = parsePullRequestQuery(query);
307 return {
308 state: controls.state,
309 involvement: controls.involvement,
310 filters: { ...controls.filters, ...parsed.filters },
311 limit: PR_LIST_PAGE_SIZE,
312 ...(controls.projectId ? { projectId: ProjectId.make(controls.projectId) } : {}),
313 ...(parsed.text ? { query: parsed.text } : {}),
314 };
316​
317export function nextPullRequestListInput(
318 pages: PullRequestListPages,
319 input: PullRequestListInput,
320): PullRequestListInput | null {
321 if (Object.keys(pages.nextCursors).length)
322 return { ...input, limit: PR_LIST_PAGE_SIZE, cursors: pages.nextCursors };
323 return pages.truncated && pages.limit < 500
324 ? { ...input, limit: Math.min(pages.limit + PR_LIST_PAGE_SIZE, 500) }
325 : null;
327​
328export function appendPullRequestListPage(
329 previous: PullRequestListPages,
330 page: PullRequestListResult,
331 input: PullRequestListInput,
332): PullRequestListPages {
333 return {
334 ...previous,
335 entries: mergePullRequestListEntries(previous.entries, page.entries),
336 nextCursors: page.nextCursors,
337 truncated: page.truncated,
338 limit: input.limit ?? PR_LIST_PAGE_SIZE,
339 continued: true,
340 };
342​
343/**
344 * A filter picker's rows: what is picked stays in view, then the loaded rows' values that match,
345 * then the host's answers, each once. The host matched its own way (an author's display name, say),
346 * so its answers are not narrowed again here.
347 */
348const FILTER_CHOICE_LIMIT = 100;
349​
350export function pullRequestFilterChoices(input: {
351 readonly selected: readonly string[];
352 readonly suggestions: readonly string[];
353 readonly hosted: readonly string[];
354 readonly query: string;
355}): readonly string[] {
356 const needle = input.query.trim().toLowerCase();
357 const seen = new Set<string>();
358 const rows: string[] = [];
359 const add = (value: string) => {
360 const key = value.toLowerCase();
361 if (seen.has(key)) return;
362 seen.add(key);
363 rows.push(value);
364 };
365 const loaded = input.suggestions.filter((value) => value.toLowerCase().includes(needle));
366 for (const value of input.selected) add(value);
367 // Loaded matches alone could fill the list, so the host's answers get room after the first half.
368 for (const value of loaded.slice(0, FILTER_CHOICE_LIMIT / 2)) add(value);
369 for (const value of input.hosted) add(value);
370 for (const value of loaded.slice(FILTER_CHOICE_LIMIT / 2)) add(value);
371 return rows.slice(0, FILTER_CHOICE_LIMIT);
373​
374export function pullRequestListFacets(entries: readonly PullRequestListEntry[]) {
375 return {
376 authors: [
377 ...new Set(entries.flatMap((entry) => (entry.author ? [entry.author.login] : []))),
378 ].sort((a, b) => a.localeCompare(b)),
379 labels: [...new Set(entries.flatMap((entry) => entry.labels.map((label) => label.name)))].sort(
380 (a, b) => a.localeCompare(b),
381 ),
382 labelColors: pullRequestLabelColors(entries.flatMap((entry) => entry.labels)),
383 };
385​
386/** Each label's dot color by lowercased name; the first usable color a name carries wins. */
387export function pullRequestLabelColors(
388 labels: readonly { readonly name: string; readonly color: string | null }[],
389): Readonly<Record<string, string>> {
390 const colors: Record<string, string> = {};
391 for (const label of labels) {
392 const key = label.name.toLowerCase();
393 const color = pullRequestLabelColor(label.color);
394 if (color && !(key in colors)) colors[key] = color;
395 }
396 return colors;
398​
399export function filterPullRequestList<
400 Entry extends PullRequestListEntry & { environmentId?: string },
401>(
402 entries: readonly Entry[],
403 controls: PullRequestListControls,
404 viewers: PullRequestListResult["viewers"],
405 query: string,
406 localSearchHosts: ReadonlySet<string>,
407 connectedEnvironmentIds?: ReadonlySet<string>,
408) {
409 const normalize = (value: string) => value.toLowerCase();
410 const parsed = parsePullRequestQuery(query);
411 const words = parsed.text.toLowerCase().split(/\s+/).filter(Boolean);
412 const filters = { ...controls.filters, ...parsed.filters };
413 const filtered = entries.filter((entry) => {
414 if (controls.state !== "all" && entry.state !== controls.state) return false;
415 if (controls.projectId && entry.projectId !== controls.projectId) return false;
416 if (controls.projectEnvironmentId && entry.environmentId !== controls.projectEnvironmentId)
417 return false;
418 const viewerKey = entry.environmentId ? `${entry.environmentId} ${entry.host}` : entry.host;
419 const viewer = viewers[viewerKey];
420 if (controls.involvement === "reviewing" && !entry.viewerReviewRequested) return false;
421 if (
422 controls.involvement === "authored" &&
423 (!viewer || normalize(entry.author?.login ?? "") !== normalize(viewer))
424 )
425 return false;
426 if ((filters.draft === "only" && !entry.isDraft) || (filters.draft === "hide" && entry.isDraft))
427 return false;
428 if (filters.author) {
429 const author = resolvePullRequestAuthorFilter(filters.author, viewer);
430 if (normalize(entry.author?.login ?? "") !== normalize(author)) return false;
431 }
432 if (filters.review && entry.reviewDecision && entry.reviewDecision !== filters.review)
433 return false;
434 if (filters.review === "none" && entry.reviewDecision) return false;
435 if (filters.checks && entry.checksState && entry.checksState !== filters.checks) return false;
436 const labels = new Set(entry.labels.map((label) => normalize(label.name)));
437 if (filters.labels?.some((group) => !group.some((label) => labels.has(normalize(label)))))
438 return false;
439 if (filters.excludedLabels?.some((label) => labels.has(normalize(label)))) return false;
440 if (words.length && localSearchHosts.has(viewerKey)) {
441 const text =
442 `${entry.repository} #${entry.number} ${entry.title} ${entry.author?.login ?? ""} ${entry.headBranch} ${entry.baseBranch} ${entry.labels.map((label) => label.name).join(" ")}`.toLowerCase();
443 if (!words.every((word) => text.includes(word))) return false;
444 }
445 return true;
446 });
447 const reachable = (entry: Entry) =>
448 !connectedEnvironmentIds ||
449 (entry.environmentId !== undefined && connectedEnvironmentIds.has(entry.environmentId));
450 const unique = new Map<string, Entry>();
451 for (const entry of filtered) {
452 const key = pullRequestListKey(entry);
453 const held = unique.get(key);
454 if (!held || (!reachable(held) && reachable(entry))) unique.set(key, entry);
455 }
456 return [...unique.values()].sort((a, b) =>
457 controls.sort === "created"
458 ? b.createdAt.localeCompare(a.createdAt)
459 : controls.sort === "oldest"
460 ? a.updatedAt.localeCompare(b.updatedAt)
461 : b.updatedAt.localeCompare(a.updatedAt),
462 );
464​
465export function pullRequestListReviewStatus(
466 entry: Pick<PullRequestListEntry, "reviewDecision" | "viewerReviewRequested">,
467) {
468 if (entry.reviewDecision === "changes-requested")
469 return {
470 label: "Changes",
471 accessibleLabel: "Changes requested",
472 icon: "person.fill.xmark",
473 tint: "accent-danger-foreground",
474 } as const;
475 if (entry.reviewDecision === "approved")
476 return {
477 label: "Approved",
478 accessibleLabel: "Reviews approved",
479 icon: "person.fill.checkmark",
480 tint: "accent-adaptive-emerald-600-400",
481 } as const;
482 if (entry.viewerReviewRequested)
483 return {
484 label: "Your review",
485 accessibleLabel: "Your review requested",
486 icon: "person.badge.clock",
487 tint: "accent-warning-foreground",
488 } as const;
489 if (entry.reviewDecision === "review-required")
490 return {
491 label: "Review",
492 accessibleLabel: "Review required",
493 icon: "person.badge.clock",
494 tint: "accent-icon-muted",
495 } as const;
496 return null;
498​
499export function pullRequestLabelColor(color: string | null) {
500 const hex = color?.replace(/^#/, "");
501 return hex && /^[\da-f]{6}$/i.test(hex) ? `#${hex}` : null;