1090 lines · 43.3 KB · TypeScript
RawDownload
import * as Schema from "effect/Schema"; import {  EnvironmentId,  PullRequestListEntry,  PullRequestListProjectError,  PullRequestListResult,  resolvePullRequestAuthorFilter,} from "@t3tools/contracts";import type {  ProjectId,  PullRequestActor,  PullRequestDiffStat,  PullRequestInvolvement,  PullRequestLabel,  PullRequestListCursors,  PullRequestListFilters,  PullRequestListState,} from "@t3tools/contracts"; import { toSortableTimestamp } from "../../lib/threadSort";import type { PullRequestListSort } from "./pullRequestListPreferences"; /** * A listed change request with the environment that read it. Nothing on a row says which machine * it came from, and the page unions every connected one — so acting on a row, refreshing it, or * opening its detail all need the tag the listing itself does not carry. */export interface EnvironmentPullRequestEntry extends PullRequestListEntry {  readonly environmentId: EnvironmentId;} export interface EnvironmentPullRequestStat extends PullRequestDiffStat {  readonly environmentId: EnvironmentId;} export interface EnvironmentPullRequestError extends PullRequestListProjectError {  readonly environmentId: EnvironmentId;} export type PullRequestGroupKey = "reviewRequested" | "authored" | "others"; export interface PullRequestGroup<Entry extends PullRequestListEntry = PullRequestListEntry> {  readonly key: PullRequestGroupKey;  readonly label: string;  readonly entries: ReadonlyArray<Entry>;} export interface PullRequestAuthorFacet {  readonly actor: PullRequestActor;  readonly count: number;  readonly mergedCount: number;} export interface PullRequestLabelFacet extends PullRequestLabel {  readonly count: number;} /** * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than * one environment: two machines can both reach github.com signed in as different people, and a * single host-keyed record would let whichever answered last decide who "I" am — which is how * every row of somebody's own work came to be filed under Others. */export type PullRequestViewers = PullRequestListResult["viewers"]; /** A row plus the environment that read it, where the caller has one to give. */type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string }; export const pullRequestViewerKey = (entry: ScopedEntry): string =>  `${entry.environmentId ?? ""} ${entry.host}`; 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;} export function pullRequestLabelColor(color: string | null): string | null {  const hex = color?.trim().replace(/^#/, "") ?? "";  return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;} export function collectPullRequestListFacets(  entries: ReadonlyArray<PullRequestListEntry>,  state: PullRequestListState,) {  const authors = new Map<string, PullRequestAuthorFacet>();  const labels = new Map<string, PullRequestLabelFacet>();  const uniqueEntries = new Map(entries.map((entry) => [pullRequestEntryKey(entry), entry]));  for (const entry of uniqueEntries.values()) {    const inState = state === "all" || entry.state === state;    if (entry.author !== null) {      const key = normalize(entry.author.login);      if (key !== null) {        const held = authors.get(key);        authors.set(key, {          actor: held?.actor ?? entry.author,          count: (held?.count ?? 0) + Number(inState),          mergedCount: (held?.mergedCount ?? 0) + Number(entry.state === "merged"),        });      }    }    if (!inState) continue;    for (const label of entry.labels) {      const key = normalize(label.name);      if (key === null) continue;      const held = labels.get(key);      labels.set(key, {        ...label,        name: held?.name ?? label.name,        color: held?.color ?? label.color,        count: (held?.count ?? 0) + 1,      });    }  }  return {    authors: [...authors.values()]      .filter((author) => author.count > 0)      .toSorted(        (left, right) =>          right.mergedCount - left.mergedCount ||          right.count - left.count ||          left.actor.login.localeCompare(right.actor.login),      ),    labels: [...labels.values()].toSorted(      (left, right) => right.count - left.count || left.name.localeCompare(right.name),    ),  };} /** * The signed-in login for the host a row came from, or null where none was given. Shared by * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed. */export function pullRequestEntryViewer(  entry: ScopedEntry,  viewers: PullRequestViewers,): string | null {  // The environment's own answer first; a plain host key is what a single-environment listing  // still writes, and what the snapshot from one carries.  return normalize(viewers[pullRequestViewerKey(entry)] ?? viewers[entry.host]);} /** * 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: ScopedEntry, viewers: PullRequestViewers): boolean {  const viewer = pullRequestEntryViewer(entry, viewers);  return viewer !== null && normalize(entry.author?.login) === viewer;} /** What `review:` and `status:` take, in GitHub's spelling and in the contract's. */const REVIEW_VALUES: Record<string, PullRequestListFilters["review"]> = {  approved: "approved",  changes_requested: "changes-requested",  "changes-requested": "changes-requested",  required: "review-required",  "review-required": "review-required",  none: "none",};const CHECKS_VALUES: Record<string, PullRequestListFilters["checks"]> = {  success: "passing",  passing: "passing",  failure: "failing",  failing: "failing",}; /** * One token of a typed query: a run of non-space characters in which a quoted stretch counts as * part of the token, so `label:"needs design"` stays whole. An unbalanced quote is dropped rather * than swallowing the rest of the line. */const QUERY_TOKEN = /(?:[^\s"]|"[^"]*")+/g;/** The contract's own ceilings on a qualifier: this many names, each this long. */const MAX_QUALIFIER_VALUES = 10;const MAX_QUALIFIER_LENGTH = 200; function qualifierValue(raw: string): string {  return raw.replaceAll('"', "").trim();} /** A qualifier's value as the list it may be: split on commas, each name unquoted on its own. */function splitQualifierList(raw: string): string[] {  // Quoting names one whole label however many commas it holds: `label:"needs,triage"` asks for  // the label written that way, not for either half of it.  if (/^\s*"[^"]*"\s*$/.test(raw)) {    const whole = qualifierValue(raw);    return whole.length === 0 ? [] : [whole];  }  return raw    .split(",")    .map((part) => qualifierValue(part))    .filter((part) => part.length > 0);} /** * A typed query is written by hand, and the contract bounds what a qualifier may carry — so what * is typed past those bounds is cut here rather than refused by the request that carries it. */function boundedNames(names: ReadonlyArray<string>): string[] {  return names    .slice(0, MAX_QUALIFIER_VALUES)    .map((name) => name.slice(0, MAX_QUALIFIER_LENGTH).trim())    .filter((name) => name.length > 0);} /** * A typed query split into the qualifiers the hosts can act on and the text that is left. Written * GitHub's way — `label:foo`, `-label:"needs design"`, `author:octocat`, `draft:true`, * `review:approved`, `status:success` — because a project's labels are its own and a menu cannot * list them. * * A key this does not know is read as a label of that whole name: repositories namespace their * labels with a colon — `size:XXL`, `area:web`, `vouch:trusted` — and someone typing one means * the label, not a description that happens to contain it. `-size:XXL` excludes the same way. * * Quoting is the way back to plain text: `"size:XXL"` is searched for as written, which is what * makes a literal search for a colon still possible. A known key whose value it does not take is * text too, so a search for "status:" itself is still findable. */export function parsePullRequestQuery(raw: string): {  readonly text: string;  readonly filters: PullRequestListFilters;} {  const text: string[] = [];  const labels: string[][] = [];  const excludedLabels: string[] = [];  let author: string | undefined;  let draft: PullRequestListFilters["draft"];  let review: PullRequestListFilters["review"];  let checks: PullRequestListFilters["checks"];  for (const [token] of raw.matchAll(QUERY_TOKEN)) {    const qualifier = /^(-?)([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(token);    const value = qualifier === null ? "" : qualifierValue(qualifier[3] ?? "");    const negated = qualifier?.[1] === "-";    switch (value.length === 0 ? "" : (qualifier?.[2]?.toLowerCase() ?? "")) {      case "label": {        // GitHub's own OR: `label:a,b` is one qualifier satisfied by either name. Negated, the        // comma excludes each — a row carrying any of them goes.        const names = boundedNames(splitQualifierList(qualifier?.[3] ?? ""));        if (names.length === 0) break;        if (negated) excludedLabels.push(...names);        else labels.push(names);        continue;      }      case "author":        if (negated) break;        author = value.slice(0, MAX_QUALIFIER_LENGTH).trim();        continue;      case "draft":        if (negated || (value.toLowerCase() !== "true" && value.toLowerCase() !== "false")) break;        draft = value.toLowerCase() === "true" ? "only" : "hide";        continue;      case "review": {        const decision = negated ? undefined : REVIEW_VALUES[value.toLowerCase()];        if (decision === undefined) break;        review = decision;        continue;      }      case "status":      case "checks": {        const state = negated ? undefined : CHECKS_VALUES[value.toLowerCase()];        if (state === undefined) break;        checks = state;        continue;      }      case "":        break;      default:        // An unknown key, read as the namespaced label it almost always is. A pasted link is        // not one — `https://…` would otherwise become a label named after its own scheme.        if (!value.startsWith("/")) {          // The key names the namespace, so the bare parts of `size:S,XS` are both sizes. A part          // that already carries a colon names its whole label — `size:S,size:XS` is the same          // pair written out, and prefixing it again would ask for `size:size:XS`.          const names = boundedNames(            splitQualifierList(qualifier?.[3] ?? "").map((name) =>              name.includes(":") ? name : `${qualifier?.[2] ?? ""}:${name}`,            ),          );          if (names.length === 0) break;          if (negated) excludedLabels.push(...names);          else labels.push(names);          continue;        }    }    text.push(token);  }  return {    text: text.join(" "),    filters: {      ...(labels.length === 0 ? {} : { labels: labels.slice(0, MAX_QUALIFIER_VALUES) }),      ...(excludedLabels.length === 0        ? {}        : { excludedLabels: excludedLabels.slice(0, MAX_QUALIFIER_VALUES) }),      ...(author === undefined ? {} : { author }),      ...(draft === undefined ? {} : { draft }),      ...(review === undefined ? {} : { review }),      ...(checks === undefined ? {} : { checks }),    },  };} /** 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<Entry extends ScopedEntry>(  entries: ReadonlyArray<Entry>,  viewers: PullRequestViewers,  involvement: PullRequestInvolvement,): ReadonlyArray<Entry> {  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<Entry extends PullRequestListEntry>(  entries: ReadonlyArray<Entry>,  filters: {    readonly state: PullRequestListState;    readonly projectId: string | undefined;    readonly host: string | undefined;  },): ReadonlyArray<Entry> {  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),  );} /** * The further narrowings over rows that have already arrived, for the hosts that could not apply * them themselves and for the moment before an answer that did lands. * * `checks` is absent because no listed row carries its check state: that one filter is the * host's alone, and a row a host did not narrow stays rather than being guessed at. * * `viewer` is the row's own host's signed-in login, so `author:me` resolves against it rather * than being compared as the literal name "me". Optional, and left unresolved without one. */export function matchesPullRequestFilters(  entry: PullRequestListEntry,  filters: PullRequestListFilters,  viewer?: string | null,): boolean {  const labels = entry.labels.map((label) => label.name.trim().toLowerCase());  const holds = (label: string) => labels.includes(label.trim().toLowerCase());  return (    (filters.draft === undefined || entry.isDraft === (filters.draft === "only")) &&    (filters.review === undefined ||      (filters.review === "none"        ? entry.reviewDecision === undefined        : entry.reviewDecision === filters.review)) &&    (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) &&    (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) &&    (filters.author === undefined ||      entry.author?.login.toLowerCase() ===        resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase())  );} /** * Only relationships the list data actually carries: no "previously reviewed" bucket is * inferred, because the listing has no review history. */export function groupPullRequestsByInvolvement<Entry extends ScopedEntry>(  entries: ReadonlyArray<Entry>,  viewers: PullRequestViewers,): ReadonlyArray<PullRequestGroup<Entry>> {  const buckets: Record<PullRequestGroupKey, Entry[]> = {    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 (["authored", "reviewRequested", "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 — and * the environment on top of that, because two connected machines can hold the same repository and * would otherwise contribute two rows under one key. */export function pullRequestEntryKey(entry: ScopedEntry): string {  const scope = entry.environmentId === undefined ? "" : `${entry.environmentId}:`;  return `${scope}${entry.host}:${entry.repository}#${entry.number}`;} export interface PullRequestStatsTarget {  readonly environmentId: EnvironmentId;  readonly input: {    readonly refs: ReadonlyArray<{      readonly projectId: ProjectId;      readonly repository: string;      readonly number: number;    }>;  };} export interface PullRequestStatsBatch extends PullRequestStatsTarget {  readonly keys: ReadonlySet<string>;} export type PullRequestStatsPolicy = "visible" | "eager"; export interface PullRequestStatsScope {  readonly key: string;  readonly policy: PullRequestStatsPolicy;} const MAX_PULL_REQUEST_STATS_REFS = 500; /** Excludes rows already covered by an active batch or the received-count cache. */export function pullRequestStatsKeysToRequest(  entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>,  enteredKeys: ReadonlySet<string>,  batches: ReadonlyArray<PullRequestStatsBatch>,  statsByRow: ReadonlyMap<string, unknown>,): ReadonlySet<string> {  const requested = new Set(batches.flatMap((batch) => [...batch.keys]));  return new Set(    [...enteredKeys].filter((key) => {      const entry = entriesByKey.get(key);      return (        entry !== undefined &&        entry.additions === 0 &&        entry.deletions === 0 &&        !requested.has(key) &&        !statsByRow.has(pullRequestDiffStatKey(entry))      );    }),  );} /** Groups selected rows into bounded, immutable line-count reads per environment. */export function pullRequestStatsBatches(  entriesByKey: ReadonlyMap<string, EnvironmentPullRequestEntry>,  keys: ReadonlySet<string>,): ReadonlyArray<PullRequestStatsBatch> {  const byEnvironment = new Map<    EnvironmentId,    Array<{      readonly key: string;      readonly ref: PullRequestStatsTarget["input"]["refs"][number];    }>  >();  for (const key of keys) {    const entry = entriesByKey.get(key);    if (entry === undefined) continue;    const rows = byEnvironment.get(entry.environmentId) ?? [];    rows.push({      key,      ref: {

Lines 1–500 of 1090.