authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-18 16:11:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 20:11:40-03:00
logd99bc5a4d707df2506e60cbb411b8d30022bbf89
tree16039981c84bc034315036cf16ae8df8be9b42cc
parentea15f17afd94e7ce7133e52f5b5983910662c9ec
signature Signed by PGP key B5690EEEBB952194

feat(web): sort pull requests by what is blocked on me (#12508)


4 files changed, 161 insertions(+), 1 deletions(-)

apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+104
......@@ -21,6 +21,8 @@ import {
2121 readPullRequestListSnapshot,
2222 writePullRequestListSnapshot,
2323 rankPullRequestMatches,
24 rankPullRequestsBlockedOnAuthor,
25 rankPullRequestsBlockedOnReviewer,
2426 rankPullRequestsByMergeReadiness,
2527 scorePullRequestMatch,
2628 sortPullRequestGroups,
......@@ -873,6 +875,108 @@ describe("default merge-readiness ranking", () => {
873875 });
874876});
875877
878describe("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
876980describe("line counts that arrive after the rows", () => {
877981 const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]);
878982
apps/web/src/components/pullRequest/pullRequestList.logic.ts+52
......@@ -1034,12 +1034,50 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn
10341034 });
10351035}
10361036
1037function 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
1054export 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
1068export function rankPullRequestsBlockedOnReviewer<Entry extends PullRequestListEntry>(
1069 entries: ReadonlyArray<Entry>,
1070): ReadonlyArray<Entry> {
1071 return rankByTierThenRecency(entries, (entry) => (entry.state === "open" ? 0 : 1));
1072}
1073
10371074/** Keeps authored work first while applying the selected ordering inside every involvement group. */
10381075export function sortPullRequestGroups<Entry extends PullRequestListEntry>(
10391076 groups: ReadonlyArray<PullRequestGroup<Entry>>,
10401077 sort: PullRequestListSort,
10411078 searchText: string,
10421079 hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0,
1080 involvement: PullRequestInvolvement = "all",
10431081): ReadonlyArray<PullRequestGroup<Entry>> {
10441082 const sortWithinGroups = (rank: (entries: ReadonlyArray<Entry>) => ReadonlyArray<Entry>) =>
10451083 groups.map((group) => ({ ...group, entries: rank(group.entries) }));
......@@ -1049,6 +1087,20 @@ export function sortPullRequestGroups<Entry extends PullRequestListEntry>(
10491087 ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize))
10501088 : groups;
10511089 }
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 }
10521104 if (sort === "updated") return groups;
10531105
10541106 const timestamp = (entry: Entry) =>
apps/web/src/components/pullRequest/pullRequestListPreferences.ts+1
......@@ -10,6 +10,7 @@ import {
1010
1111export const PullRequestListSort = Schema.Literals([
1212 "ready",
13 "blocked",
1314 "updated",
1415 "newest",
1516 "oldest",
apps/web/src/routes/_chat.pull-requests.tsx+4-1
......@@ -28,6 +28,7 @@ import {
2828 Maximize2Icon,
2929 Minimize2Icon,
3030 SearchIcon,
31 UserLockIcon,
3132} from "lucide-react";
3233import {
3334 useCallback,
......@@ -203,6 +204,7 @@ const STATE_TABS = [
203204
204205const SORT_OPTIONS = [
205206 { value: "ready", label: "Merge readiness", Icon: ListChecksIcon },
207 { value: "blocked", label: "Blocked on me", Icon: UserLockIcon },
206208 { value: "updated", label: "Recently updated", Icon: ClockIcon },
207209 { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon },
208210 { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon },
......@@ -1437,8 +1439,9 @@ function PullRequestsRouteView() {
14371439 typedParsed.text,
14381440 (entry) =>
14391441 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
1442 search.involvement,
14401443 );
1441 }, [groups, sort, statsByRow, typedParsed.text]);
1444 }, [groups, search.involvement, sort, statsByRow, typedParsed.text]);
14421445 const listedPullRequestsBySurface = useMemo(
14431446 () =>
14441447 new Map(