authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 23:55:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 23:55:15-04:00
log931d41f933b2536f699975412169f5318e6adad5
treeb433f860576b001843904dfb341cfbbef19459f4
parent9cb40178a53cca279c67a9079afab3cddf6b6ddb
signature Signed by PGP key B5690EEEBB952194

fix(web): sort equally merge-ready pull requests by diff size (#9887)


3 files changed, 39 insertions(+), 18 deletions(-)

apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+24-7
......@@ -723,6 +723,7 @@ describe("default merge-readiness ranking", () => {
723723 number: 4,
724724 checksState: "passing",
725725 reviewDecision: "approved",
726 additions: 1_000,
726727 updatedAt: "2026-06-01T00:00:00Z",
727728 });
728729 const draft = entry({
......@@ -747,7 +748,7 @@ describe("default merge-readiness ranking", () => {
747748 ).toEqual([4, 3, 5, 2, 6, 1]);
748749 });
749750
750 it("uses recency inside one readiness tier", () => {
751 it("uses recency when readiness and diff size tie", () => {
751752 const older = entry({ number: 1, checksState: "passing" });
752753 const newer = entry({
753754 number: 2,
......@@ -760,13 +761,13 @@ describe("default merge-readiness ranking", () => {
760761 ]);
761762 });
762763
763 it("keeps readiness order stable when optional diff counts arrive", () => {
764 it("ranks smaller measured diffs first within a readiness tier as counts arrive", () => {
764765 const larger = entry({
765766 number: 1,
766767 checksState: "passing",
767768 reviewDecision: "approved",
768 additions: 40,
769 deletions: 10,
769 additions: 1,
770 deletions: 49,
770771 updatedAt: "2026-09-01T00:00:00Z",
771772 });
772773 const smaller = entry({
......@@ -788,14 +789,30 @@ describe("default merge-readiness ranking", () => {
788789
789790 expect(
790791 rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number),
791 ).toEqual([3, 1, 2]);
792 ).toEqual([2, 1, 3]);
792793 expect(
793794 rankPullRequestsByMergeReadiness([
794795 larger,
795 { ...unknown, additions: 500, deletions: 200 },
796 { ...unknown, additions: 1, deletions: 0 },
796797 smaller,
797798 ]).map((row) => row.number),
798 ).toEqual([3, 1, 2]);
799 ).toEqual([3, 2, 1]);
800 });
801
802 it("distinguishes measured empty diffs from missing counts when sorting groups", () => {
803 const unknown = entry({ number: 1, additions: 0, deletions: 0 });
804 const measured = entry({ number: 2 });
805 const empty = entry({ number: 3, additions: 0, deletions: 0 });
806 const groups = [
807 { key: "others", label: "Others", entries: [unknown, measured, empty] },
808 ] as const;
809
810 const sorted = sortPullRequestGroups(groups, "ready", "", (row) => row.number !== 1);
811
812 expect(sorted[0]!.entries.map((row) => row.number)).toEqual([3, 2, 1]);
813 expect(sortPullRequestGroups(groups, "ready", "sidebar", (row) => row.number !== 1)).toEqual(
814 groups,
815 );
799816 });
800817
801818 it("keeps authored work first and ranks each group by readiness", () => {
apps/web/src/components/pullRequest/pullRequestList.logic.ts+7-3
......@@ -1010,10 +1010,12 @@ export function rankPullRequestMatches<Entry extends PullRequestListEntry>(
10101010 * verdict, then everything else still open. Drafts stay in that third tier because their author
10111011 * has not made them mergeable yet. Finished work follows open work when all states are visible. A
10121012 * known conflict is never ready, whatever its checks, review or state say, so it stays at the
1013 * bottom. Recency breaks ties, so optional diff counts never move the queue beneath the reader.
1013 * bottom. Within each tier, smaller measured diffs come first, then unknown sizes. Recency
1014 * breaks ties between equally sized diffs.
10141015 */
10151016export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEntry>(
10161017 entries: ReadonlyArray<Entry>,
1018 hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0,
10171019): ReadonlyArray<Entry> {
10181020 const tier = (entry: Entry) => {
10191021 if (entry.mergeability === "conflicting") return 4;
......@@ -1026,7 +1028,9 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn
10261028 return entries.toSorted((left, right) => {
10271029 const byTier = tier(left) - tier(right);
10281030 if (byTier !== 0) return byTier;
1029 return right.updatedAt.localeCompare(left.updatedAt);
1031 const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left));
1032 const sized = left.additions + left.deletions - (right.additions + right.deletions);
1033 return measured || sized || right.updatedAt.localeCompare(left.updatedAt);
10301034 });
10311035}
10321036
......@@ -1042,7 +1046,7 @@ export function sortPullRequestGroups<Entry extends PullRequestListEntry>(
10421046
10431047 if (sort === "ready") {
10441048 return searchText.trim().length === 0
1045 ? sortWithinGroups(rankPullRequestsByMergeReadiness)
1049 ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize))
10461050 : groups;
10471051 }
10481052 if (sort === "updated") return groups;
apps/web/src/routes/_chat.pull-requests.tsx+8-8
......@@ -292,7 +292,7 @@ function PullRequestsRouteView() {
292292 const search = Route.useSearch();
293293 const sort = search.sort ?? "ready";
294294 const statsPolicy: PullRequestStatsPolicy =
295 sort === "largest" || sort === "smallest" ? "eager" : "visible";
295 sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible";
296296 const navigate = useNavigate({ from: Route.fullPath });
297297 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
298298 const { environments } = useEnvironments();
......@@ -826,7 +826,7 @@ function PullRequestsRouteView() {
826826 // from the first moment rather than the second: a button that stays live through the slow half
827827 // of its own work is a button that gets pressed again, and buys the whole cascade twice.
828828 const [invalidating, setInvalidating] = useState(false);
829 const refreshFromHost = async () => {
829 const refreshFromHost = async (includeDetail = true) => {
830830 const requestedStatsScope = statsScopeRef.current;
831831 setInvalidating(true);
832832 try {
......@@ -851,7 +851,7 @@ function PullRequestsRouteView() {
851851 setStatsTargetState({ key: requestedStatsScope.key, batches });
852852 statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input })));
853853 }
854 setDetailRefreshToken((token) => token + 1);
854 if (includeDetail) setDetailRefreshToken((token) => token + 1);
855855 };
856856 const refreshing = invalidating || listQuery.isPending;
857857
......@@ -1270,8 +1270,8 @@ function PullRequestsRouteView() {
12701270 viewers,
12711271 ]);
12721272
1273 // Date sorts keep optional line-count reads near the viewport. Size sorts need every loaded
1274 // count before their order is final. Received counts stay cached across both policies.
1273 // Date sorts keep optional line-count reads near the viewport. Size and readiness sorts need
1274 // every loaded count before their order is final. Counts stay cached across both policies.
12751275 const entriesByStatsKey = useRef<ReadonlyMap<string, EnvironmentPullRequestEntry>>(new Map());
12761276 entriesByStatsKey.current = new Map(
12771277 groups.flatMap((group) =>
......@@ -1950,10 +1950,10 @@ function PullRequestsRouteView() {
19501950 ) ?? null
19511951 }
19521952 refreshToken={detailRefreshToken}
1953 // Merging, closing or reopening changes the row this panel was opened from, so
1954 // the list behind it is out of date the moment the host takes the action.
1953 // Host actions can change both readiness and diff size, so refresh the counts
1954 // alongside the list. The panel already refreshes itself after each action.
19551955 onActed={() => {
1956 refreshList(true);
1956 void refreshFromHost(false);
19571957 }}
19581958 />
19591959 </RightPanelTabs>