1import * as Schema from "effect/Schema";
2
3import {
4 EnvironmentId,
5 PullRequestListEntry,
6 PullRequestListProjectError,
7 PullRequestListResult,
8 resolvePullRequestAuthorFilter,
9} from "@t3tools/contracts";
10import 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
23import { toSortableTimestamp } from "../../lib/threadSort";
24import 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 */
31export interface EnvironmentPullRequestEntry extends PullRequestListEntry {
32 readonly environmentId: EnvironmentId;
33}
34
35export interface EnvironmentPullRequestStat extends PullRequestDiffStat {
36 readonly environmentId: EnvironmentId;
37}
38
39export interface EnvironmentPullRequestError extends PullRequestListProjectError {
40 readonly environmentId: EnvironmentId;
41}
42
43export type PullRequestGroupKey = "reviewRequested" | "authored" | "others";
44
45export interface PullRequestGroup<Entry extends PullRequestListEntry = PullRequestListEntry> {
46 readonly key: PullRequestGroupKey;
47 readonly label: string;
48 readonly entries: ReadonlyArray<Entry>;
49}
50
51export interface PullRequestAuthorFacet {
52 readonly actor: PullRequestActor;
53 readonly count: number;
54 readonly mergedCount: number;
55}
56
57export 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 */
67export type PullRequestViewers = PullRequestListResult["viewers"];
68
69/** A row plus the environment that read it, where the caller has one to give. */
70type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string };
71
72const pullRequestViewerKey = (entry: ScopedEntry): string =>
73 `${entry.environmentId ?? ""} ${entry.host}`;
74
75const GROUP_LABELS: Record<PullRequestGroupKey, string> = {
76 reviewRequested: "Review requested",
77 authored: "Authored",
78 others: "Others",
79};
80
81function normalize(value: string | null | undefined): string | null {
82 const trimmed = value?.trim().toLowerCase() ?? "";
83 return trimmed.length > 0 ? trimmed : null;
84}
85
86export 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
91export 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 */
143export 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 */
157function 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. */
163const 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};
171const 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 */
183const QUERY_TOKEN = /(?:[^\s"]|"[^"]*")+/g;
184/** The contract's own ceilings on a qualifier: this many names, each this long. */
185const MAX_QUALIFIER_VALUES = 10;
186const MAX_QUALIFIER_LENGTH = 200;
187
188function 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. */
193function 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 */
210function 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 */
231export 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`. */
315export 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 */
327export 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 */
354export 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 */
380export 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 */
405export 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 */
433export function pullRequestEntryKey(entry: ScopedEntry): string {
434 const scope = entry.environmentId === undefined ? "" : `${entry.environmentId}:`;
435 return `${scope}${entry.host}:${entry.repository}#${entry.number}`;
436}
437
438export 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
449export interface PullRequestStatsBatch extends PullRequestStatsTarget {
450 readonly keys: ReadonlySet<string>;
451}
452
453export type PullRequestStatsPolicy = "visible" | "eager";
454
455export interface PullRequestStatsScope {
456 readonly key: string;
457 readonly policy: PullRequestStatsPolicy;
458}
459
460const MAX_PULL_REQUEST_STATS_REFS = 500;
461
462/** Excludes rows already covered by an active batch or the received-count cache. */
463export 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. */
485export 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 */
529export 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. */
552export 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. */
579export 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 */
599export 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
636export 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 */
647export 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. */
669export 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 */
682export 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 */
703export 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. */
752const SNAPSHOT_MAX_ENTRIES = 99;
753
754type 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 */
761export const pullRequestEnvironmentSetKey = (environmentIds: ReadonlyArray<string>): string =>
762 [...environmentIds].sort((left, right) => left.localeCompare(right)).join(",");
763
764const 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 */
772export interface PullRequestPartitionsSnapshot {
773 readonly authored: ReadonlyArray<EnvironmentPullRequestEntry>;
774 readonly reviewing: ReadonlyArray<EnvironmentPullRequestEntry>;
775}
776
777export 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 */
789const EnvironmentPullRequestEntrySchema = Schema.Struct({
790 ...PullRequestListEntry.fields,
791 environmentId: EnvironmentId,
792});
793
794const EnvironmentPullRequestErrorSchema = Schema.Struct({
795 ...PullRequestListProjectError.fields,
796 environmentId: EnvironmentId,
797});
798
799const 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 */
827export 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
841export 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 */
886export 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 */
901export 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 */
924export 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 */
953export 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 */
974export 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 */
999export 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 */
1018export 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
1039function 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
1056export 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
1070export 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. */
1077export 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 */
1135export 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 */
1152export 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
1162export 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. */
1186export 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 */
1207export 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. */
1231const 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 */
1242export 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. */
1267export 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 */
1274export 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. */
1293export 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}