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. Load more
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: {