379 lines · 15.3 KB · TypeScript
RawDownload
import * as Schema from "effect/Schema"; import { PullRequestListEntry, PullRequestListResult } from "@t3tools/contracts";import type { PullRequestInvolvement, PullRequestListState } from "@t3tools/contracts"; export type PullRequestGroupKey = "reviewRequested" | "authored" | "others"; export interface PullRequestGroup {  readonly key: PullRequestGroupKey;  readonly label: string;  readonly entries: ReadonlyArray<PullRequestListEntry>;} /** The signed-in account per host, as the listing reports it. */export type PullRequestViewers = PullRequestListResult["viewers"]; const GROUP_LABELS: Record<PullRequestGroupKey, string> = {  reviewRequested: "Review requested",  authored: "Authored",  others: "Others",}; function normalize(value: string | null | undefined): string | null {  const trimmed = value?.trim().toLowerCase() ?? "";  return trimmed.length > 0 ? trimmed : null;} /** * Authorship is per host, not per provider kind: the same list can hold change requests from * GitHub, GitLab and a GitHub Enterprise install, and the account that owns one says nothing * about the others. */function isAuthoredByViewer(entry: PullRequestListEntry, viewers: PullRequestViewers): boolean {  const viewer = normalize(viewers[entry.host]);  return viewer !== null && normalize(entry.author?.login) === viewer;} /** Free-text filter over the fields a row actually shows, plus `#123` / `123`. */export function matchesPullRequestQuery(entry: PullRequestListEntry, query: string): boolean {  const normalizedQuery = query.trim().toLowerCase();  if (normalizedQuery.length === 0) return true;  return `#${entry.number} ${entry.title} ${entry.repository} ${entry.headBranch} ${entry.author?.login ?? ""}`    .toLowerCase()    .includes(normalizedQuery);} /** * The server returns the involvement superset for a state, so switching between the Reviewing * and Authored tabs never waits on the network. */export function filterPullRequestsByInvolvement(  entries: ReadonlyArray<PullRequestListEntry>,  viewers: PullRequestViewers,  involvement: PullRequestInvolvement,): ReadonlyArray<PullRequestListEntry> {  if (involvement === "reviewing") {    return entries.filter((entry) => entry.viewerReviewRequested);  }  if (involvement === "authored") {    return entries.filter((entry) => isAuthoredByViewer(entry, viewers));  }  return entries;} /** * The rows already read, kept only where the filters now being asked about would keep them. * * Every combination of filters is its own question to the hosts, and asking a new one is no * reason to blank the page: what has been read is narrowed here and stays until the answer for * the new question replaces it. So this may only ever drop rows — a merged pull request cannot * sit under "Open" for the round trip — and what it leaves is a subset of the answer rather than * the answer itself. * * Only the filters a row can be judged by from its own fields. Involvement is left out because * it needs to know who is signed in on each host, and the search text because searching is the * hosts' own answer; both are narrowed where that knowledge already lives. */export function narrowPullRequestsToFilters(  entries: ReadonlyArray<PullRequestListEntry>,  filters: {    readonly state: PullRequestListState;    readonly projectId: string | undefined;    readonly host: string | undefined;  },): ReadonlyArray<PullRequestListEntry> {  return entries.filter(    (entry) =>      (filters.state === "all" || entry.state === filters.state) &&      (filters.projectId === undefined || entry.projectId === filters.projectId) &&      (filters.host === undefined || entry.host === filters.host),  );} /** * Only relationships the list data actually carries: no "previously reviewed" bucket is * inferred, because the listing has no review history. */export function groupPullRequestsByInvolvement(  entries: ReadonlyArray<PullRequestListEntry>,  viewers: PullRequestViewers,): ReadonlyArray<PullRequestGroup> {  const buckets: Record<PullRequestGroupKey, PullRequestListEntry[]> = {    reviewRequested: [],    authored: [],    others: [],  };  for (const entry of entries) {    if (isAuthoredByViewer(entry, viewers)) {      buckets.authored.push(entry);    } else if (entry.viewerReviewRequested) {      buckets.reviewRequested.push(entry);    } else {      buckets.others.push(entry);    }  }  return (["reviewRequested", "authored", "others"] as const)    .filter((key) => buckets[key].length > 0)    .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] }));} /** Repository plus number is unique on one host, so the host makes the key unique overall. */export function pullRequestEntryKey(entry: PullRequestListEntry): string {  return `${entry.host}:${entry.repository}#${entry.number}`;} /** * The priority groups built from the hosts' own answers rather than re-partitioned from the * paginated feed. The feed is sliced by recency, so an older authored or review-requested row * can be missing from its first page and arrive with a later one; grouping the loaded pages * would then move it above rows already read. Here the partitions come whole from their own * server-filtered reads, the feed fills "Others" in its own order, and a continuation can only * append — a row it carries that a partition already holds is dropped rather than moved. */export function partitionPullRequestsWithPriority(  entries: ReadonlyArray<PullRequestListEntry>,  authored: ReadonlyArray<PullRequestListEntry>,  reviewRequested: ReadonlyArray<PullRequestListEntry>,): ReadonlyArray<PullRequestGroup> {  const authoredByKey = new Map(authored.map((entry) => [pullRequestEntryKey(entry), entry]));  // A row can be both authored and review-requested; authored wins, as the local grouping has it.  const reviewByKey = new Map(    reviewRequested.flatMap((entry) => {      const key = pullRequestEntryKey(entry);      return authoredByKey.has(key) ? [] : [[key, entry] as const];    }),  );  const others: PullRequestListEntry[] = [];  for (const entry of entries) {    const key = pullRequestEntryKey(entry);    // The feed's copy of a partitioned row is at least as fresh — it replaces in place.    if (authoredByKey.has(key)) {      authoredByKey.set(key, entry);    } else if (reviewByKey.has(key)) {      reviewByKey.set(key, entry);    } else {      others.push(entry);    }  }  const byRecency = (left: PullRequestListEntry, right: PullRequestListEntry) =>    right.updatedAt.localeCompare(left.updatedAt);  return (    [      { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) },      { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) },      { key: "others", entries: others },    ] as const  )    .filter((group) => group.entries.length > 0)    .map((group) => ({ ...group, label: GROUP_LABELS[group.key] }));} export type PullRequestDiffStats = ReadonlyMap<  string,  { readonly additions: number; readonly deletions: number }>; /** * The line counts held so far, with a new batch merged on. The stats read is keyed by the rows * on screen, so adding or removing one row is a fresh query with nothing in it yet; replacing * the map would blank every count already showing for the round trip. Merging by row identity * keeps them until their replacements arrive. */export function mergePullRequestDiffStats(  previous: PullRequestDiffStats,  stats: ReadonlyArray<{    readonly projectId: string;    readonly number: number;    readonly additions: number;    readonly deletions: number;  }>,): PullRequestDiffStats {  if (stats.length === 0) return previous;  const next = new Map(previous);  for (const stat of stats) {    next.set(`${stat.projectId} ${stat.number}`, {      additions: stat.additions,      deletions: stat.deletions,    });  }  return next;} /** One page is what the list itself starts with, and all a cold start needs to look warm. */const SNAPSHOT_MAX_ENTRIES = 99; type SnapshotStorage = Pick<Storage, "getItem" | "setItem">; const snapshotStorageKey = (environmentId: string) => `t3.pullRequests.list:${environmentId}`; /** * The priority groups' own server-filtered answers, carried with the feed. An authored pull * request older than the feed's first page lives only in these, so a snapshot without them * cold-starts into an Authored group missing exactly the rows that made it worth having. */export interface PullRequestPartitionsSnapshot {  readonly authored: ReadonlyArray<PullRequestListEntry>;  readonly reviewing: ReadonlyArray<PullRequestListEntry>;} export interface PullRequestListSnapshot {  readonly scope: string;  readonly data: PullRequestListResult;  readonly partitions?: PullRequestPartitionsSnapshot | undefined;} /** * Decoded with the contract's own schema rather than trusted from a cast: storage is writable * by anything in the origin and by any past version of this app, and one malformed row would * otherwise crash the list on every reload until the key is cleared. A snapshot from before a * schema change is rejected the same way, which is exactly the cold start it would have broken. */const decodeSnapshot = Schema.decodeUnknownOption(  Schema.Struct({    scope: Schema.String,    data: PullRequestListResult,    // Optional so a snapshot written before the partitions existed still hydrates the feed.    partitions: Schema.optional(      Schema.Struct({        authored: Schema.Array(PullRequestListEntry),        reviewing: Schema.Array(PullRequestListEntry),      }),    ),  }),); /** * The last list answered for this environment, brought back across a reload. The registry the * queries live in is recreated with the renderer, so without this a revisit cold-starts into * skeletons even though almost every row is unchanged; hydrated, the stale rows render at once * and the live read reconciles them in place by key. Errors are not carried — a failure is * never cached, and yesterday's is not this morning's. */export function readPullRequestListSnapshot(  storage: SnapshotStorage | undefined,  environmentId: string,): PullRequestListSnapshot | null {  try {    const raw = storage?.getItem(snapshotStorageKey(environmentId));    if (!raw) return null;    const decoded = decodeSnapshot(JSON.parse(raw));    return decoded._tag === "Some" ? decoded.value : null;  } catch {    return null;  }} export function writePullRequestListSnapshot(  storage: SnapshotStorage | undefined,  environmentId: string,  snapshot: PullRequestListSnapshot,): void {  try {    storage?.setItem(      snapshotStorageKey(environmentId),      JSON.stringify({        scope: snapshot.scope,        data: {          ...snapshot.data,          entries: snapshot.data.entries.slice(0, SNAPSHOT_MAX_ENTRIES),          // A failure is never cached and yesterday's is not this morning's; a cursor names a          // position in a listing the host has long since forgotten.          errors: [],          nextCursors: {},        },        ...(snapshot.partitions === undefined          ? {}          : {              partitions: {                authored: snapshot.partitions.authored.slice(0, SNAPSHOT_MAX_ENTRIES),                reviewing: snapshot.partitions.reviewing.slice(0, SNAPSHOT_MAX_ENTRIES),              },            }),      }),    );  } catch {    // Storage can be full or denied; the snapshot is a convenience, not a record.  }} /** * The project scope to actually ask for. A `projectId` in the URL outlives the environment it * came from, and one from elsewhere narrows the listing to nothing — an empty page with no * visible filter explaining it, since the switcher has no such project to show as selected. So * an id the environment does not have is dropped. * * Until `projectsKnown`, the id is kept rather than dropped: an environment that has not * reported yet is not the same as one without the project, and dropping first would show every * project's pull requests for a moment before narrowing back down. */export function resolveProjectScope<Id extends string>(  projectId: Id | undefined,  projects: ReadonlyArray<{ readonly id: string }>,  projectsKnown: boolean,): Id | undefined {  if (projectId === undefined || !projectsKnown) return projectId;  return projects.some((project) => project.id === projectId) ? projectId : undefined;} /** * How well a row answers the text that was searched for, as a number to order by. * * Every host searches more than a row shows — GitHub reads bodies and commit messages, GitLab * and Bitbucket read descriptions — so a result can be a real match with nothing on the row to * show for it. Ordering those by recency alone is what puts an apparently unrelated pull request * between two obvious ones. They are still results, so they are still shown; they are shown last, * under the rows whose own words matched. * * The scale is deliberately coarse. It sorts rows into "this is the one", "this mentions it" and * "the host says so", which is as fine a judgement as the row's own fields support. */export function scorePullRequestMatch(entry: PullRequestListEntry, query: string): number {  const needle = query.trim().toLowerCase();  if (needle.length === 0) return 0;  const number = needle.replace(/^#/u, "");  // Asking for a number is asking for one pull request, and it is the answer or it is not.  if (/^\d+$/u.test(number)) return String(entry.number) === number ? 100 : 0;   const title = entry.title.toLowerCase();  const terms = needle.split(/\s+/u).filter((term) => term.length > 0);  if (title === needle) return 90;  if (title.includes(needle)) return 80;  // Every word, in any order: "wizard welcome" is still about the welcome wizard.  if (terms.length > 1 && terms.every((term) => title.includes(term))) return 70;  if (entry.headBranch.toLowerCase().includes(needle)) return 60;  if ((entry.author?.login ?? "").toLowerCase().includes(needle)) return 50;  if (entry.repository.toLowerCase().includes(needle)) return 40;  if (terms.some((term) => title.includes(term))) return 30;  // The host matched something this row does not show — a description, a comment, a commit.  return 10;} /** * Search results in the order they answer the question, most convincing first, and by recency * among equals. Only for a search: without one, a listing is a timeline and recency is the order. */export function rankPullRequestMatches(  entries: ReadonlyArray<PullRequestListEntry>,  query: string,): ReadonlyArray<PullRequestListEntry> {  if (query.trim().length === 0) return entries;  return entries.toSorted((left, right) => {    const byScore = scorePullRequestMatch(right, query) - scorePullRequestMatch(left, query);    return byScore !== 0 ? byScore : right.updatedAt.localeCompare(left.updatedAt);  });} /** * A row with the line counts that arrived after it did. Only where the host left them out — a * listing that carried them is not second-guessed — and only where they have arrived, since a row * draws perfectly well without them in the meantime. */export function withDiffStat(  entry: PullRequestListEntry,  statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>,): PullRequestListEntry {  if (entry.additions !== 0 || entry.deletions !== 0) return entry;  const stat = statsByRow.get(`${entry.projectId} ${entry.number}`);  return stat === undefined ? entry : { ...entry, ...stat };}