import * as Schema from "effect/Schema"; import { EnvironmentId, PullRequestListEntry, PullRequestListProjectError, PullRequestListResult, resolvePullRequestAuthorFilter,} from "@t3tools/contracts";import type { PullRequestDiffStat, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, PullRequestListState,} from "@t3tools/contracts"; /** * 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>;} /** * 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;} /** * 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 (["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 — 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}`;} /** * 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<Entry extends PullRequestListEntry>( entries: ReadonlyArray<Entry>, authored: ReadonlyArray<Entry>, reviewRequested: ReadonlyArray<Entry>,): ReadonlyArray<PullRequestGroup<Entry>> { 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: Entry[] = []; 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: Entry, right: Entry) => 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 environmentId: string; 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(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions }); } return next;} /** A project id only names a project within its own environment, so the key carries both. */const diffStatKey = (row: { readonly environmentId: string; readonly projectId: string; readonly number: number;}) => `${row.environmentId} ${row.projectId} ${row.number}`; /** * Every connected environment's listing, read as one list. * * `nextCursors` is keyed by environment rather than by repository: a cursor only means anything * to the host that issued it, and two machines can hold the same repository. `viewers` is not — * a host names one account, and the same host reached from two machines is the same account. */export interface MergedPullRequestList { /** Keyed `"<environmentId> <host>"`, so one host's two accounts stay two accounts. */ readonly viewers: PullRequestViewers; readonly providers: PullRequestListResult["providers"]; readonly entries: ReadonlyArray<EnvironmentPullRequestEntry>; readonly errors: ReadonlyArray<EnvironmentPullRequestError>; readonly truncated: boolean; readonly nextCursors: Readonly<Record<string, PullRequestListCursors>>; /** * The environments with rows still on their hosts. Those with a cursor are continued from it; * the rest can only be reached by asking them for a longer page, and are named here so that * asking still happens once every other environment has run out of cursors. */ readonly truncatedEnvironments: ReadonlyArray<string>;} /** * The environments' answers folded into one. A host reached from more than one environment is one * row in the switcher, readable if any environment could read it and searched on the host only if * every one of them did — a host answering unnarrowed anywhere still needs the local pass. */export function mergePullRequestLists( answers: ReadonlyArray<readonly [EnvironmentId, PullRequestListResult]>,): MergedPullRequestList | null { if (answers.length === 0) return null; const viewers: Record<string, string> = {}; const truncatedEnvironments: string[] = []; const providers = new Map<string, PullRequestListResult["providers"][number]>(); const entries: EnvironmentPullRequestEntry[] = []; const errors: EnvironmentPullRequestError[] = []; const nextCursors: Record<string, PullRequestListCursors> = {}; let truncated = false; for (const [environmentId, answer] of answers) { for (const [host, login] of Object.entries(answer.viewers)) { viewers[`${environmentId} ${host}`] = login; } for (const provider of answer.providers) { const held = providers.get(provider.host); providers.set( provider.host, held === undefined ? provider : { ...(held.configured ? held : provider), projectCount: held.projectCount + provider.projectCount, searchesOnHost: held.searchesOnHost && provider.searchesOnHost,Lines 1–500 of 797. Load more
import * as Schema from "effect/Schema"; import { EnvironmentId, PullRequestListEntry, PullRequestListProjectError, PullRequestListResult, resolvePullRequestAuthorFilter, } from "@t3tools/contracts"; import type { PullRequestDiffStat, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, PullRequestListState, } from "@t3tools/contracts"; /** * 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>; } /** * 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; } /** * 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 (["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 — 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}`; } /** * 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<Entry extends PullRequestListEntry>( entries: ReadonlyArray<Entry>, authored: ReadonlyArray<Entry>, reviewRequested: ReadonlyArray<Entry>, ): ReadonlyArray<PullRequestGroup<Entry>> { 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: Entry[] = []; 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: Entry, right: Entry) => 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 environmentId: string; 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(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions }); } return next; } /** A project id only names a project within its own environment, so the key carries both. */ const diffStatKey = (row: { readonly environmentId: string; readonly projectId: string; readonly number: number; }) => `${row.environmentId} ${row.projectId} ${row.number}`; /** * Every connected environment's listing, read as one list. * * `nextCursors` is keyed by environment rather than by repository: a cursor only means anything * to the host that issued it, and two machines can hold the same repository. `viewers` is not — * a host names one account, and the same host reached from two machines is the same account. */ export interface MergedPullRequestList { /** Keyed `"<environmentId> <host>"`, so one host's two accounts stay two accounts. */ readonly viewers: PullRequestViewers; readonly providers: PullRequestListResult["providers"]; readonly entries: ReadonlyArray<EnvironmentPullRequestEntry>; readonly errors: ReadonlyArray<EnvironmentPullRequestError>; readonly truncated: boolean; readonly nextCursors: Readonly<Record<string, PullRequestListCursors>>; /** * The environments with rows still on their hosts. Those with a cursor are continued from it; * the rest can only be reached by asking them for a longer page, and are named here so that * asking still happens once every other environment has run out of cursors. */ readonly truncatedEnvironments: ReadonlyArray<string>; } /** * The environments' answers folded into one. A host reached from more than one environment is one * row in the switcher, readable if any environment could read it and searched on the host only if * every one of them did — a host answering unnarrowed anywhere still needs the local pass. */ export function mergePullRequestLists( answers: ReadonlyArray<readonly [EnvironmentId, PullRequestListResult]>, ): MergedPullRequestList | null { if (answers.length === 0) return null; const viewers: Record<string, string> = {}; const truncatedEnvironments: string[] = []; const providers = new Map<string, PullRequestListResult["providers"][number]>(); const entries: EnvironmentPullRequestEntry[] = []; const errors: EnvironmentPullRequestError[] = []; const nextCursors: Record<string, PullRequestListCursors> = {}; let truncated = false; for (const [environmentId, answer] of answers) { for (const [host, login] of Object.entries(answer.viewers)) { viewers[`${environmentId} ${host}`] = login; } for (const provider of answer.providers) { const held = providers.get(provider.host); providers.set( provider.host, held === undefined ? provider : { ...(held.configured ? held : provider), projectCount: held.projectCount + provider.projectCount, searchesOnHost: held.searchesOnHost && provider.searchesOnHost,