diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 9b0043ef90b4..a90022ad72c9 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -723,6 +723,7 @@ describe("default merge-readiness ranking", () => { number: 4, checksState: "passing", reviewDecision: "approved", + additions: 1_000, updatedAt: "2026-06-01T00:00:00Z", }); const draft = entry({ @@ -747,7 +748,7 @@ describe("default merge-readiness ranking", () => { ).toEqual([4, 3, 5, 2, 6, 1]); }); - it("uses recency inside one readiness tier", () => { + it("uses recency when readiness and diff size tie", () => { const older = entry({ number: 1, checksState: "passing" }); const newer = entry({ number: 2, @@ -760,13 +761,13 @@ describe("default merge-readiness ranking", () => { ]); }); - it("keeps readiness order stable when optional diff counts arrive", () => { + it("ranks smaller measured diffs first within a readiness tier as counts arrive", () => { const larger = entry({ number: 1, checksState: "passing", reviewDecision: "approved", - additions: 40, - deletions: 10, + additions: 1, + deletions: 49, updatedAt: "2026-09-01T00:00:00Z", }); const smaller = entry({ @@ -788,14 +789,30 @@ describe("default merge-readiness ranking", () => { expect( rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), - ).toEqual([3, 1, 2]); + ).toEqual([2, 1, 3]); expect( rankPullRequestsByMergeReadiness([ larger, - { ...unknown, additions: 500, deletions: 200 }, + { ...unknown, additions: 1, deletions: 0 }, smaller, ]).map((row) => row.number), - ).toEqual([3, 1, 2]); + ).toEqual([3, 2, 1]); + }); + + it("distinguishes measured empty diffs from missing counts when sorting groups", () => { + const unknown = entry({ number: 1, additions: 0, deletions: 0 }); + const measured = entry({ number: 2 }); + const empty = entry({ number: 3, additions: 0, deletions: 0 }); + const groups = [ + { key: "others", label: "Others", entries: [unknown, measured, empty] }, + ] as const; + + const sorted = sortPullRequestGroups(groups, "ready", "", (row) => row.number !== 1); + + expect(sorted[0]!.entries.map((row) => row.number)).toEqual([3, 2, 1]); + expect(sortPullRequestGroups(groups, "ready", "sidebar", (row) => row.number !== 1)).toEqual( + groups, + ); }); it("keeps authored work first and ranks each group by readiness", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index af71c934ad61..cee672489389 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1010,10 +1010,12 @@ export function rankPullRequestMatches( * verdict, then everything else still open. Drafts stay in that third tier because their author * has not made them mergeable yet. Finished work follows open work when all states are visible. A * known conflict is never ready, whatever its checks, review or state say, so it stays at the - * bottom. Recency breaks ties, so optional diff counts never move the queue beneath the reader. + * bottom. Within each tier, smaller measured diffs come first, then unknown sizes. Recency + * breaks ties between equally sized diffs. */ export function rankPullRequestsByMergeReadiness( entries: ReadonlyArray, + hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, ): ReadonlyArray { const tier = (entry: Entry) => { if (entry.mergeability === "conflicting") return 4; @@ -1026,7 +1028,9 @@ export function rankPullRequestsByMergeReadiness { const byTier = tier(left) - tier(right); if (byTier !== 0) return byTier; - return right.updatedAt.localeCompare(left.updatedAt); + const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return measured || sized || right.updatedAt.localeCompare(left.updatedAt); }); } @@ -1042,7 +1046,7 @@ export function sortPullRequestGroups( if (sort === "ready") { return searchText.trim().length === 0 - ? sortWithinGroups(rankPullRequestsByMergeReadiness) + ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) : groups; } if (sort === "updated") return groups; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 48a558be8e0e..a9537423a7f0 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -292,7 +292,7 @@ function PullRequestsRouteView() { const search = Route.useSearch(); const sort = search.sort ?? "ready"; const statsPolicy: PullRequestStatsPolicy = - sort === "largest" || sort === "smallest" ? "eager" : "visible"; + sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; const navigate = useNavigate({ from: Route.fullPath }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); @@ -826,7 +826,7 @@ function PullRequestsRouteView() { // from the first moment rather than the second: a button that stays live through the slow half // of its own work is a button that gets pressed again, and buys the whole cascade twice. const [invalidating, setInvalidating] = useState(false); - const refreshFromHost = async () => { + const refreshFromHost = async (includeDetail = true) => { const requestedStatsScope = statsScopeRef.current; setInvalidating(true); try { @@ -851,7 +851,7 @@ function PullRequestsRouteView() { setStatsTargetState({ key: requestedStatsScope.key, batches }); statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input }))); } - setDetailRefreshToken((token) => token + 1); + if (includeDetail) setDetailRefreshToken((token) => token + 1); }; const refreshing = invalidating || listQuery.isPending; @@ -1270,8 +1270,8 @@ function PullRequestsRouteView() { viewers, ]); - // Date sorts keep optional line-count reads near the viewport. Size sorts need every loaded - // count before their order is final. Received counts stay cached across both policies. + // Date sorts keep optional line-count reads near the viewport. Size and readiness sorts need + // every loaded count before their order is final. Counts stay cached across both policies. const entriesByStatsKey = useRef>(new Map()); entriesByStatsKey.current = new Map( groups.flatMap((group) => @@ -1950,10 +1950,10 @@ function PullRequestsRouteView() { ) ?? null } refreshToken={detailRefreshToken} - // Merging, closing or reopening changes the row this panel was opened from, so - // the list behind it is out of date the moment the host takes the action. + // Host actions can change both readiness and diff size, so refresh the counts + // alongside the list. The panel already refreshes itself after each action. onActed={() => { - refreshList(true); + void refreshFromHost(false); }} />