| 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 | 21 | readPullRequestListSnapshot, |
| 22 | 22 | writePullRequestListSnapshot, |
| 23 | 23 | rankPullRequestMatches, |
| 24 | rankPullRequestsBlockedOnAuthor, | |
| 25 | rankPullRequestsBlockedOnReviewer, | |
| 24 | 26 | rankPullRequestsByMergeReadiness, |
| 25 | 27 | scorePullRequestMatch, |
| 26 | 28 | sortPullRequestGroups, |
| ... | ... | @@ -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 | 980 | describe("line counts that arrive after the rows", () => { |
| 877 | 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 | 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 | 1074 | /** Keeps authored work first while applying the selected ordering inside every involvement group. */ |
| 1038 | 1075 | export function sortPullRequestGroups<Entry extends PullRequestListEntry>( |
| 1039 | 1076 | groups: ReadonlyArray<PullRequestGroup<Entry>>, |
| 1040 | 1077 | sort: PullRequestListSort, |
| 1041 | 1078 | searchText: string, |
| 1042 | 1079 | hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, |
| 1080 | involvement: PullRequestInvolvement = "all", | |
| 1043 | 1081 | ): ReadonlyArray<PullRequestGroup<Entry>> { |
| 1044 | 1082 | const sortWithinGroups = (rank: (entries: ReadonlyArray<Entry>) => ReadonlyArray<Entry>) => |
| 1045 | 1083 | groups.map((group) => ({ ...group, entries: rank(group.entries) })); |
| ... | ... | @@ -1049,6 +1087,20 @@ export function sortPullRequestGroups<Entry extends PullRequestListEntry>( |
| 1049 | 1087 | ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) |
| 1050 | 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 | 1104 | if (sort === "updated") return groups; |
| 1053 | 1105 | |
| 1054 | 1106 | const timestamp = (entry: Entry) => |
apps/web/src/components/pullRequest/pullRequestListPreferences.ts+1| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | |
| 11 | 11 | export const PullRequestListSort = Schema.Literals([ |
| 12 | 12 | "ready", |
| 13 | "blocked", | |
| 13 | 14 | "updated", |
| 14 | 15 | "newest", |
| 15 | 16 | "oldest", |
apps/web/src/routes/_chat.pull-requests.tsx+4-1| ... | ... | @@ -28,6 +28,7 @@ import { |
| 28 | 28 | Maximize2Icon, |
| 29 | 29 | Minimize2Icon, |
| 30 | 30 | SearchIcon, |
| 31 | UserLockIcon, | |
| 31 | 32 | } from "lucide-react"; |
| 32 | 33 | import { |
| 33 | 34 | useCallback, |
| ... | ... | @@ -203,6 +204,7 @@ const STATE_TABS = [ |
| 203 | 204 | |
| 204 | 205 | const SORT_OPTIONS = [ |
| 205 | 206 | { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, |
| 207 | { value: "blocked", label: "Blocked on me", Icon: UserLockIcon }, | |
| 206 | 208 | { value: "updated", label: "Recently updated", Icon: ClockIcon }, |
| 207 | 209 | { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, |
| 208 | 210 | { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, |
| ... | ... | @@ -1437,8 +1439,9 @@ function PullRequestsRouteView() { |
| 1437 | 1439 | typedParsed.text, |
| 1438 | 1440 | (entry) => |
| 1439 | 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 | 1445 | const listedPullRequestsBySurface = useMemo( |
| 1443 | 1446 | () => |
| 1444 | 1447 | new Map( |