| 1 | import type { |
| 2 | EnvironmentId, |
| 3 | PullRequestListEntry, |
| 4 | PullRequestListInput, |
| 5 | PullRequestListResult, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import type { PullRequestListControls } from "../../persistence/mobile-preferences"; |
| 8 | |
| 9 | export const PR_LIST_PAGE_SIZE = 30; |
| 10 | export type { PullRequestListControls } from "../../persistence/mobile-preferences"; |
| 11 | export 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". */ |
| 19 | export 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 | |
| 33 | export function pullRequestListKey( |
| 34 | item: Pick<PullRequestListEntry, "host" | "repository" | "number">, |
| 35 | ) { |
| 36 | return `${item.host.toLowerCase()}:${item.repository.toLowerCase()}:${item.number}`; |
| 37 | } |
| 38 | |
| 39 | export type EnvironmentPullRequestListEntry = PullRequestListEntry & { |
| 40 | readonly environmentId: EnvironmentId; |
| 41 | }; |
| 42 | |
| 43 | export const scopedPullRequestListKey = (entry: EnvironmentPullRequestListEntry) => |
| 44 | `${entry.environmentId}:${pullRequestListKey(entry)}`; |
| 45 | |
| 46 | /** Provider refreshes and diff-count batches recreate entries; unchanged rows can stay mounted. */ |
| 47 | export 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. */ |
| 81 | export 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 | |
| 105 | export 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 | |
| 118 | export 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 | |
| 127 | export 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 | |
| 144 | export 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 | |
| 155 | export 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 | |
| 170 | export 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 | |
| 181 | export 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 | |
| 249 | export 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 | |
| 283 | export function pullRequestLabelColor(color: string | null) { |
| 284 | const hex = color?.replace(/^#/, ""); |
| 285 | return hex && /^[\da-f]{6}$/i.test(hex) ? `#${hex}` : null; |
| 286 | } |