| author | |
| committer | |
| log | d99bc5a4d707df2506e60cbb411b8d30022bbf89 |
| tree | 16039981c84bc034315036cf16ae8df8be9b42cc |
| parent | ea15f17afd94e7ce7133e52f5b5983910662c9ec |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 161 insertions(+), 1 deletions(-)
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+104| ... | @@ -21,6 +21,8 @@ import { | ... | @@ -21,6 +21,8 @@ import { |
| 21 | readPullRequestListSnapshot, | 21 | readPullRequestListSnapshot, |
| 22 | writePullRequestListSnapshot, | 22 | writePullRequestListSnapshot, |
| 23 | rankPullRequestMatches, | 23 | rankPullRequestMatches, |
| 24 | rankPullRequestsBlockedOnAuthor, | ||
| 25 | rankPullRequestsBlockedOnReviewer, | ||
| 24 | rankPullRequestsByMergeReadiness, | 26 | rankPullRequestsByMergeReadiness, |
| 25 | scorePullRequestMatch, | 27 | scorePullRequestMatch, |
| 26 | sortPullRequestGroups, | 28 | sortPullRequestGroups, |
| ... | @@ -873,6 +875,108 @@ describe("default merge-readiness ranking", () => { | ... | @@ -873,6 +875,108 @@ describe("default merge-readiness ranking", () => { |
| 873 | }); | 875 | }); |
| 874 | }); | 876 | }); |
| 875 | 877 | ||
| 878 | describe("blocked-on-me ranking", () => { | ||
| 879 | it("ranks authored work by how surely it is the author's to unblock, newest first within a tier", () => { | ||
| 880 | const conflict = entry({ | ||
| 881 | number: 1, | ||
| 882 | mergeability: "conflicting", | ||
| 883 | checksState: "failing", | ||
| 884 | updatedAt: "2026-08-01T00:00:00Z", | ||
| 885 | }); | ||
| 886 | const olderFailing = entry({ | ||
| 887 | number: 2, | ||
| 888 | checksState: "failing", | ||
| 889 | updatedAt: "2026-08-01T00:00:00Z", | ||
| 890 | }); | ||
| 891 | const changesRequested = entry({ | ||
| 892 | number: 3, | ||
| 893 | reviewDecision: "changes-requested", | ||
| 894 | checksState: "failing", | ||
| 895 | updatedAt: "2026-08-02T00:00:00Z", | ||
| 896 | }); | ||
| 897 | const approved = entry({ number: 4, checksState: "passing", reviewDecision: "approved" }); | ||
| 898 | const draft = entry({ number: 5, isDraft: true, checksState: "passing" }); | ||
| 899 | const waiting = entry({ number: 6 }); | ||
| 900 | const merged = entry({ number: 7, state: "merged", updatedAt: "2026-08-01T00:00:00Z" }); | ||
| 901 | const newerClosed = entry({ number: 8, state: "closed", updatedAt: "2026-08-03T00:00:00Z" }); | ||
| 902 | const newerFailing = entry({ | ||
| 903 | number: 9, | ||
| 904 | checksState: "failing", | ||
| 905 | updatedAt: "2026-08-03T00:00:00Z", | ||
| 906 | }); | ||
| 907 | |||
| 908 | expect( | ||
| 909 | rankPullRequestsBlockedOnAuthor([ | ||
| 910 | waiting, | ||
| 911 | merged, | ||
| 912 | olderFailing, | ||
| 913 | draft, | ||
| 914 | approved, | ||
| 915 | changesRequested, | ||
| 916 | newerClosed, | ||
| 917 | conflict, | ||
| 918 | newerFailing, | ||
| 919 | ]).map((row) => row.number), | ||
| 920 | ).toEqual([1, 3, 9, 2, 5, 6, 4, 8, 7]); | ||
| 921 | }); | ||
| 922 | |||
| 923 | it("ranks reviewer work with open rows first, newest first", () => { | ||
| 924 | const olderOpen = entry({ number: 1, updatedAt: "2026-08-01T00:00:00Z" }); | ||
| 925 | const newerOpen = entry({ number: 2, updatedAt: "2026-08-02T00:00:00Z" }); | ||
| 926 | const olderFinished = entry({ number: 3, state: "merged", updatedAt: "2026-08-01T00:00:00Z" }); | ||
| 927 | const newerFinished = entry({ number: 4, state: "closed", updatedAt: "2026-08-03T00:00:00Z" }); | ||
| 928 | |||
| 929 | expect( | ||
| 930 | rankPullRequestsBlockedOnReviewer([newerFinished, newerOpen, olderFinished, olderOpen]).map( | ||
| 931 | (row) => row.number, | ||
| 932 | ), | ||
| 933 | ).toEqual([2, 1, 4, 3]); | ||
| 934 | }); | ||
| 935 | |||
| 936 | it("ranks blocked groups according to their involvement", () => { | ||
| 937 | const authored = [ | ||
| 938 | entry({ number: 1, isDraft: true }), | ||
| 939 | entry({ number: 2, checksState: "failing" }), | ||
| 940 | ]; | ||
| 941 | const reviewing = [ | ||
| 942 | entry({ number: 3, updatedAt: "2026-08-02T00:00:00Z" }), | ||
| 943 | entry({ number: 4, updatedAt: "2026-08-01T00:00:00Z" }), | ||
| 944 | ]; | ||
| 945 | const others = [ | ||
| 946 | entry({ number: 5, updatedAt: "2026-08-02T00:00:00Z" }), | ||
| 947 | entry({ number: 6, isDraft: true }), | ||
| 948 | ]; | ||
| 949 | const groups = [ | ||
| 950 | { key: "authored", label: "Authored", entries: authored }, | ||
| 951 | { key: "reviewRequested", label: "Review requested", entries: reviewing }, | ||
| 952 | { key: "others", label: "Others", entries: others }, | ||
| 953 | ] as const; | ||
| 954 | |||
| 955 | expect( | ||
| 956 | sortPullRequestGroups(groups, "blocked", "", undefined, "all").map((group) => | ||
| 957 | group.entries.map((row) => row.number), | ||
| 958 | ), | ||
| 959 | ).toEqual([ | ||
| 960 | [2, 1], | ||
| 961 | [3, 4], | ||
| 962 | [5, 6], | ||
| 963 | ]); | ||
| 964 | expect( | ||
| 965 | sortPullRequestGroups([groups[2]], "blocked", "", undefined, "authored")[0]!.entries.map( | ||
| 966 | (row) => row.number, | ||
| 967 | ), | ||
| 968 | ).toEqual([6, 5]); | ||
| 969 | expect( | ||
| 970 | sortPullRequestGroups([groups[2]], "blocked", "", undefined, "reviewing")[0]!.entries.map( | ||
| 971 | (row) => row.number, | ||
| 972 | ), | ||
| 973 | ).toEqual([5, 6]); | ||
| 974 | expect(sortPullRequestGroups(groups, "blocked", "needle", undefined, "authored")).toEqual( | ||
| 975 | groups, | ||
| 976 | ); | ||
| 977 | }); | ||
| 978 | }); | ||
| 979 | |||
| 876 | describe("line counts that arrive after the rows", () => { | 980 | describe("line counts that arrive after the rows", () => { |
| 877 | const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]); | 981 | const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]); |
| 878 | 982 |
apps/web/src/components/pullRequest/pullRequestList.logic.ts+52| ... | @@ -1034,12 +1034,50 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn | ... | @@ -1034,12 +1034,50 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn |
| 1034 | }); | 1034 | }); |
| 1035 | } | 1035 | } |
| 1036 | 1036 | ||
| 1037 | function rankByTierThenRecency<Entry extends PullRequestListEntry>( | ||
| 1038 | entries: ReadonlyArray<Entry>, | ||
| 1039 | tier: (entry: Entry) => number, | ||
| 1040 | ): ReadonlyArray<Entry> { | ||
| 1041 | const timestamp = (entry: Entry) => toSortableTimestamp(entry.updatedAt); | ||
| 1042 | return entries.toSorted((left, right) => { | ||
| 1043 | const byTier = tier(left) - tier(right); | ||
| 1044 | if (byTier !== 0) return byTier; | ||
| 1045 | const leftUpdated = timestamp(left); | ||
| 1046 | const rightUpdated = timestamp(right); | ||
| 1047 | const measured = Number(leftUpdated === null) - Number(rightUpdated === null); | ||
| 1048 | if (measured !== 0) return measured; | ||
| 1049 | if (leftUpdated === null || rightUpdated === null) return 0; | ||
| 1050 | return rightUpdated - leftUpdated; | ||
| 1051 | }); | ||
| 1052 | } | ||
| 1053 | |||
| 1054 | export function rankPullRequestsBlockedOnAuthor<Entry extends PullRequestListEntry>( | ||
| 1055 | entries: ReadonlyArray<Entry>, | ||
| 1056 | ): ReadonlyArray<Entry> { | ||
| 1057 | return rankByTierThenRecency(entries, (entry) => { | ||
| 1058 | if (entry.state !== "open") return 6; | ||
| 1059 | if (entry.mergeability === "conflicting") return 0; | ||
| 1060 | if (entry.reviewDecision === "changes-requested") return 1; | ||
| 1061 | if (entry.checksState === "failing") return 2; | ||
| 1062 | if (entry.isDraft) return 3; | ||
| 1063 | if (entry.checksState === "passing" && entry.reviewDecision === "approved") return 5; | ||
| 1064 | return 4; | ||
| 1065 | }); | ||
| 1066 | } | ||
| 1067 | |||
| 1068 | export function rankPullRequestsBlockedOnReviewer<Entry extends PullRequestListEntry>( | ||
| 1069 | entries: ReadonlyArray<Entry>, | ||
| 1070 | ): ReadonlyArray<Entry> { | ||
| 1071 | return rankByTierThenRecency(entries, (entry) => (entry.state === "open" ? 0 : 1)); | ||
| 1072 | } | ||
| 1073 | |||
| 1037 | /** Keeps authored work first while applying the selected ordering inside every involvement group. */ | 1074 | /** Keeps authored work first while applying the selected ordering inside every involvement group. */ |
| 1038 | export function sortPullRequestGroups<Entry extends PullRequestListEntry>( | 1075 | export function sortPullRequestGroups<Entry extends PullRequestListEntry>( |
| 1039 | groups: ReadonlyArray<PullRequestGroup<Entry>>, | 1076 | groups: ReadonlyArray<PullRequestGroup<Entry>>, |
| 1040 | sort: PullRequestListSort, | 1077 | sort: PullRequestListSort, |
| 1041 | searchText: string, | 1078 | searchText: string, |
| 1042 | hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, | 1079 | hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, |
| 1080 | involvement: PullRequestInvolvement = "all", | ||
| 1043 | ): ReadonlyArray<PullRequestGroup<Entry>> { | 1081 | ): ReadonlyArray<PullRequestGroup<Entry>> { |
| 1044 | const sortWithinGroups = (rank: (entries: ReadonlyArray<Entry>) => ReadonlyArray<Entry>) => | 1082 | const sortWithinGroups = (rank: (entries: ReadonlyArray<Entry>) => ReadonlyArray<Entry>) => |
| 1045 | groups.map((group) => ({ ...group, entries: rank(group.entries) })); | 1083 | groups.map((group) => ({ ...group, entries: rank(group.entries) })); |
| ... | @@ -1049,6 +1087,20 @@ export function sortPullRequestGroups<Entry extends PullRequestListEntry>( | ... | @@ -1049,6 +1087,20 @@ export function sortPullRequestGroups<Entry extends PullRequestListEntry>( |
| 1049 | ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) | 1087 | ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) |
| 1050 | : groups; | 1088 | : groups; |
| 1051 | } | 1089 | } |
| 1090 | if (sort === "blocked") { | ||
| 1091 | if (searchText.trim().length > 0) return groups; | ||
| 1092 | const role = (key: PullRequestGroupKey) => | ||
| 1093 | key === "others" ? involvement : key === "authored" ? "authored" : "reviewing"; | ||
| 1094 | return groups.map((group) => { | ||
| 1095 | const groupRole = role(group.key); | ||
| 1096 | if (groupRole === "all") return group; | ||
| 1097 | const rank = | ||
| 1098 | groupRole === "authored" | ||
| 1099 | ? rankPullRequestsBlockedOnAuthor | ||
| 1100 | : rankPullRequestsBlockedOnReviewer; | ||
| 1101 | return { ...group, entries: rank(group.entries) }; | ||
| 1102 | }); | ||
| 1103 | } | ||
| 1052 | if (sort === "updated") return groups; | 1104 | if (sort === "updated") return groups; |
| 1053 | 1105 | ||
| 1054 | const timestamp = (entry: Entry) => | 1106 | const timestamp = (entry: Entry) => |
apps/web/src/components/pullRequest/pullRequestListPreferences.ts+1| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | ||
| 11 | export const PullRequestListSort = Schema.Literals([ | 11 | export const PullRequestListSort = Schema.Literals([ |
| 12 | "ready", | 12 | "ready", |
| 13 | "blocked", | ||
| 13 | "updated", | 14 | "updated", |
| 14 | "newest", | 15 | "newest", |
| 15 | "oldest", | 16 | "oldest", |
apps/web/src/routes/_chat.pull-requests.tsx+4-1| ... | @@ -28,6 +28,7 @@ import { | ... | @@ -28,6 +28,7 @@ import { |
| 28 | Maximize2Icon, | 28 | Maximize2Icon, |
| 29 | Minimize2Icon, | 29 | Minimize2Icon, |
| 30 | SearchIcon, | 30 | SearchIcon, |
| 31 | UserLockIcon, | ||
| 31 | } from "lucide-react"; | 32 | } from "lucide-react"; |
| 32 | import { | 33 | import { |
| 33 | useCallback, | 34 | useCallback, |
| ... | @@ -203,6 +204,7 @@ const STATE_TABS = [ | ... | @@ -203,6 +204,7 @@ const STATE_TABS = [ |
| 203 | 204 | ||
| 204 | const SORT_OPTIONS = [ | 205 | const SORT_OPTIONS = [ |
| 205 | { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, | 206 | { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, |
| 207 | { value: "blocked", label: "Blocked on me", Icon: UserLockIcon }, | ||
| 206 | { value: "updated", label: "Recently updated", Icon: ClockIcon }, | 208 | { value: "updated", label: "Recently updated", Icon: ClockIcon }, |
| 207 | { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, | 209 | { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, |
| 208 | { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, | 210 | { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, |
| ... | @@ -1437,8 +1439,9 @@ function PullRequestsRouteView() { | ... | @@ -1437,8 +1439,9 @@ function PullRequestsRouteView() { |
| 1437 | typedParsed.text, | 1439 | typedParsed.text, |
| 1438 | (entry) => | 1440 | (entry) => |
| 1439 | entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), | 1441 | entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), |
| 1442 | search.involvement, | ||
| 1440 | ); | 1443 | ); |
| 1441 | }, [groups, sort, statsByRow, typedParsed.text]); | 1444 | }, [groups, search.involvement, sort, statsByRow, typedParsed.text]); |
| 1442 | const listedPullRequestsBySurface = useMemo( | 1445 | const listedPullRequestsBySurface = useMemo( |
| 1443 | () => | 1446 | () => |
| 1444 | new Map( | 1447 | new Map( |