authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 13:47:01-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 13:47:01-04:00
log645d58547d282eb2aaf6c48e5907625fc112386a
tree03dcf97cf11ee248de3d17c795e493af373955f7
parent21b9dda5afb00a33e228a68d2ccc885bba7285dc
signature Signed by PGP key B5690EEEBB952194

fix(web): prioritize authored pull requests (#9453)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

4 files changed, 118 insertions(+), 53 deletions(-)

apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+62-4
......@@ -23,6 +23,7 @@ import {
2323 rankPullRequestMatches,
2424 rankPullRequestsByMergeReadiness,
2525 scorePullRequestMatch,
26 sortPullRequestGroups,
2627 retainVisiblePullRequestStatsBatches,
2728 withDiffStat,
2829 resolveProjectScope,
......@@ -328,8 +329,8 @@ describe("pull request grouping", () => {
328329 VIEWERS,
329330 );
330331 expect(groups.map((group) => [group.key, group.entries.length])).toEqual([
331 ["reviewRequested", 1],
332332 ["authored", 1],
333 ["reviewRequested", 1],
333334 ]);
334335 });
335336
......@@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => {
753754 rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number),
754755 ).toEqual([2, 1, 3]);
755756 });
757
758 it("keeps authored work first and ranks each group by readiness", () => {
759 const authoredWaiting = entry({ number: 1, checksState: "pending" });
760 const authoredReady = entry({
761 number: 2,
762 checksState: "passing",
763 reviewDecision: "approved",
764 });
765 const otherReady = entry({
766 number: 3,
767 checksState: "passing",
768 reviewDecision: "approved",
769 });
770 const sorted = sortPullRequestGroups(
771 [
772 { key: "authored", label: "Authored", entries: [authoredWaiting, authoredReady] },
773 { key: "others", label: "Others", entries: [otherReady] },
774 ],
775 "ready",
776 "",
777 );
778
779 expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]);
780 expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]);
781 });
782
783 it.each([
784 ["updated", [1, 2]],
785 ["newest", [2, 1]],
786 ["oldest", [1, 2]],
787 ["largest", [1, 2]],
788 ["smallest", [2, 1]],
789 ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => {
790 const olderLarger = entry({
791 number: 1,
792 additions: 20,
793 createdAt: "2026-07-01T00:00:00Z",
794 updatedAt: "2026-08-01T00:00:00Z",
795 });
796 const newerSmaller = entry({
797 number: 2,
798 additions: 2,
799 createdAt: "2026-08-01T00:00:00Z",
800 updatedAt: "2026-07-01T00:00:00Z",
801 });
802 const sorted = sortPullRequestGroups(
803 [
804 { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] },
805 { key: "others", label: "Others", entries: [entry({ number: 3 })] },
806 ],
807 sort,
808 "",
809 );
810
811 expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]);
812 expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order);
813 });
756814});
757815
758816describe("line counts that arrive after the rows", () => {
......@@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => {
843901 updatedAt: "2026-06-02T00:00:00Z",
844902 });
845903 const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]);
846 expect(groups.map((group) => group.key)).toEqual(["reviewRequested", "authored"]);
847 expect(groups[0]!.entries.map((item) => item.number)).toEqual([2, 3]);
848 expect(groups[1]!.entries.map((item) => item.number)).toEqual([1]);
904 expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]);
905 expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]);
906 expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]);
849907 });
850908
851909 it("lets the feed's copy of a partitioned row replace the partition's", () => {
apps/web/src/components/pullRequest/pullRequestList.logic.ts+44-2
......@@ -18,6 +18,9 @@ import type {
1818 PullRequestListState,
1919} from "@t3tools/contracts";
2020
21import { toSortableTimestamp } from "../../lib/threadSort";
22import type { PullRequestListSort } from "./pullRequestListPreferences";
23
2124/**
2225 * A listed change request with the environment that read it. Nothing on a row says which machine
2326 * it came from, and the page unions every connected one — so acting on a row, refreshing it, or
......@@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement<Entry extends ScopedEntry>(
415418 buckets.others.push(entry);
416419 }
417420 }
418 return (["reviewRequested", "authored", "others"] as const)
421 return (["authored", "reviewRequested", "others"] as const)
419422 .filter((key) => buckets[key].length > 0)
420423 .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] }));
421424}
......@@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority<Entry extends PullRequestListE
615618 const byRecency = (left: Entry, right: Entry) => right.updatedAt.localeCompare(left.updatedAt);
616619 return (
617620 [
618 { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) },
619621 { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) },
622 { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) },
620623 { key: "others", entries: others },
621624 ] as const
622625 )
......@@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn
10271030 });
10281031}
10291032
1033/** Keeps authored work first while applying the selected ordering inside every involvement group. */
1034export function sortPullRequestGroups<Entry extends PullRequestListEntry>(
1035 groups: ReadonlyArray<PullRequestGroup<Entry>>,
1036 sort: PullRequestListSort,
1037 searchText: string,
1038 hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0,
1039): ReadonlyArray<PullRequestGroup<Entry>> {
1040 const sortWithinGroups = (rank: (entries: ReadonlyArray<Entry>) => ReadonlyArray<Entry>) =>
1041 groups.map((group) => ({ ...group, entries: rank(group.entries) }));
1042
1043 if (sort === "ready") {
1044 return searchText.trim().length === 0
1045 ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize))
1046 : groups;
1047 }
1048 if (sort === "updated") return groups;
1049
1050 const timestamp = (entry: Entry) =>
1051 toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0;
1052 return sortWithinGroups((entries) =>
1053 entries.toSorted((left, right) => {
1054 if (sort === "newest" || sort === "oldest") {
1055 const leftCreated = toSortableTimestamp(left.createdAt);
1056 const rightCreated = toSortableTimestamp(right.createdAt);
1057 const measured = Number(rightCreated !== null) - Number(leftCreated !== null);
1058 const dated = (leftCreated ?? 0) - (rightCreated ?? 0);
1059 return (
1060 measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left)
1061 );
1062 }
1063 const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left));
1064 const sized = left.additions + left.deletions - (right.additions + right.deletions);
1065 return (
1066 measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left)
1067 );
1068 }),
1069 );
1070}
1071
10301072/**
10311073 * A row with the line counts that arrived after it did. Only where the host left them out — a
10321074 * listing that carried them is not second-guessed — and only where they have arrived, since a row
apps/web/src/routes/_chat.pull-requests.tsx+8-44
......@@ -58,7 +58,7 @@ import {
5858 pullRequestEntryKey,
5959 pullRequestEntryViewer,
6060 rankPullRequestMatches,
61 rankPullRequestsByMergeReadiness,
61 sortPullRequestGroups,
6262 pullRequestEnvironmentSetKey,
6363 readPullRequestListSnapshot,
6464 resolveProjectScope,
......@@ -120,7 +120,6 @@ import { SidebarInset } from "../components/ui/sidebar";
120120import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
121121import { useLiveRefresh } from "../hooks/useLiveRefresh";
122122import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
123import { toSortableTimestamp } from "../lib/threadSort";
124123import {
125124 pullRequestSurfaceId,
126125 selectActiveRightPanelSurface,
......@@ -1426,51 +1425,16 @@ function PullRequestsRouteView() {
14261425 ...group,
14271426 entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)),
14281427 }));
1429 if (sort === "ready" && typedParsed.text.length === 0) {
1430 return [
1431 {
1432 key: "others" as const,
1433 label: "",
1434 entries: rankPullRequestsByMergeReadiness(
1435 enriched.flatMap((group) => group.entries),
1436 (entry) =>
1437 entry.additions + entry.deletions > 0 ||
1438 statsByRow.has(pullRequestDiffStatKey(entry)),
1439 ),
1440 },
1441 ];
1442 }
14431428 // Searching keeps its relevance order and priority groups unless the reader explicitly asks
14441429 // for another sort. The readiness queue is the default browse order, not a way to bury a
14451430 // closer text match.
1446 if (sort === "ready" || sort === "updated") return enriched;
1447 const entries = enriched.flatMap((group) => group.entries);
1448 const hasSize = (entry: (typeof entries)[number]) =>
1449 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry));
1450 const timestamp = (entry: (typeof entries)[number]) =>
1451 toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0;
1452 return [
1453 {
1454 key: "others" as const,
1455 label: "",
1456 entries: entries.toSorted((left, right) => {
1457 if (sort === "newest" || sort === "oldest") {
1458 const leftCreated = toSortableTimestamp(left.createdAt);
1459 const rightCreated = toSortableTimestamp(right.createdAt);
1460 const measured = Number(rightCreated !== null) - Number(leftCreated !== null);
1461 const dated = (leftCreated ?? 0) - (rightCreated ?? 0);
1462 return (
1463 measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left)
1464 );
1465 }
1466 const measured = Number(hasSize(right)) - Number(hasSize(left));
1467 const sized = left.additions + left.deletions - (right.additions + right.deletions);
1468 return (
1469 measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left)
1470 );
1471 }),
1472 },
1473 ];
1431 return sortPullRequestGroups(
1432 enriched,
1433 sort,
1434 typedParsed.text,
1435 (entry) =>
1436 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
1437 );
14741438 }, [groups, sort, statsByRow, typedParsed.text]);
14751439
14761440 const linkedSelection = useMemo(
docs/user/source-control.md+4-3
......@@ -41,9 +41,10 @@ T3 Code works with the platforms your team already uses:
4141
4242- See if your current branch already has an open PR/MR
4343- Open several reviews from the **Pull requests** page as tabs in the right panel
44- By default, see passing and approved reviews first, passing reviews awaiting approval next, and
45 conflicting reviews last. Smaller changes come first within each readiness group, and finished
46 reviews follow open work when all states are visible.
44- Your authored reviews stay at the top and use the selected sort within their group. By default,
45 see passing and approved reviews first, passing reviews awaiting approval next, and conflicting
46 reviews last. Smaller changes come first within each readiness group, and finished reviews follow
47 open work when all states are visible.
4748- Filter the list by author or labels, rank authors by merges in the loaded results, see label and
4849 change-size context on each row, and sort the results currently shown by readiness, update time,
4950 creation time, or change size. Your filters, search, scope, and sort are restored when you return.