| 1 | import * as Schema from "effect/Schema"; |
| 2 | |
| 3 | import { |
| 4 | EnvironmentId, |
| 5 | PullRequestListEntry, |
| 6 | PullRequestListProjectError, |
| 7 | PullRequestListResult, |
| 8 | resolvePullRequestAuthorFilter, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import type { |
| 11 | ProjectId, |
| 12 | PullRequestAction, |
| 13 | PullRequestActor, |
| 14 | PullRequestDiffStat, |
| 15 | PullRequestInvolvement, |
| 16 | PullRequestLabel, |
| 17 | PullRequestListCursors, |
| 18 | PullRequestListFilters, |
| 19 | PullRequestListState, |
| 20 | PullRequestState, |
| 21 | } from "@t3tools/contracts"; |
| 22 | |
| 23 | import { toSortableTimestamp } from "../../lib/threadSort"; |
| 24 | import type { PullRequestListSort } from "./pullRequestListPreferences"; |
| 25 | |
| 26 | /** |
| 27 | * A listed change request with the environment that read it. Nothing on a row says which machine |
| 28 | * it came from, and the page unions every connected one — so acting on a row, refreshing it, or |
| 29 | * opening its detail all need the tag the listing itself does not carry. |
| 30 | */ |
| 31 | export interface EnvironmentPullRequestEntry extends PullRequestListEntry { |
| 32 | readonly environmentId: EnvironmentId; |
| 33 | } |
| 34 | |
| 35 | export interface EnvironmentPullRequestStat extends PullRequestDiffStat { |
| 36 | readonly environmentId: EnvironmentId; |
| 37 | } |
| 38 | |
| 39 | export interface EnvironmentPullRequestError extends PullRequestListProjectError { |
| 40 | readonly environmentId: EnvironmentId; |
| 41 | } |
| 42 | |
| 43 | export type PullRequestGroupKey = "reviewRequested" | "authored" | "others"; |
| 44 | |
| 45 | export interface PullRequestGroup<Entry extends PullRequestListEntry = PullRequestListEntry> { |
| 46 | readonly key: PullRequestGroupKey; |
| 47 | readonly label: string; |
| 48 | readonly entries: ReadonlyArray<Entry>; |
| 49 | } |
| 50 | |
| 51 | export interface PullRequestAuthorFacet { |
| 52 | readonly actor: PullRequestActor; |
| 53 | readonly count: number; |
| 54 | readonly mergedCount: number; |
| 55 | } |
| 56 | |
| 57 | export interface PullRequestLabelFacet extends PullRequestLabel { |
| 58 | readonly count: number; |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than |
| 63 | * one environment: two machines can both reach github.com signed in as different people, and a |
| 64 | * single host-keyed record would let whichever answered last decide who "I" am — which is how |
| 65 | * every row of somebody's own work came to be filed under Others. |
| 66 | */ |
| 67 | export type PullRequestViewers = PullRequestListResult["viewers"]; |
| 68 | |
| 69 | /** A row plus the environment that read it, where the caller has one to give. */ |
| 70 | type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string }; |
| 71 | |
| 72 | const pullRequestViewerKey = (entry: ScopedEntry): string => |
| 73 | `${entry.environmentId ?? ""} ${entry.host}`; |
| 74 | |
| 75 | const GROUP_LABELS: Record<PullRequestGroupKey, string> = { |
| 76 | reviewRequested: "Review requested", |
| 77 | authored: "Authored", |
| 78 | others: "Others", |
| 79 | }; |
| 80 | |
| 81 | function normalize(value: string | null | undefined): string | null { |
| 82 | const trimmed = value?.trim().toLowerCase() ?? ""; |
| 83 | return trimmed.length > 0 ? trimmed : null; |
| 84 | } |
| 85 | |
| 86 | export function pullRequestLabelColor(color: string | null): string | null { |
| 87 | const hex = color?.trim().replace(/^#/, "") ?? ""; |
| 88 | return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; |
| 89 | } |
| 90 | |
| 91 | export function collectPullRequestListFacets( |
| 92 | entries: ReadonlyArray<PullRequestListEntry>, |
| 93 | state: PullRequestListState, |
| 94 | ) { |
| 95 | const authors = new Map<string, PullRequestAuthorFacet>(); |
| 96 | const labels = new Map<string, PullRequestLabelFacet>(); |
| 97 | const uniqueEntries = new Map(entries.map((entry) => [pullRequestEntryKey(entry), entry])); |
| 98 | for (const entry of uniqueEntries.values()) { |
| 99 | const inState = state === "all" || entry.state === state; |
| 100 | if (entry.author !== null) { |
| 101 | const key = normalize(entry.author.login); |
| 102 | if (key !== null) { |
| 103 | const held = authors.get(key); |
| 104 | authors.set(key, { |
| 105 | actor: held?.actor ?? entry.author, |
| 106 | count: (held?.count ?? 0) + Number(inState), |
| 107 | mergedCount: (held?.mergedCount ?? 0) + Number(entry.state === "merged"), |
| 108 | }); |
| 109 | } |
| 110 | } |
| 111 | if (!inState) continue; |
| 112 | for (const label of entry.labels) { |
| 113 | const key = normalize(label.name); |
| 114 | if (key === null) continue; |
| 115 | const held = labels.get(key); |
| 116 | labels.set(key, { |
| 117 | ...label, |
| 118 | name: held?.name ?? label.name, |
| 119 | color: held?.color ?? label.color, |
| 120 | count: (held?.count ?? 0) + 1, |
| 121 | }); |
| 122 | } |
| 123 | } |
| 124 | return { |
| 125 | authors: [...authors.values()] |
| 126 | .filter((author) => author.count > 0) |
| 127 | .toSorted( |
| 128 | (left, right) => |
| 129 | right.mergedCount - left.mergedCount || |
| 130 | right.count - left.count || |
| 131 | left.actor.login.localeCompare(right.actor.login), |
| 132 | ), |
| 133 | labels: [...labels.values()].toSorted( |
| 134 | (left, right) => right.count - left.count || left.name.localeCompare(right.name), |
| 135 | ), |
| 136 | }; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * The signed-in login for the host a row came from, or null where none was given. Shared by |
| 141 | * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed. |
| 142 | */ |
| 143 | export function pullRequestEntryViewer( |
| 144 | entry: ScopedEntry, |
| 145 | viewers: PullRequestViewers, |
| 146 | ): string | null { |
| 147 | // The environment's own answer first; a plain host key is what a single-environment listing |
| 148 | // still writes, and what the snapshot from one carries. |
| 149 | return normalize(viewers[pullRequestViewerKey(entry)] ?? viewers[entry.host]); |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Authorship is per host, not per provider kind: the same list can hold change requests from |
| 154 | * GitHub, GitLab and a GitHub Enterprise install, and the account that owns one says nothing |
| 155 | * about the others. |
| 156 | */ |
| 157 | function isAuthoredByViewer(entry: ScopedEntry, viewers: PullRequestViewers): boolean { |
| 158 | const viewer = pullRequestEntryViewer(entry, viewers); |
| 159 | return viewer !== null && normalize(entry.author?.login) === viewer; |
| 160 | } |
| 161 | |
| 162 | /** What `review:` and `status:` take, in GitHub's spelling and in the contract's. */ |
| 163 | const REVIEW_VALUES: Record<string, PullRequestListFilters["review"]> = { |
| 164 | approved: "approved", |
| 165 | changes_requested: "changes-requested", |
| 166 | "changes-requested": "changes-requested", |
| 167 | required: "review-required", |
| 168 | "review-required": "review-required", |
| 169 | none: "none", |
| 170 | }; |
| 171 | const CHECKS_VALUES: Record<string, PullRequestListFilters["checks"]> = { |
| 172 | success: "passing", |
| 173 | passing: "passing", |
| 174 | failure: "failing", |
| 175 | failing: "failing", |
| 176 | }; |
| 177 | |
| 178 | /** |
| 179 | * One token of a typed query: a run of non-space characters in which a quoted stretch counts as |
| 180 | * part of the token, so `label:"needs design"` stays whole. An unbalanced quote is dropped rather |
| 181 | * than swallowing the rest of the line. |
| 182 | */ |
| 183 | const QUERY_TOKEN = /(?:[^\s"]|"[^"]*")+/g; |
| 184 | /** The contract's own ceilings on a qualifier: this many names, each this long. */ |
| 185 | const MAX_QUALIFIER_VALUES = 10; |
| 186 | const MAX_QUALIFIER_LENGTH = 200; |
| 187 | |
| 188 | function qualifierValue(raw: string): string { |
| 189 | return raw.replaceAll('"', "").trim(); |
| 190 | } |
| 191 | |
| 192 | /** A qualifier's value as the list it may be: split on commas, each name unquoted on its own. */ |
| 193 | function splitQualifierList(raw: string): string[] { |
| 194 | // Quoting names one whole label however many commas it holds: `label:"needs,triage"` asks for |
| 195 | // the label written that way, not for either half of it. |
| 196 | if (/^\s*"[^"]*"\s*$/.test(raw)) { |
| 197 | const whole = qualifierValue(raw); |
| 198 | return whole.length === 0 ? [] : [whole]; |
| 199 | } |
| 200 | return raw |
| 201 | .split(",") |
| 202 | .map((part) => qualifierValue(part)) |
| 203 | .filter((part) => part.length > 0); |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * A typed query is written by hand, and the contract bounds what a qualifier may carry — so what |
| 208 | * is typed past those bounds is cut here rather than refused by the request that carries it. |
| 209 | */ |
| 210 | function boundedNames(names: ReadonlyArray<string>): string[] { |
| 211 | return names |
| 212 | .slice(0, MAX_QUALIFIER_VALUES) |
| 213 | .map((name) => name.slice(0, MAX_QUALIFIER_LENGTH).trim()) |
| 214 | .filter((name) => name.length > 0); |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * A typed query split into the qualifiers the hosts can act on and the text that is left. Written |
| 219 | * GitHub's way — `label:foo`, `-label:"needs design"`, `author:octocat`, `draft:true`, |
| 220 | * `review:approved`, `status:success` — because a project's labels are its own and a menu cannot |
| 221 | * list them. |
| 222 | * |
| 223 | * A key this does not know is read as a label of that whole name: repositories namespace their |
| 224 | * labels with a colon — `size:XXL`, `area:web`, `vouch:trusted` — and someone typing one means |
| 225 | * the label, not a description that happens to contain it. `-size:XXL` excludes the same way. |
| 226 | * |
| 227 | * Quoting is the way back to plain text: `"size:XXL"` is searched for as written, which is what |
| 228 | * makes a literal search for a colon still possible. A known key whose value it does not take is |
| 229 | * text too, so a search for "status:" itself is still findable. |
| 230 | */ |
| 231 | export function parsePullRequestQuery(raw: string): { |
| 232 | readonly text: string; |
| 233 | readonly filters: PullRequestListFilters; |
| 234 | } { |
| 235 | const text: string[] = []; |
| 236 | const labels: string[][] = []; |
| 237 | const excludedLabels: string[] = []; |
| 238 | let author: string | undefined; |
| 239 | let draft: PullRequestListFilters["draft"]; |
| 240 | let review: PullRequestListFilters["review"]; |
| 241 | let checks: PullRequestListFilters["checks"]; |
| 242 | for (const [token] of raw.matchAll(QUERY_TOKEN)) { |
| 243 | const qualifier = /^(-?)([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(token); |
| 244 | const value = qualifier === null ? "" : qualifierValue(qualifier[3] ?? ""); |
| 245 | const negated = qualifier?.[1] === "-"; |
| 246 | switch (value.length === 0 ? "" : (qualifier?.[2]?.toLowerCase() ?? "")) { |
| 247 | case "label": { |
| 248 | // GitHub's own OR: `label:a,b` is one qualifier satisfied by either name. Negated, the |
| 249 | // comma excludes each — a row carrying any of them goes. |
| 250 | const names = boundedNames(splitQualifierList(qualifier?.[3] ?? "")); |
| 251 | if (names.length === 0) break; |
| 252 | if (negated) excludedLabels.push(...names); |
| 253 | else labels.push(names); |
| 254 | continue; |
| 255 | } |
| 256 | case "author": |
| 257 | if (negated) break; |
| 258 | author = value.slice(0, MAX_QUALIFIER_LENGTH).trim(); |
| 259 | continue; |
| 260 | case "draft": |
| 261 | if (negated || (value.toLowerCase() !== "true" && value.toLowerCase() !== "false")) break; |
| 262 | draft = value.toLowerCase() === "true" ? "only" : "hide"; |
| 263 | continue; |
| 264 | case "review": { |
| 265 | const decision = negated ? undefined : REVIEW_VALUES[value.toLowerCase()]; |
| 266 | if (decision === undefined) break; |
| 267 | review = decision; |
| 268 | continue; |
| 269 | } |
| 270 | case "status": |
| 271 | case "checks": { |
| 272 | const state = negated ? undefined : CHECKS_VALUES[value.toLowerCase()]; |
| 273 | if (state === undefined) break; |
| 274 | checks = state; |
| 275 | continue; |
| 276 | } |
| 277 | case "": |
| 278 | break; |
| 279 | default: |
| 280 | // An unknown key, read as the namespaced label it almost always is. A pasted link is |
| 281 | // not one — `https://…` would otherwise become a label named after its own scheme. |
| 282 | if (!value.startsWith("/")) { |
| 283 | // The key names the namespace, so the bare parts of `size:S,XS` are both sizes. A part |
| 284 | // that already carries a colon names its whole label — `size:S,size:XS` is the same |
| 285 | // pair written out, and prefixing it again would ask for `size:size:XS`. |
| 286 | const names = boundedNames( |
| 287 | splitQualifierList(qualifier?.[3] ?? "").map((name) => |
| 288 | name.includes(":") ? name : `${qualifier?.[2] ?? ""}:${name}`, |
| 289 | ), |
| 290 | ); |
| 291 | if (names.length === 0) break; |
| 292 | if (negated) excludedLabels.push(...names); |
| 293 | else labels.push(names); |
| 294 | continue; |
| 295 | } |
| 296 | } |
| 297 | text.push(token); |
| 298 | } |
| 299 | return { |
| 300 | text: text.join(" "), |
| 301 | filters: { |
| 302 | ...(labels.length === 0 ? {} : { labels: labels.slice(0, MAX_QUALIFIER_VALUES) }), |
| 303 | ...(excludedLabels.length === 0 |
| 304 | ? {} |
| 305 | : { excludedLabels: excludedLabels.slice(0, MAX_QUALIFIER_VALUES) }), |
| 306 | ...(author === undefined ? {} : { author }), |
| 307 | ...(draft === undefined ? {} : { draft }), |
| 308 | ...(review === undefined ? {} : { review }), |
| 309 | ...(checks === undefined ? {} : { checks }), |
| 310 | }, |
| 311 | }; |
| 312 | } |
| 313 | |
| 314 | /** Free-text filter over the fields a row actually shows, plus `#123` / `123`. */ |
| 315 | export function matchesPullRequestQuery(entry: PullRequestListEntry, query: string): boolean { |
| 316 | const normalizedQuery = query.trim().toLowerCase(); |
| 317 | if (normalizedQuery.length === 0) return true; |
| 318 | return `#${entry.number} ${entry.title} ${entry.repository} ${entry.headBranch} ${entry.author?.login ?? ""}` |
| 319 | .toLowerCase() |
| 320 | .includes(normalizedQuery); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * The server returns the involvement superset for a state, so switching between the Reviewing |
| 325 | * and Authored tabs never waits on the network. |
| 326 | */ |
| 327 | export function filterPullRequestsByInvolvement<Entry extends ScopedEntry>( |
| 328 | entries: ReadonlyArray<Entry>, |
| 329 | viewers: PullRequestViewers, |
| 330 | involvement: PullRequestInvolvement, |
| 331 | ): ReadonlyArray<Entry> { |
| 332 | if (involvement === "reviewing") { |
| 333 | return entries.filter((entry) => entry.viewerReviewRequested); |
| 334 | } |
| 335 | if (involvement === "authored") { |
| 336 | return entries.filter((entry) => isAuthoredByViewer(entry, viewers)); |
| 337 | } |
| 338 | return entries; |
| 339 | } |
| 340 | |
| 341 | /** |
| 342 | * The rows already read, kept only where the filters now being asked about would keep them. |
| 343 | * |
| 344 | * Every combination of filters is its own question to the hosts, and asking a new one is no |
| 345 | * reason to blank the page: what has been read is narrowed here and stays until the answer for |
| 346 | * the new question replaces it. So this may only ever drop rows — a merged pull request cannot |
| 347 | * sit under "Open" for the round trip — and what it leaves is a subset of the answer rather than |
| 348 | * the answer itself. |
| 349 | * |
| 350 | * Only the filters a row can be judged by from its own fields. Involvement is left out because |
| 351 | * it needs to know who is signed in on each host, and the search text because searching is the |
| 352 | * hosts' own answer; both are narrowed where that knowledge already lives. |
| 353 | */ |
| 354 | export function narrowPullRequestsToFilters<Entry extends PullRequestListEntry>( |
| 355 | entries: ReadonlyArray<Entry>, |
| 356 | filters: { |
| 357 | readonly state: PullRequestListState; |
| 358 | readonly projectId: string | undefined; |
| 359 | readonly host: string | undefined; |
| 360 | }, |
| 361 | ): ReadonlyArray<Entry> { |
| 362 | return entries.filter( |
| 363 | (entry) => |
| 364 | (filters.state === "all" || entry.state === filters.state) && |
| 365 | (filters.projectId === undefined || entry.projectId === filters.projectId) && |
| 366 | (filters.host === undefined || entry.host === filters.host), |
| 367 | ); |
| 368 | } |
| 369 | |
| 370 | /** |
| 371 | * The further narrowings over rows that have already arrived, for the hosts that could not apply |
| 372 | * them themselves and for the moment before an answer that did lands. |
| 373 | * |
| 374 | * `checks` is absent because no listed row carries its check state: that one filter is the |
| 375 | * host's alone, and a row a host did not narrow stays rather than being guessed at. |
| 376 | * |
| 377 | * `viewer` is the row's own host's signed-in login, so `author:me` resolves against it rather |
| 378 | * than being compared as the literal name "me". Optional, and left unresolved without one. |
| 379 | */ |
| 380 | export function matchesPullRequestFilters( |
| 381 | entry: PullRequestListEntry, |
| 382 | filters: PullRequestListFilters, |
| 383 | viewer?: string | null, |
| 384 | ): boolean { |
| 385 | const labels = entry.labels.map((label) => label.name.trim().toLowerCase()); |
| 386 | const holds = (label: string) => labels.includes(label.trim().toLowerCase()); |
| 387 | return ( |
| 388 | (filters.draft === undefined || entry.isDraft === (filters.draft === "only")) && |
| 389 | (filters.review === undefined || |
| 390 | (filters.review === "none" |
| 391 | ? entry.reviewDecision === undefined |
| 392 | : entry.reviewDecision === filters.review)) && |
| 393 | (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && |
| 394 | (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && |
| 395 | (filters.author === undefined || |
| 396 | entry.author?.login.toLowerCase() === |
| 397 | resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) |
| 398 | ); |
| 399 | } |
| 400 | |
| 401 | /** |
| 402 | * Only relationships the list data actually carries: no "previously reviewed" bucket is |
| 403 | * inferred, because the listing has no review history. |
| 404 | */ |
| 405 | export function groupPullRequestsByInvolvement<Entry extends ScopedEntry>( |
| 406 | entries: ReadonlyArray<Entry>, |
| 407 | viewers: PullRequestViewers, |
| 408 | ): ReadonlyArray<PullRequestGroup<Entry>> { |
| 409 | const buckets: Record<PullRequestGroupKey, Entry[]> = { |
| 410 | reviewRequested: [], |
| 411 | authored: [], |
| 412 | others: [], |
| 413 | }; |
| 414 | for (const entry of entries) { |
| 415 | if (isAuthoredByViewer(entry, viewers)) { |
| 416 | buckets.authored.push(entry); |
| 417 | } else if (entry.viewerReviewRequested) { |
| 418 | buckets.reviewRequested.push(entry); |
| 419 | } else { |
| 420 | buckets.others.push(entry); |
| 421 | } |
| 422 | } |
| 423 | return (["authored", "reviewRequested", "others"] as const) |
| 424 | .filter((key) => buckets[key].length > 0) |
| 425 | .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); |
| 426 | } |
| 427 | |
| 428 | /** |
| 429 | * Repository plus number is unique on one host, so the host makes the key unique overall — and |
| 430 | * the environment on top of that, because two connected machines can hold the same repository and |
| 431 | * would otherwise contribute two rows under one key. |
| 432 | */ |
| 433 | export function pullRequestEntryKey(entry: ScopedEntry): string { |
| 434 | const scope = entry.environmentId === undefined ? "" : `${entry.environmentId}:`; |
| 435 | return `${scope}${entry.host}:${entry.repository}#${entry.number}`; |
| 436 | } |
| 437 | |
| 438 | export interface PullRequestStatsTarget { |
| 439 | readonly environmentId: EnvironmentId; |
| 440 | readonly input: { |
| 441 | readonly refs: ReadonlyArray<{ |
| 442 | readonly projectId: ProjectId; |
| 443 | readonly repository: string; |
| 444 | readonly number: number; |
| 445 | }>; |
| 446 | }; |
| 447 | } |
| 448 | |
| 449 | export interface PullRequestStatsBatch extends PullRequestStatsTarget { |
| 450 | readonly keys: ReadonlySet<string>; |
| 451 | } |
| 452 | |
| 453 | export type PullRequestStatsPolicy = "visible" | "eager"; |
| 454 | |
| 455 | export interface PullRequestStatsScope { |
| 456 | readonly key: string; |
| 457 | readonly policy: PullRequestStatsPolicy; |
| 458 | } |
| 459 | |
| 460 | const MAX_PULL_REQUEST_STATS_REFS = 500; |
| 461 | |
| 462 | /** Excludes rows already covered by an active batch or the received-count cache. */ |
| 463 | export function pullRequestStatsKeysToRequest( |
| 464 | entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>, |
| 465 | enteredKeys: ReadonlySet<string>, |
| 466 | batches: ReadonlyArray<PullRequestStatsBatch>, |
| 467 | statsByRow: ReadonlyMap<string, unknown>, |
| 468 | ): ReadonlySet<string> { |
| 469 | const requested = new Set(batches.flatMap((batch) => [...batch.keys])); |
| 470 | return new Set( |
| 471 | [...enteredKeys].filter((key) => { |
| 472 | const entry = entriesByKey.get(key); |
| 473 | return ( |
| 474 | entry !== undefined && |
| 475 | entry.additions === 0 && |
| 476 | entry.deletions === 0 && |
| 477 | !requested.has(key) && |
| 478 | !statsByRow.has(pullRequestDiffStatKey(entry)) |
| 479 | ); |
| 480 | }), |
| 481 | ); |
| 482 | } |
| 483 | |
| 484 | /** Groups selected rows into bounded, immutable line-count reads per environment. */ |
| 485 | export function pullRequestStatsBatches( |
| 486 | entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>, |
| 487 | keys: ReadonlySet<string>, |
| 488 | ): ReadonlyArray<PullRequestStatsBatch> { |
| 489 | const byEnvironment = new Map< |
| 490 | EnvironmentId, |
| 491 | Array<{ |
| 492 | readonly key: string; |
| 493 | readonly ref: PullRequestStatsTarget["input"]["refs"][number]; |
| 494 | }> |
| 495 | >(); |
| 496 | for (const key of keys) { |
| 497 | const entry = entriesByKey.get(key); |
| 498 | if (entry === undefined) continue; |
| 499 | const rows = byEnvironment.get(entry.environmentId) ?? []; |
| 500 | rows.push({ |
| 501 | key, |
| 502 | ref: { |
| 503 | projectId: entry.projectId, |
| 504 | repository: entry.repository, |
| 505 | number: entry.number, |
| 506 | }, |
| 507 | }); |
| 508 | byEnvironment.set(entry.environmentId, rows); |
| 509 | } |
| 510 | return [...byEnvironment].flatMap(([environmentId, rows]) => { |
| 511 | const batches: PullRequestStatsBatch[] = []; |
| 512 | for (let index = 0; index < rows.length; index += MAX_PULL_REQUEST_STATS_REFS) { |
| 513 | const batch = rows.slice(index, index + MAX_PULL_REQUEST_STATS_REFS); |
| 514 | batches.push({ |
| 515 | environmentId, |
| 516 | input: { refs: batch.map((row) => row.ref) }, |
| 517 | keys: new Set(batch.map((row) => row.key)), |
| 518 | }); |
| 519 | } |
| 520 | return batches; |
| 521 | }); |
| 522 | } |
| 523 | |
| 524 | /** |
| 525 | * Selects the next immutable stats batches. Size sorting needs every loaded row, while the other |
| 526 | * modes only need rows near the viewport. Normal reads skip active and cached rows; an explicit |
| 527 | * refresh asks for the selected rows again. |
| 528 | */ |
| 529 | export function pullRequestStatsRequestBatches({ |
| 530 | entriesByKey, |
| 531 | candidateKeys, |
| 532 | policy, |
| 533 | activeBatches, |
| 534 | statsByRow, |
| 535 | refresh = false, |
| 536 | }: { |
| 537 | readonly entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>; |
| 538 | readonly candidateKeys: ReadonlySet<string>; |
| 539 | readonly policy: PullRequestStatsPolicy; |
| 540 | readonly activeBatches: ReadonlyArray<PullRequestStatsBatch>; |
| 541 | readonly statsByRow: ReadonlyMap<string, unknown>; |
| 542 | readonly refresh?: boolean; |
| 543 | }): ReadonlyArray<PullRequestStatsBatch> { |
| 544 | const requestedKeys = policy === "eager" ? new Set(entriesByKey.keys()) : candidateKeys; |
| 545 | const keys = refresh |
| 546 | ? requestedKeys |
| 547 | : pullRequestStatsKeysToRequest(entriesByKey, requestedKeys, activeBatches, statsByRow); |
| 548 | return pullRequestStatsBatches(entriesByKey, keys); |
| 549 | } |
| 550 | |
| 551 | /** Ignores a refresh that finished after the list moved to another filter or stats policy. */ |
| 552 | export function pullRequestStatsRefreshBatches({ |
| 553 | requestedScope, |
| 554 | currentScope, |
| 555 | entriesByKey, |
| 556 | candidateKeys, |
| 557 | statsByRow, |
| 558 | }: { |
| 559 | readonly requestedScope: PullRequestStatsScope; |
| 560 | readonly currentScope: PullRequestStatsScope; |
| 561 | readonly entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>; |
| 562 | readonly candidateKeys: ReadonlySet<string>; |
| 563 | readonly statsByRow: ReadonlyMap<string, unknown>; |
| 564 | }): ReadonlyArray<PullRequestStatsBatch> | null { |
| 565 | if (requestedScope.key !== currentScope.key || requestedScope.policy !== currentScope.policy) { |
| 566 | return null; |
| 567 | } |
| 568 | return pullRequestStatsRequestBatches({ |
| 569 | entriesByKey, |
| 570 | candidateKeys, |
| 571 | policy: requestedScope.policy, |
| 572 | activeBatches: [], |
| 573 | statsByRow, |
| 574 | refresh: true, |
| 575 | }); |
| 576 | } |
| 577 | |
| 578 | /** Drops completed batches once every row in them has left the observer window. */ |
| 579 | export function retainVisiblePullRequestStatsBatches( |
| 580 | batches: ReadonlyArray<PullRequestStatsBatch>, |
| 581 | visibleKeys: ReadonlySet<string>, |
| 582 | ): ReadonlyArray<PullRequestStatsBatch> { |
| 583 | return batches.filter((batch) => { |
| 584 | for (const key of batch.keys) { |
| 585 | if (visibleKeys.has(key)) return true; |
| 586 | } |
| 587 | return false; |
| 588 | }); |
| 589 | } |
| 590 | |
| 591 | /** |
| 592 | * The priority groups built from the hosts' own answers rather than re-partitioned from the |
| 593 | * paginated feed. The feed is sliced by recency, so an older authored or review-requested row |
| 594 | * can be missing from its first page and arrive with a later one; grouping the loaded pages |
| 595 | * would then move it above rows already read. Here the partitions come whole from their own |
| 596 | * server-filtered reads, the feed fills "Others" in its own order, and a continuation can only |
| 597 | * append — a row it carries that a partition already holds is dropped rather than moved. |
| 598 | */ |
| 599 | export function partitionPullRequestsWithPriority<Entry extends PullRequestListEntry>( |
| 600 | entries: ReadonlyArray<Entry>, |
| 601 | authored: ReadonlyArray<Entry>, |
| 602 | reviewRequested: ReadonlyArray<Entry>, |
| 603 | ): ReadonlyArray<PullRequestGroup<Entry>> { |
| 604 | const authoredByKey = new Map(authored.map((entry) => [pullRequestEntryKey(entry), entry])); |
| 605 | // A row can be both authored and review-requested; authored wins, as the local grouping has it. |
| 606 | const reviewByKey = new Map( |
| 607 | reviewRequested.flatMap((entry) => { |
| 608 | const key = pullRequestEntryKey(entry); |
| 609 | return authoredByKey.has(key) ? [] : [[key, entry] as const]; |
| 610 | }), |
| 611 | ); |
| 612 | const others: Entry[] = []; |
| 613 | for (const entry of entries) { |
| 614 | const key = pullRequestEntryKey(entry); |
| 615 | // The feed's copy of a partitioned row is at least as fresh — it replaces in place. |
| 616 | if (authoredByKey.has(key)) { |
| 617 | authoredByKey.set(key, entry); |
| 618 | } else if (reviewByKey.has(key)) { |
| 619 | reviewByKey.set(key, entry); |
| 620 | } else { |
| 621 | others.push(entry); |
| 622 | } |
| 623 | } |
| 624 | const byRecency = (left: Entry, right: Entry) => right.updatedAt.localeCompare(left.updatedAt); |
| 625 | return ( |
| 626 | [ |
| 627 | { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) }, |
| 628 | { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, |
| 629 | { key: "others", entries: others }, |
| 630 | ] as const |
| 631 | ) |
| 632 | .filter((group) => group.entries.length > 0) |
| 633 | .map((group) => ({ ...group, label: GROUP_LABELS[group.key] })); |
| 634 | } |
| 635 | |
| 636 | export type PullRequestDiffStats = ReadonlyMap< |
| 637 | string, |
| 638 | { readonly additions: number; readonly deletions: number } |
| 639 | >; |
| 640 | |
| 641 | /** |
| 642 | * The line counts held so far, with a new batch merged on. The stats read is keyed by the rows |
| 643 | * on screen, so adding or removing one row is a fresh query with nothing in it yet; replacing |
| 644 | * the map would blank every count already showing for the round trip. Merging by row identity |
| 645 | * keeps them until their replacements arrive. |
| 646 | */ |
| 647 | export function mergePullRequestDiffStats( |
| 648 | previous: PullRequestDiffStats, |
| 649 | stats: ReadonlyArray<{ |
| 650 | readonly environmentId: string; |
| 651 | readonly projectId: string; |
| 652 | readonly number: number; |
| 653 | readonly additions: number; |
| 654 | readonly deletions: number; |
| 655 | }>, |
| 656 | ): PullRequestDiffStats { |
| 657 | if (stats.length === 0) return previous; |
| 658 | const next = new Map(previous); |
| 659 | for (const stat of stats) { |
| 660 | next.set(pullRequestDiffStatKey(stat), { |
| 661 | additions: stat.additions, |
| 662 | deletions: stat.deletions, |
| 663 | }); |
| 664 | } |
| 665 | return next; |
| 666 | } |
| 667 | |
| 668 | /** A project id only names a project within its own environment, so the key carries both. */ |
| 669 | export const pullRequestDiffStatKey = (row: { |
| 670 | readonly environmentId: string; |
| 671 | readonly projectId: string; |
| 672 | readonly number: number; |
| 673 | }) => `${row.environmentId} ${row.projectId} ${row.number}`; |
| 674 | |
| 675 | /** |
| 676 | * Every connected environment's listing, read as one list. |
| 677 | * |
| 678 | * `nextCursors` is keyed by environment rather than by repository: a cursor only means anything |
| 679 | * to the host that issued it, and two machines can hold the same repository. `viewers` is not — |
| 680 | * a host names one account, and the same host reached from two machines is the same account. |
| 681 | */ |
| 682 | export interface MergedPullRequestList { |
| 683 | /** Keyed `"<environmentId> <host>"`, so one host's two accounts stay two accounts. */ |
| 684 | readonly viewers: PullRequestViewers; |
| 685 | readonly providers: PullRequestListResult["providers"]; |
| 686 | readonly entries: ReadonlyArray<EnvironmentPullRequestEntry>; |
| 687 | readonly errors: ReadonlyArray<EnvironmentPullRequestError>; |
| 688 | readonly truncated: boolean; |
| 689 | readonly nextCursors: Readonly<Record<string, PullRequestListCursors>>; |
| 690 | /** |
| 691 | * The environments with rows still on their hosts. Those with a cursor are continued from it; |
| 692 | * the rest can only be reached by asking them for a longer page, and are named here so that |
| 693 | * asking still happens once every other environment has run out of cursors. |
| 694 | */ |
| 695 | readonly truncatedEnvironments: ReadonlyArray<string>; |
| 696 | } |
| 697 | |
| 698 | /** |
| 699 | * The environments' answers folded into one. A host reached from more than one environment is one |
| 700 | * row in the switcher, readable if any environment could read it and searched on the host only if |
| 701 | * every one of them did — a host answering unnarrowed anywhere still needs the local pass. |
| 702 | */ |
| 703 | export function mergePullRequestLists( |
| 704 | answers: ReadonlyArray<readonly [EnvironmentId, PullRequestListResult]>, |
| 705 | ): MergedPullRequestList | null { |
| 706 | if (answers.length === 0) return null; |
| 707 | const viewers: Record<string, string> = {}; |
| 708 | const truncatedEnvironments: string[] = []; |
| 709 | const providers = new Map<string, PullRequestListResult["providers"][number]>(); |
| 710 | const entries: EnvironmentPullRequestEntry[] = []; |
| 711 | const errors: EnvironmentPullRequestError[] = []; |
| 712 | const nextCursors: Record<string, PullRequestListCursors> = {}; |
| 713 | let truncated = false; |
| 714 | for (const [environmentId, answer] of answers) { |
| 715 | for (const [host, login] of Object.entries(answer.viewers)) { |
| 716 | viewers[`${environmentId} ${host}`] = login; |
| 717 | } |
| 718 | for (const provider of answer.providers) { |
| 719 | const held = providers.get(provider.host); |
| 720 | providers.set( |
| 721 | provider.host, |
| 722 | held === undefined |
| 723 | ? provider |
| 724 | : { |
| 725 | ...(held.configured ? held : provider), |
| 726 | projectCount: held.projectCount + provider.projectCount, |
| 727 | searchesOnHost: held.searchesOnHost && provider.searchesOnHost, |
| 728 | configured: held.configured || provider.configured, |
| 729 | }, |
| 730 | ); |
| 731 | } |
| 732 | entries.push(...answer.entries.map((entry) => ({ ...entry, environmentId }))); |
| 733 | errors.push(...answer.errors.map((error) => ({ ...error, environmentId }))); |
| 734 | truncated ||= answer.truncated; |
| 735 | if (answer.truncated) truncatedEnvironments.push(environmentId); |
| 736 | if (Object.keys(answer.nextCursors).length > 0) { |
| 737 | nextCursors[environmentId] = answer.nextCursors; |
| 738 | } |
| 739 | } |
| 740 | return { |
| 741 | viewers, |
| 742 | providers: [...providers.values()], |
| 743 | entries: entries.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), |
| 744 | errors, |
| 745 | truncated, |
| 746 | nextCursors, |
| 747 | truncatedEnvironments, |
| 748 | }; |
| 749 | } |
| 750 | |
| 751 | /** One page is what the list itself starts with, and all a cold start needs to look warm. */ |
| 752 | const SNAPSHOT_MAX_ENTRIES = 99; |
| 753 | |
| 754 | type SnapshotStorage = Pick<Storage, "getItem" | "setItem">; |
| 755 | |
| 756 | /** |
| 757 | * Keyed by the whole set of environments the list was read from: connecting or dropping one |
| 758 | * changes which rows belong on the page, and a snapshot taken from a different set would |
| 759 | * hydrate rows no longer being read. |
| 760 | */ |
| 761 | export const pullRequestEnvironmentSetKey = (environmentIds: ReadonlyArray<string>): string => |
| 762 | [...environmentIds].sort((left, right) => left.localeCompare(right)).join(","); |
| 763 | |
| 764 | const snapshotStorageKey = (environmentSetKey: string) => |
| 765 | `t3.pullRequests.list:${environmentSetKey}`; |
| 766 | |
| 767 | /** |
| 768 | * The priority groups' own server-filtered answers, carried with the feed. An authored pull |
| 769 | * request older than the feed's first page lives only in these, so a snapshot without them |
| 770 | * cold-starts into an Authored group missing exactly the rows that made it worth having. |
| 771 | */ |
| 772 | export interface PullRequestPartitionsSnapshot { |
| 773 | readonly authored: ReadonlyArray<EnvironmentPullRequestEntry>; |
| 774 | readonly reviewing: ReadonlyArray<EnvironmentPullRequestEntry>; |
| 775 | } |
| 776 | |
| 777 | export interface PullRequestListSnapshot { |
| 778 | readonly scope: string; |
| 779 | readonly data: MergedPullRequestList; |
| 780 | readonly partitions?: PullRequestPartitionsSnapshot | undefined; |
| 781 | } |
| 782 | |
| 783 | /** |
| 784 | * Decoded with the contract's own schema rather than trusted from a cast: storage is writable |
| 785 | * by anything in the origin and by any past version of this app, and one malformed row would |
| 786 | * otherwise crash the list on every reload until the key is cleared. A snapshot from before a |
| 787 | * schema change is rejected the same way, which is exactly the cold start it would have broken. |
| 788 | */ |
| 789 | const EnvironmentPullRequestEntrySchema = Schema.Struct({ |
| 790 | ...PullRequestListEntry.fields, |
| 791 | environmentId: EnvironmentId, |
| 792 | }); |
| 793 | |
| 794 | const EnvironmentPullRequestErrorSchema = Schema.Struct({ |
| 795 | ...PullRequestListProjectError.fields, |
| 796 | environmentId: EnvironmentId, |
| 797 | }); |
| 798 | |
| 799 | const decodeSnapshot = Schema.decodeUnknownOption( |
| 800 | Schema.Struct({ |
| 801 | scope: Schema.String, |
| 802 | data: Schema.Struct({ |
| 803 | ...PullRequestListResult.fields, |
| 804 | entries: Schema.Array(EnvironmentPullRequestEntrySchema), |
| 805 | errors: Schema.Array(EnvironmentPullRequestErrorSchema), |
| 806 | // Per environment here, unlike the wire shape, which is per repository within one. |
| 807 | nextCursors: Schema.Record(Schema.String, PullRequestListResult.fields.nextCursors), |
| 808 | truncatedEnvironments: Schema.Array(Schema.String), |
| 809 | }), |
| 810 | // Optional so a snapshot written before the partitions existed still hydrates the feed. |
| 811 | partitions: Schema.optional( |
| 812 | Schema.Struct({ |
| 813 | authored: Schema.Array(EnvironmentPullRequestEntrySchema), |
| 814 | reviewing: Schema.Array(EnvironmentPullRequestEntrySchema), |
| 815 | }), |
| 816 | ), |
| 817 | }), |
| 818 | ); |
| 819 | |
| 820 | /** |
| 821 | * The last list answered for this environment, brought back across a reload. The registry the |
| 822 | * queries live in is recreated with the renderer, so without this a revisit cold-starts into |
| 823 | * skeletons even though almost every row is unchanged; hydrated, the stale rows render at once |
| 824 | * and the live read reconciles them in place by key. Errors are not carried — a failure is |
| 825 | * never cached, and yesterday's is not this morning's. |
| 826 | */ |
| 827 | export function readPullRequestListSnapshot( |
| 828 | storage: SnapshotStorage | undefined, |
| 829 | environmentSetKey: string, |
| 830 | ): PullRequestListSnapshot | null { |
| 831 | try { |
| 832 | const raw = storage?.getItem(snapshotStorageKey(environmentSetKey)); |
| 833 | if (!raw) return null; |
| 834 | const decoded = decodeSnapshot(JSON.parse(raw)); |
| 835 | return decoded._tag === "Some" ? decoded.value : null; |
| 836 | } catch { |
| 837 | return null; |
| 838 | } |
| 839 | } |
| 840 | |
| 841 | export function writePullRequestListSnapshot( |
| 842 | storage: SnapshotStorage | undefined, |
| 843 | environmentSetKey: string, |
| 844 | snapshot: PullRequestListSnapshot, |
| 845 | ): void { |
| 846 | try { |
| 847 | storage?.setItem( |
| 848 | snapshotStorageKey(environmentSetKey), |
| 849 | JSON.stringify({ |
| 850 | scope: snapshot.scope, |
| 851 | data: { |
| 852 | ...snapshot.data, |
| 853 | entries: snapshot.data.entries.slice(0, SNAPSHOT_MAX_ENTRIES), |
| 854 | // A failure is never cached and yesterday's is not this morning's; a cursor names a |
| 855 | // position in a listing the host has long since forgotten. |
| 856 | errors: [], |
| 857 | nextCursors: {}, |
| 858 | // Where a listing stopped is as stale as the cursor that named it. |
| 859 | truncatedEnvironments: [], |
| 860 | }, |
| 861 | ...(snapshot.partitions === undefined |
| 862 | ? {} |
| 863 | : { |
| 864 | partitions: { |
| 865 | authored: snapshot.partitions.authored.slice(0, SNAPSHOT_MAX_ENTRIES), |
| 866 | reviewing: snapshot.partitions.reviewing.slice(0, SNAPSHOT_MAX_ENTRIES), |
| 867 | }, |
| 868 | }), |
| 869 | }), |
| 870 | ); |
| 871 | } catch { |
| 872 | // Storage can be full or denied; the snapshot is a convenience, not a record. |
| 873 | } |
| 874 | } |
| 875 | |
| 876 | /** |
| 877 | * The project scope to actually ask for. A `projectId` in the URL outlives the environment it |
| 878 | * came from, and one from elsewhere narrows the listing to nothing — an empty page with no |
| 879 | * visible filter explaining it, since the switcher has no such project to show as selected. So |
| 880 | * an id the environment does not have is dropped. |
| 881 | * |
| 882 | * Until `projectsKnown`, the id is kept rather than dropped: an environment that has not |
| 883 | * reported yet is not the same as one without the project, and dropping first would show every |
| 884 | * project's pull requests for a moment before narrowing back down. |
| 885 | */ |
| 886 | export function resolveProjectScope<Id extends string>( |
| 887 | projectId: Id | undefined, |
| 888 | projects: ReadonlyArray<{ readonly id: string }>, |
| 889 | projectsKnown: boolean, |
| 890 | ): Id | undefined { |
| 891 | if (projectId === undefined || !projectsKnown) return projectId; |
| 892 | return projects.some((project) => project.id === projectId) ? projectId : undefined; |
| 893 | } |
| 894 | |
| 895 | /** |
| 896 | * The project an id names, on the server that owns it. A project id is only unique within its own |
| 897 | * environment, so an id alone can name two projects on two connected machines: with a server in |
| 898 | * hand the answer is exact, and without one it is only given where a single environment has that |
| 899 | * id — narrowing to the wrong machine reads an empty list nobody asked for. |
| 900 | */ |
| 901 | export function findScopedProject< |
| 902 | Project extends { readonly id: string; readonly environmentId: string }, |
| 903 | >( |
| 904 | projects: ReadonlyArray<Project>, |
| 905 | environmentId: string | null | undefined, |
| 906 | projectId: string | undefined, |
| 907 | ): Project | undefined { |
| 908 | if (projectId === undefined) return undefined; |
| 909 | const matches = projects.filter((project) => project.id === projectId); |
| 910 | if (environmentId === null || environmentId === undefined) { |
| 911 | return matches.length === 1 ? matches[0] : undefined; |
| 912 | } |
| 913 | return matches.find((project) => project.environmentId === environmentId); |
| 914 | } |
| 915 | |
| 916 | /** |
| 917 | * Which environments a listing should ask, once the project scope is known. Scoping to a project |
| 918 | * scopes to the server that owns it, saving every other server a read that could only answer with |
| 919 | * nothing. Where an id is ambiguous — two servers holding the same project id, with no server |
| 920 | * named — only the servers that actually hold that id are asked: a server without it may still |
| 921 | * hold an unrelated project of its own under the same string, and asking it would return that |
| 922 | * project's rows rather than an honest empty answer. |
| 923 | */ |
| 924 | export function resolveQueryEnvironmentIds<Id extends string>( |
| 925 | environmentIds: ReadonlyArray<Id>, |
| 926 | projects: ReadonlyArray<{ readonly id: string; readonly environmentId: Id }>, |
| 927 | scopedProject: { readonly environmentId: Id } | undefined, |
| 928 | scopedProjectId: string | undefined, |
| 929 | projectsKnown: boolean, |
| 930 | ): ReadonlyArray<Id> { |
| 931 | if (scopedProject !== undefined) { |
| 932 | return environmentIds.filter((environmentId) => environmentId === scopedProject.environmentId); |
| 933 | } |
| 934 | // Before the servers have said what they hold, an id nothing matches is an id nothing has been |
| 935 | // asked about yet — reading none of them would show an empty page for a project that is there. |
| 936 | if (scopedProjectId === undefined || !projectsKnown) return environmentIds; |
| 937 | const holders = new Set( |
| 938 | projects |
| 939 | .filter((project) => project.id === scopedProjectId) |
| 940 | .map((project) => project.environmentId), |
| 941 | ); |
| 942 | return environmentIds.filter((environmentId) => holders.has(environmentId)); |
| 943 | } |
| 944 | |
| 945 | /** |
| 946 | * The server a saved selection names, before its project is looked up against it. Still |
| 947 | * connecting is not the same as gone: a server the workspace already knows about is kept named |
| 948 | * here even while it has nothing to answer with yet, so the lookup that follows finds nothing |
| 949 | * under it rather than falling through to a different server's project of the same id. Only a |
| 950 | * name outside the workspace's whole catalog falls back to the page's own scope, which is what |
| 951 | * lets a link to a server since removed still resolve by project id alone. |
| 952 | */ |
| 953 | export function resolveSelectedEnvironmentId<Id extends string>( |
| 954 | namedEnvironmentId: Id | undefined, |
| 955 | knownEnvironmentIds: ReadonlySet<Id>, |
| 956 | fallbackEnvironmentId: Id | null, |
| 957 | ): Id | null { |
| 958 | if (namedEnvironmentId === undefined) return fallbackEnvironmentId; |
| 959 | return knownEnvironmentIds.has(namedEnvironmentId) ? namedEnvironmentId : fallbackEnvironmentId; |
| 960 | } |
| 961 | |
| 962 | /** |
| 963 | * How well a row answers the text that was searched for, as a number to order by. |
| 964 | * |
| 965 | * Every host searches more than a row shows — GitHub reads bodies and commit messages, GitLab |
| 966 | * and Bitbucket read descriptions — so a result can be a real match with nothing on the row to |
| 967 | * show for it. Ordering those by recency alone is what puts an apparently unrelated pull request |
| 968 | * between two obvious ones. They are still results, so they are still shown; they are shown last, |
| 969 | * under the rows whose own words matched. |
| 970 | * |
| 971 | * The scale is deliberately coarse. It sorts rows into "this is the one", "this mentions it" and |
| 972 | * "the host says so", which is as fine a judgement as the row's own fields support. |
| 973 | */ |
| 974 | export function scorePullRequestMatch(entry: PullRequestListEntry, query: string): number { |
| 975 | const needle = query.trim().toLowerCase(); |
| 976 | if (needle.length === 0) return 0; |
| 977 | const number = needle.replace(/^#/u, ""); |
| 978 | // Asking for a number is asking for one pull request, and it is the answer or it is not. |
| 979 | if (/^\d+$/u.test(number)) return String(entry.number) === number ? 100 : 0; |
| 980 | |
| 981 | const title = entry.title.toLowerCase(); |
| 982 | const terms = needle.split(/\s+/u).filter((term) => term.length > 0); |
| 983 | if (title === needle) return 90; |
| 984 | if (title.includes(needle)) return 80; |
| 985 | // Every word, in any order: "wizard welcome" is still about the welcome wizard. |
| 986 | if (terms.length > 1 && terms.every((term) => title.includes(term))) return 70; |
| 987 | if (entry.headBranch.toLowerCase().includes(needle)) return 60; |
| 988 | if ((entry.author?.login ?? "").toLowerCase().includes(needle)) return 50; |
| 989 | if (entry.repository.toLowerCase().includes(needle)) return 40; |
| 990 | if (terms.some((term) => title.includes(term))) return 30; |
| 991 | // The host matched something this row does not show — a description, a comment, a commit. |
| 992 | return 10; |
| 993 | } |
| 994 | |
| 995 | /** |
| 996 | * Search results in the order they answer the question, most convincing first, and by recency |
| 997 | * among equals. Without a search, preserve the host order for the caller's browse-time ranking. |
| 998 | */ |
| 999 | export function rankPullRequestMatches<Entry extends PullRequestListEntry>( |
| 1000 | entries: ReadonlyArray<Entry>, |
| 1001 | query: string, |
| 1002 | ): ReadonlyArray<Entry> { |
| 1003 | if (query.trim().length === 0) return entries; |
| 1004 | return entries.toSorted((left, right) => { |
| 1005 | const byScore = scorePullRequestMatch(right, query) - scorePullRequestMatch(left, query); |
| 1006 | return byScore !== 0 ? byScore : right.updatedAt.localeCompare(left.updatedAt); |
| 1007 | }); |
| 1008 | } |
| 1009 | |
| 1010 | /** |
| 1011 | * The default review queue: work that is green and approved, then green work still waiting on a |
| 1012 | * verdict, then everything else still open. Drafts stay in that third tier because their author |
| 1013 | * has not made them mergeable yet. Finished work follows open work when all states are visible. A |
| 1014 | * known conflict is never ready, whatever its checks, review or state say, so it stays at the |
| 1015 | * bottom. Within each tier, smaller measured diffs come first, then unknown sizes. Recency |
| 1016 | * breaks ties between equally sized diffs. |
| 1017 | */ |
| 1018 | export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEntry>( |
| 1019 | entries: ReadonlyArray<Entry>, |
| 1020 | hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, |
| 1021 | ): ReadonlyArray<Entry> { |
| 1022 | const tier = (entry: Entry) => { |
| 1023 | if (entry.mergeability === "conflicting") return 4; |
| 1024 | if (entry.state !== "open") return 3; |
| 1025 | if (entry.isDraft) return 2; |
| 1026 | if (entry.checksState === "passing" && entry.reviewDecision === "approved") return 0; |
| 1027 | if (entry.checksState === "passing") return 1; |
| 1028 | return 2; |
| 1029 | }; |
| 1030 | return entries.toSorted((left, right) => { |
| 1031 | const byTier = tier(left) - tier(right); |
| 1032 | if (byTier !== 0) return byTier; |
| 1033 | const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); |
| 1034 | const sized = left.additions + left.deletions - (right.additions + right.deletions); |
| 1035 | return measured || sized || right.updatedAt.localeCompare(left.updatedAt); |
| 1036 | }); |
| 1037 | } |
| 1038 | |
| 1039 | function rankByTierThenRecency<Entry extends PullRequestListEntry>( |
| 1040 | entries: ReadonlyArray<Entry>, |
| 1041 | tier: (entry: Entry) => number, |
| 1042 | ): ReadonlyArray<Entry> { |
| 1043 | const timestamp = (entry: Entry) => toSortableTimestamp(entry.updatedAt); |
| 1044 | return entries.toSorted((left, right) => { |
| 1045 | const byTier = tier(left) - tier(right); |
| 1046 | if (byTier !== 0) return byTier; |
| 1047 | const leftUpdated = timestamp(left); |
| 1048 | const rightUpdated = timestamp(right); |
| 1049 | const measured = Number(leftUpdated === null) - Number(rightUpdated === null); |
| 1050 | if (measured !== 0) return measured; |
| 1051 | if (leftUpdated === null || rightUpdated === null) return 0; |
| 1052 | return rightUpdated - leftUpdated; |
| 1053 | }); |
| 1054 | } |
| 1055 | |
| 1056 | export function rankPullRequestsBlockedOnAuthor<Entry extends PullRequestListEntry>( |
| 1057 | entries: ReadonlyArray<Entry>, |
| 1058 | ): ReadonlyArray<Entry> { |
| 1059 | return rankByTierThenRecency(entries, (entry) => { |
| 1060 | if (entry.state !== "open") return 6; |
| 1061 | if (entry.mergeability === "conflicting") return 0; |
| 1062 | if (entry.reviewDecision === "changes-requested") return 1; |
| 1063 | if (entry.checksState === "failing") return 2; |
| 1064 | if (entry.isDraft) return 3; |
| 1065 | if (entry.checksState === "passing" && entry.reviewDecision === "approved") return 5; |
| 1066 | return 4; |
| 1067 | }); |
| 1068 | } |
| 1069 | |
| 1070 | export function rankPullRequestsBlockedOnReviewer<Entry extends PullRequestListEntry>( |
| 1071 | entries: ReadonlyArray<Entry>, |
| 1072 | ): ReadonlyArray<Entry> { |
| 1073 | return rankByTierThenRecency(entries, (entry) => (entry.state === "open" ? 0 : 1)); |
| 1074 | } |
| 1075 | |
| 1076 | /** Keeps authored work first while applying the selected ordering inside every involvement group. */ |
| 1077 | export function sortPullRequestGroups<Entry extends PullRequestListEntry>( |
| 1078 | groups: ReadonlyArray<PullRequestGroup<Entry>>, |
| 1079 | sort: PullRequestListSort, |
| 1080 | searchText: string, |
| 1081 | hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, |
| 1082 | involvement: PullRequestInvolvement = "all", |
| 1083 | ): ReadonlyArray<PullRequestGroup<Entry>> { |
| 1084 | const sortWithinGroups = (rank: (entries: ReadonlyArray<Entry>) => ReadonlyArray<Entry>) => |
| 1085 | groups.map((group) => ({ ...group, entries: rank(group.entries) })); |
| 1086 | |
| 1087 | if (sort === "ready") { |
| 1088 | return searchText.trim().length === 0 |
| 1089 | ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) |
| 1090 | : groups; |
| 1091 | } |
| 1092 | if (sort === "blocked") { |
| 1093 | if (searchText.trim().length > 0) return groups; |
| 1094 | const role = (key: PullRequestGroupKey) => |
| 1095 | key === "others" ? involvement : key === "authored" ? "authored" : "reviewing"; |
| 1096 | return groups.map((group) => { |
| 1097 | const groupRole = role(group.key); |
| 1098 | if (groupRole === "all") return group; |
| 1099 | const rank = |
| 1100 | groupRole === "authored" |
| 1101 | ? rankPullRequestsBlockedOnAuthor |
| 1102 | : rankPullRequestsBlockedOnReviewer; |
| 1103 | return { ...group, entries: rank(group.entries) }; |
| 1104 | }); |
| 1105 | } |
| 1106 | if (sort === "updated") return groups; |
| 1107 | |
| 1108 | const timestamp = (entry: Entry) => |
| 1109 | toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; |
| 1110 | return sortWithinGroups((entries) => |
| 1111 | entries.toSorted((left, right) => { |
| 1112 | if (sort === "newest" || sort === "oldest") { |
| 1113 | const leftCreated = toSortableTimestamp(left.createdAt); |
| 1114 | const rightCreated = toSortableTimestamp(right.createdAt); |
| 1115 | const measured = Number(rightCreated !== null) - Number(leftCreated !== null); |
| 1116 | const dated = (leftCreated ?? 0) - (rightCreated ?? 0); |
| 1117 | return ( |
| 1118 | measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) |
| 1119 | ); |
| 1120 | } |
| 1121 | const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); |
| 1122 | const sized = left.additions + left.deletions - (right.additions + right.deletions); |
| 1123 | return ( |
| 1124 | measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) |
| 1125 | ); |
| 1126 | }), |
| 1127 | ); |
| 1128 | } |
| 1129 | |
| 1130 | /** |
| 1131 | * A row with the line counts that arrived after it did. Only where the host left them out — a |
| 1132 | * listing that carried them is not second-guessed — and only where they have arrived, since a row |
| 1133 | * draws perfectly well without them in the meantime. |
| 1134 | */ |
| 1135 | export function withDiffStat< |
| 1136 | Entry extends PullRequestListEntry & { readonly environmentId: string }, |
| 1137 | >( |
| 1138 | entry: Entry, |
| 1139 | statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>, |
| 1140 | ): Entry { |
| 1141 | if (entry.additions !== 0 || entry.deletions !== 0) return entry; |
| 1142 | const stat = statsByRow.get(pullRequestDiffStatKey(entry)); |
| 1143 | return stat === undefined ? entry : { ...entry, ...stat }; |
| 1144 | } |
| 1145 | |
| 1146 | /** |
| 1147 | * What a row should say the moment an action is sent, before any host has answered. The host |
| 1148 | * is the record and a later read replaces this, but the reader pressed the button and should |
| 1149 | * see the row answer at once: a closed pull request leaves an "open" list on the click, not |
| 1150 | * after the reads that follow. |
| 1151 | */ |
| 1152 | export interface PullRequestListOverride { |
| 1153 | readonly state: PullRequestState; |
| 1154 | readonly isDraft?: boolean; |
| 1155 | readonly updatedAt: string; |
| 1156 | /** Which action wrote it, so a failure takes back its own note and not a later one's. */ |
| 1157 | readonly token: number; |
| 1158 | /** When it was written, in the reader's clock. */ |
| 1159 | readonly at: number; |
| 1160 | } |
| 1161 | |
| 1162 | export function pullRequestOverrideAfterAction( |
| 1163 | entry: Pick<PullRequestListEntry, "state" | "isDraft">, |
| 1164 | action: PullRequestAction, |
| 1165 | now: Date, |
| 1166 | token: number, |
| 1167 | ): PullRequestListOverride | null { |
| 1168 | const stamp = { updatedAt: now.toISOString(), token, at: now.getTime() }; |
| 1169 | switch (action) { |
| 1170 | case "close": |
| 1171 | return { state: "closed", ...stamp }; |
| 1172 | case "reopen": |
| 1173 | return { state: "open", ...stamp }; |
| 1174 | case "merge": |
| 1175 | return { state: "merged", ...stamp }; |
| 1176 | case "draft": |
| 1177 | return { state: entry.state, isDraft: true, ...stamp }; |
| 1178 | case "ready": |
| 1179 | return { state: entry.state, isDraft: false, ...stamp }; |
| 1180 | default: |
| 1181 | return null; |
| 1182 | } |
| 1183 | } |
| 1184 | |
| 1185 | /** The rows with their pending answers written over them, and the ones the list's state filter no longer holds dropped. */ |
| 1186 | export function applyPullRequestOverrides<Entry extends PullRequestListEntry>( |
| 1187 | entries: ReadonlyArray<Entry>, |
| 1188 | overrides: ReadonlyMap<string, PullRequestListOverride>, |
| 1189 | keyOf: (entry: Entry) => string, |
| 1190 | state: PullRequestListState, |
| 1191 | ): ReadonlyArray<Entry> { |
| 1192 | if (overrides.size === 0 && state === "all") return entries; |
| 1193 | const out: Entry[] = []; |
| 1194 | for (const entry of entries) { |
| 1195 | const override = overrides.get(keyOf(entry)); |
| 1196 | if (state !== "all" && (override?.state ?? entry.state) !== state) continue; |
| 1197 | out.push(override === undefined ? entry : { ...entry, ...override }); |
| 1198 | } |
| 1199 | return overrides.size === 0 && out.length === entries.length ? entries : out; |
| 1200 | } |
| 1201 | |
| 1202 | /** |
| 1203 | * A fresh answer with the rows it did not change handed back as the objects already held, so a |
| 1204 | * memoized row whose data is the same does not render again. Every refresh otherwise rebuilds |
| 1205 | * every entry, and a hundred rows repaint for the one that moved. |
| 1206 | */ |
| 1207 | export function reusePullRequestEntries<Entry extends PullRequestListEntry>( |
| 1208 | previous: ReadonlyArray<Entry>, |
| 1209 | next: ReadonlyArray<Entry>, |
| 1210 | keyOf: (entry: Entry) => string, |
| 1211 | ): ReadonlyArray<Entry> { |
| 1212 | if (previous.length === 0) return next; |
| 1213 | const held = new Map(previous.map((entry) => [keyOf(entry), entry])); |
| 1214 | let reused = 0; |
| 1215 | const out = next.map((entry) => { |
| 1216 | const before = held.get(keyOf(entry)); |
| 1217 | if (before !== undefined && JSON.stringify(before) === JSON.stringify(entry)) { |
| 1218 | reused += 1; |
| 1219 | return before; |
| 1220 | } |
| 1221 | return entry; |
| 1222 | }); |
| 1223 | return reused === next.length && |
| 1224 | previous.length === next.length && |
| 1225 | previous.every((entry, index) => keyOf(entry) === keyOf(next[index]!)) |
| 1226 | ? previous |
| 1227 | : out; |
| 1228 | } |
| 1229 | |
| 1230 | /** How long a read that disagrees is taken for a stale one rather than for news. */ |
| 1231 | const PULL_REQUEST_OVERRIDE_TRUST_MS = 60_000; |
| 1232 | |
| 1233 | /** |
| 1234 | * The overrides an answer has confirmed, dropped; the rest kept. A read that started before |
| 1235 | * the action can land after it and still say the old thing, so an override is not cleared |
| 1236 | * because an answer arrived but because the answer agrees: the row is there in the state the |
| 1237 | * override said. A row that is absent says nothing — the authored and reviewing groups are read |
| 1238 | * apart from the feed, and a page is only a page — so absence never confirms. A row present in |
| 1239 | * another state is taken for a stale read for a minute, and for the host's news after that, |
| 1240 | * which is how a pull request reopened elsewhere comes back. |
| 1241 | */ |
| 1242 | export function settlePullRequestOverrides<Entry extends PullRequestListEntry>( |
| 1243 | overrides: ReadonlyMap<string, PullRequestListOverride>, |
| 1244 | answered: ReadonlyArray<Entry>, |
| 1245 | keyOf: (entry: Entry) => string, |
| 1246 | now: number, |
| 1247 | ): ReadonlyMap<string, PullRequestListOverride> { |
| 1248 | if (overrides.size === 0) return overrides; |
| 1249 | const byKey = new Map(answered.map((entry) => [keyOf(entry), entry])); |
| 1250 | const kept = new Map<string, PullRequestListOverride>(); |
| 1251 | for (const [key, override] of overrides) { |
| 1252 | const row = byKey.get(key); |
| 1253 | if (row === undefined) { |
| 1254 | kept.set(key, override); |
| 1255 | continue; |
| 1256 | } |
| 1257 | const agrees = |
| 1258 | row.state === override.state && |
| 1259 | (override.isDraft === undefined || row.isDraft === override.isDraft); |
| 1260 | const outranked = now - override.at > PULL_REQUEST_OVERRIDE_TRUST_MS; |
| 1261 | if (!agrees && !outranked) kept.set(key, override); |
| 1262 | } |
| 1263 | return kept.size === overrides.size ? overrides : kept; |
| 1264 | } |
| 1265 | |
| 1266 | /** The row actions speed mode offers, in the order they are drawn. */ |
| 1267 | export type PullRequestQuickAction = "close" | "merge" | "ready" | "reopen"; |
| 1268 | |
| 1269 | /** |
| 1270 | * Which actions each host can take on a listed row, before any row's detail is read. Hosts are |
| 1271 | * matched by the host summary; a server that does not report actions per host only ever offered |
| 1272 | * these on GitHub, so that is what it keeps getting. |
| 1273 | */ |
| 1274 | export function pullRequestHostActions( |
| 1275 | providers: ReadonlyArray<PullRequestListResult["providers"][number]>, |
| 1276 | ): ( |
| 1277 | entry: Pick<EnvironmentPullRequestEntry, "host" | "provider">, |
| 1278 | ) => ReadonlySet<PullRequestAction> { |
| 1279 | const byHost = new Map( |
| 1280 | providers.flatMap((provider) => |
| 1281 | provider.actions === undefined |
| 1282 | ? [] |
| 1283 | : [[provider.host.toLowerCase(), new Set(provider.actions)] as const], |
| 1284 | ), |
| 1285 | ); |
| 1286 | const legacy = new Set<PullRequestAction>(["close", "merge", "ready", "reopen"]); |
| 1287 | const none = new Set<PullRequestAction>(); |
| 1288 | return (entry) => |
| 1289 | byHost.get(entry.host.toLowerCase()) ?? (entry.provider === "github" ? legacy : none); |
| 1290 | } |
| 1291 | |
| 1292 | /** The quick actions a row offers: what its state allows, narrowed to what its host can do. */ |
| 1293 | export function pullRequestQuickActions( |
| 1294 | entry: Pick<EnvironmentPullRequestEntry, "state" | "isDraft">, |
| 1295 | hostActions: ReadonlySet<PullRequestAction>, |
| 1296 | ): ReadonlyArray<PullRequestQuickAction> { |
| 1297 | const forState: ReadonlyArray<PullRequestQuickAction> = |
| 1298 | entry.state === "merged" |
| 1299 | ? [] |
| 1300 | : entry.state === "closed" |
| 1301 | ? ["reopen"] |
| 1302 | : entry.isDraft |
| 1303 | ? ["close", "ready"] |
| 1304 | : ["close", "merge"]; |
| 1305 | return forState.filter((action) => hostActions.has(action)); |
| 1306 | } |