diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 2af8383bebcd..1c55213e7b38 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -694,6 +694,8 @@ export function PullRequestsPage() { heldPullRequestsBySurface, } = pullRequestGroups({ known: ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []), + // The baseline was read without typed qualifiers, and rows cannot be checked for `checks:`. + baseline: typedParsed.filters.checks === undefined ? baselineQuery.data?.entries : undefined, providers: baselineQuery.data?.providers ?? listData?.providers ?? [], viewers, involvement: search.involvement, diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 123111ff6a35..fd52cfe7ede4 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -38,6 +38,7 @@ import { reusePullRequestEntries, settlePullRequestOverrides, } from "./pullRequestList.logic"; +import { pullRequestGroups } from "./pullRequestListGroups.logic"; import { readPullRequestListPreferences, writePullRequestListPreferences, @@ -177,6 +178,43 @@ describe("ranking what a search found", () => { rankPullRequestMatches([mentioned, titled], "welcome wizard").map((entry) => entry.number), ).toEqual([1, 2]); }); + + it("keeps loaded partial-word matches the host's whole-word search missed, after its answer", () => { + const bilal = { login: "bilal", name: null, avatarUrl: null }; + const hosted = row({ number: 1, title: "Notifications match the body text", author: bilal }); + const toast = row({ number: 2, title: "Threads show a completed toast", author: bilal }); + const toaster = row({ number: 3, title: "Toaster fixtures cover retries", author: bilal }); + const theirs = row({ number: 4, title: "Toasted sidebar" }); + const unrelated = row({ number: 5, title: "Cursor no longer adds attribution", author: bilal }); + + const { entries } = pullRequestGroups({ + known: [hosted, toast], + baseline: [theirs, unrelated, toaster, toast], + providers: [ + { + host: "github.com", + kind: SourceControlProviderKind.make("github"), + searchesOnHost: true, + projectCount: 1, + configured: true, + detail: null, + }, + ], + viewers: VIEWERS, + involvement: "all", + localFilters: { author: "bilal" }, + hasLocalFilters: true, + text: "toa", + answeredLocally: true, + baselineEmpty: false, + held: undefined, + authored: undefined, + reviewing: undefined, + partitionsWanted: false, + }); + + expect(entries.map((entry) => entry.number)).toEqual([1, 2, 3]); + }); }); describe("default merge-readiness ranking", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts index 9a3bda738a28..31f02978c918 100644 --- a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts @@ -15,6 +15,7 @@ import { partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, + rankPullRequestMatches, type EnvironmentPullRequestEntry, type PullRequestPartitionsSnapshot, } from "./pullRequestList.logic"; @@ -25,6 +26,7 @@ export const pullRequestListEntryId = (target: Parameters; + /** The text-free rows already loaded, or undefined when the typed filters cannot vet them. */ + baseline: ReadonlyArray | undefined; providers: PullRequestListResult["providers"]; viewers: PullRequestListResult["viewers"]; involvement: PullRequestInvolvement; @@ -56,22 +60,33 @@ export function pullRequestGroups({ providers.flatMap((provider) => (provider.searchesOnHost ? [provider.host] : [])), ); - const entries = (() => { - const involvementEntries = filterPullRequestsByInvolvement(known, viewers, involvement); - // Host text searches cover fields absent from rows, so do not narrow their matches locally. - // Filter text locally while results are pending or when the host has no text search. - const narrowedEntries = hasLocalFilters + const narrowRows = (rows: ReadonlyArray) => { + const involvementEntries = filterPullRequestsByInvolvement(rows, viewers, involvement); + return hasLocalFilters ? involvementEntries.filter((entry) => matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)), ) : involvementEntries; + }; + + const entries = (() => { + const narrowedEntries = narrowRows(known); if (text.length === 0) { return narrowedEntries.toSorted(comparePullRequestsByRecency); } - return narrowedEntries.filter( + // Host text searches cover fields absent from rows, so do not narrow their matches locally. + // Filter text locally while results are pending or when the host has no text search. + const matched = narrowedEntries.filter( (entry) => (answeredLocally && searchingHosts.has(entry.host)) || matchesPullRequestQuery(entry, text), ); + // Host search matches whole words, so it can miss loaded rows a partial word matches here. + // Those follow the host's ranked answer rather than reshuffling it. + const listed = new Set(matched.map(pullRequestEntryKey)); + const missed = narrowRows(baseline ?? []).filter( + (entry) => !listed.has(pullRequestEntryKey(entry)) && matchesPullRequestQuery(entry, text), + ); + return missed.length === 0 ? matched : [...matched, ...rankPullRequestMatches(missed, text)]; })(); const listedPullRequestTabStatuses: Record = Object.fromEntries(