diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index a90022ad72c9a157655c977559f065d77b16a17c..9154e38026d2d49e895d8d47ff03721f6f46c3b0 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -21,6 +21,8 @@ import { readPullRequestListSnapshot, writePullRequestListSnapshot, rankPullRequestMatches, + rankPullRequestsBlockedOnAuthor, + rankPullRequestsBlockedOnReviewer, rankPullRequestsByMergeReadiness, scorePullRequestMatch, sortPullRequestGroups, @@ -873,6 +875,108 @@ describe("default merge-readiness ranking", () => { }); }); +describe("blocked-on-me ranking", () => { + it("ranks authored work by how surely it is the author's to unblock, newest first within a tier", () => { + const conflict = entry({ + number: 1, + mergeability: "conflicting", + checksState: "failing", + updatedAt: "2026-08-01T00:00:00Z", + }); + const olderFailing = entry({ + number: 2, + checksState: "failing", + updatedAt: "2026-08-01T00:00:00Z", + }); + const changesRequested = entry({ + number: 3, + reviewDecision: "changes-requested", + checksState: "failing", + updatedAt: "2026-08-02T00:00:00Z", + }); + const approved = entry({ number: 4, checksState: "passing", reviewDecision: "approved" }); + const draft = entry({ number: 5, isDraft: true, checksState: "passing" }); + const waiting = entry({ number: 6 }); + const merged = entry({ number: 7, state: "merged", updatedAt: "2026-08-01T00:00:00Z" }); + const newerClosed = entry({ number: 8, state: "closed", updatedAt: "2026-08-03T00:00:00Z" }); + const newerFailing = entry({ + number: 9, + checksState: "failing", + updatedAt: "2026-08-03T00:00:00Z", + }); + + expect( + rankPullRequestsBlockedOnAuthor([ + waiting, + merged, + olderFailing, + draft, + approved, + changesRequested, + newerClosed, + conflict, + newerFailing, + ]).map((row) => row.number), + ).toEqual([1, 3, 9, 2, 5, 6, 4, 8, 7]); + }); + + it("ranks reviewer work with open rows first, newest first", () => { + const olderOpen = entry({ number: 1, updatedAt: "2026-08-01T00:00:00Z" }); + const newerOpen = entry({ number: 2, updatedAt: "2026-08-02T00:00:00Z" }); + const olderFinished = entry({ number: 3, state: "merged", updatedAt: "2026-08-01T00:00:00Z" }); + const newerFinished = entry({ number: 4, state: "closed", updatedAt: "2026-08-03T00:00:00Z" }); + + expect( + rankPullRequestsBlockedOnReviewer([newerFinished, newerOpen, olderFinished, olderOpen]).map( + (row) => row.number, + ), + ).toEqual([2, 1, 4, 3]); + }); + + it("ranks blocked groups according to their involvement", () => { + const authored = [ + entry({ number: 1, isDraft: true }), + entry({ number: 2, checksState: "failing" }), + ]; + const reviewing = [ + entry({ number: 3, updatedAt: "2026-08-02T00:00:00Z" }), + entry({ number: 4, updatedAt: "2026-08-01T00:00:00Z" }), + ]; + const others = [ + entry({ number: 5, updatedAt: "2026-08-02T00:00:00Z" }), + entry({ number: 6, isDraft: true }), + ]; + const groups = [ + { key: "authored", label: "Authored", entries: authored }, + { key: "reviewRequested", label: "Review requested", entries: reviewing }, + { key: "others", label: "Others", entries: others }, + ] as const; + + expect( + sortPullRequestGroups(groups, "blocked", "", undefined, "all").map((group) => + group.entries.map((row) => row.number), + ), + ).toEqual([ + [2, 1], + [3, 4], + [5, 6], + ]); + expect( + sortPullRequestGroups([groups[2]], "blocked", "", undefined, "authored")[0]!.entries.map( + (row) => row.number, + ), + ).toEqual([6, 5]); + expect( + sortPullRequestGroups([groups[2]], "blocked", "", undefined, "reviewing")[0]!.entries.map( + (row) => row.number, + ), + ).toEqual([5, 6]); + expect(sortPullRequestGroups(groups, "blocked", "needle", undefined, "authored")).toEqual( + groups, + ); + }); +}); + describe("line counts that arrive after the rows", () => { const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index c2fdde3d011f968bf0ac3fbfd874fc551530a426..f001e33829c523d5ab68a4fe83206335722ac02f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1034,12 +1034,50 @@ export function rankPullRequestsByMergeReadiness( + entries: ReadonlyArray, + tier: (entry: Entry) => number, +): ReadonlyArray { + const timestamp = (entry: Entry) => toSortableTimestamp(entry.updatedAt); + return entries.toSorted((left, right) => { + const byTier = tier(left) - tier(right); + if (byTier !== 0) return byTier; + const leftUpdated = timestamp(left); + const rightUpdated = timestamp(right); + const measured = Number(leftUpdated === null) - Number(rightUpdated === null); + if (measured !== 0) return measured; + if (leftUpdated === null || rightUpdated === null) return 0; + return rightUpdated - leftUpdated; + }); +} + +export function rankPullRequestsBlockedOnAuthor( + entries: ReadonlyArray, +): ReadonlyArray { + return rankByTierThenRecency(entries, (entry) => { + if (entry.state !== "open") return 6; + if (entry.mergeability === "conflicting") return 0; + if (entry.reviewDecision === "changes-requested") return 1; + if (entry.checksState === "failing") return 2; + if (entry.isDraft) return 3; + if (entry.checksState === "passing" && entry.reviewDecision === "approved") return 5; + return 4; + }); +} + +export function rankPullRequestsBlockedOnReviewer( + entries: ReadonlyArray, +): ReadonlyArray { + return rankByTierThenRecency(entries, (entry) => (entry.state === "open" ? 0 : 1)); +} + /** Keeps authored work first while applying the selected ordering inside every involvement group. */ export function sortPullRequestGroups( groups: ReadonlyArray>, sort: PullRequestListSort, searchText: string, hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, + involvement: PullRequestInvolvement = "all", ): ReadonlyArray> { const sortWithinGroups = (rank: (entries: ReadonlyArray) => ReadonlyArray) => groups.map((group) => ({ ...group, entries: rank(group.entries) })); @@ -1049,6 +1087,20 @@ export function sortPullRequestGroups( ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) : groups; } + if (sort === "blocked") { + if (searchText.trim().length > 0) return groups; + const role = (key: PullRequestGroupKey) => + key === "others" ? involvement : key === "authored" ? "authored" : "reviewing"; + return groups.map((group) => { + const groupRole = role(group.key); + if (groupRole === "all") return group; + const rank = + groupRole === "authored" + ? rankPullRequestsBlockedOnAuthor + : rankPullRequestsBlockedOnReviewer; + return { ...group, entries: rank(group.entries) }; + }); + } if (sort === "updated") return groups; const timestamp = (entry: Entry) => diff --git a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts index 95c4bf02e743e8dcfc6e413668372af79b8ebf18..afb926c5862d7485f6ad2e8836b5c0d4cb33f4b9 100644 --- a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts +++ b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts @@ -10,6 +10,7 @@ import { export const PullRequestListSort = Schema.Literals([ "ready", + "blocked", "updated", "newest", "oldest", diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index bed2b65da5e4603ca286dab1c3799a1b5311bc97..f54415b1986ceeaedc1beeb37e21e9ad135feac4 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -28,6 +28,7 @@ import { Maximize2Icon, Minimize2Icon, SearchIcon, + UserLockIcon, } from "lucide-react"; import { useCallback, @@ -203,6 +204,7 @@ const STATE_TABS = [ const SORT_OPTIONS = [ { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, + { value: "blocked", label: "Blocked on me", Icon: UserLockIcon }, { value: "updated", label: "Recently updated", Icon: ClockIcon }, { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, @@ -1437,8 +1439,9 @@ function PullRequestsRouteView() { typedParsed.text, (entry) => entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), + search.involvement, ); - }, [groups, sort, statsByRow, typedParsed.text]); + }, [groups, search.involvement, sort, statsByRow, typedParsed.text]); const listedPullRequestsBySurface = useMemo( () => new Map(