Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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", () => {
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/pullRequest/pullRequestList.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,10 +1010,12 @@ export function rankPullRequestMatches<Entry extends PullRequestListEntry>(
* 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<Entry extends PullRequestListEntry>(
entries: ReadonlyArray<Entry>,
hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0,
): ReadonlyArray<Entry> {
const tier = (entry: Entry) => {
if (entry.mergeability === "conflicting") return 4;
Expand All @@ -1026,7 +1028,9 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn
return entries.toSorted((left, right) => {
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);
});
}

Expand All @@ -1042,7 +1046,7 @@ export function sortPullRequestGroups<Entry extends PullRequestListEntry>(

if (sort === "ready") {
return searchText.trim().length === 0
? sortWithinGroups(rankPullRequestsByMergeReadiness)
? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize))
: groups;
}
if (sort === "updated") return groups;
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/routes/_chat.pull-requests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const navigate = useNavigate({ from: Route.fullPath });
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { environments } = useEnvironments();
Expand Down Expand Up @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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<ReadonlyMap<string, EnvironmentPullRequestEntry>>(new Map());
entriesByStatsKey.current = new Map(
groups.flatMap((group) =>
Expand Down Expand Up @@ -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);
}}
/>
</RightPanelTabs>
Expand Down
Loading