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 {...@@ -23,6 +23,7 @@ import {
23 rankPullRequestMatches,23 rankPullRequestMatches,
24 rankPullRequestsByMergeReadiness,24 rankPullRequestsByMergeReadiness,
25 scorePullRequestMatch,25 scorePullRequestMatch,
26 sortPullRequestGroups,
26 retainVisiblePullRequestStatsBatches,27 retainVisiblePullRequestStatsBatches,
27 withDiffStat,28 withDiffStat,
28 resolveProjectScope,29 resolveProjectScope,
...@@ -328,8 +329,8 @@ describe("pull request grouping", () => {...@@ -328,8 +329,8 @@ describe("pull request grouping", () => {
328 VIEWERS,329 VIEWERS,
329 );330 );
330 expect(groups.map((group) => [group.key, group.entries.length])).toEqual([331 expect(groups.map((group) => [group.key, group.entries.length])).toEqual([
331 ["reviewRequested", 1],
332 ["authored", 1],332 ["authored", 1],
333 ["reviewRequested", 1],
333 ]);334 ]);
334 });335 });
335336
...@@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => {...@@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => {
753 rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number),754 rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number),
754 ).toEqual([2, 1, 3]);755 ).toEqual([2, 1, 3]);
755 });756 });
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 });
756});814});
757815
758describe("line counts that arrive after the rows", () => {816describe("line counts that arrive after the rows", () => {
...@@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => {...@@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => {
843 updatedAt: "2026-06-02T00:00:00Z",901 updatedAt: "2026-06-02T00:00:00Z",
844 });902 });
845 const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]);903 const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]);
846 expect(groups.map((group) => group.key)).toEqual(["reviewRequested", "authored"]);904 expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]);
847 expect(groups[0]!.entries.map((item) => item.number)).toEqual([2, 3]);905 expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]);
848 expect(groups[1]!.entries.map((item) => item.number)).toEqual([1]);906 expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]);
849 });907 });
850908
851 it("lets the feed's copy of a partitioned row replace the partition's", () => {909 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 {...@@ -18,6 +18,9 @@ import type {
18 PullRequestListState,18 PullRequestListState,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
2020
21import { toSortableTimestamp } from "../../lib/threadSort";
22import type { PullRequestListSort } from "./pullRequestListPreferences";
23
21/**24/**
22 * A listed change request with the environment that read it. Nothing on a row says which machine25 * A listed change request with the environment that read it. Nothing on a row says which machine
23 * it came from, and the page unions every connected one — so acting on a row, refreshing it, or26 * 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>(...@@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement<Entry extends ScopedEntry>(
415 buckets.others.push(entry);418 buckets.others.push(entry);
416 }419 }
417 }420 }
418 return (["reviewRequested", "authored", "others"] as const)421 return (["authored", "reviewRequested", "others"] as const)
419 .filter((key) => buckets[key].length > 0)422 .filter((key) => buckets[key].length > 0)
420 .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] }));423 .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] }));
421}424}
...@@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority<Entry extends PullRequestListE...@@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority<Entry extends PullRequestListE
615 const byRecency = (left: Entry, right: Entry) => right.updatedAt.localeCompare(left.updatedAt);618 const byRecency = (left: Entry, right: Entry) => right.updatedAt.localeCompare(left.updatedAt);
616 return (619 return (
617 [620 [
618 { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) },
619 { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) },621 { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) },
622 { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) },
620 { key: "others", entries: others },623 { key: "others", entries: others },
621 ] as const624 ] as const
622 )625 )
...@@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn...@@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn
1027 });1030 });
1028}1031}
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
1030/**1072/**
1031 * A row with the line counts that arrived after it did. Only where the host left them out — a1073 * A row with the line counts that arrived after it did. Only where the host left them out — a
1032 * listing that carried them is not second-guessed — and only where they have arrived, since a row1074 * 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 {...@@ -58,7 +58,7 @@ import {
58 pullRequestEntryKey,58 pullRequestEntryKey,
59 pullRequestEntryViewer,59 pullRequestEntryViewer,
60 rankPullRequestMatches,60 rankPullRequestMatches,
61 rankPullRequestsByMergeReadiness,61 sortPullRequestGroups,
62 pullRequestEnvironmentSetKey,62 pullRequestEnvironmentSetKey,
63 readPullRequestListSnapshot,63 readPullRequestListSnapshot,
64 resolveProjectScope,64 resolveProjectScope,
...@@ -120,7 +120,6 @@ import { SidebarInset } from "../components/ui/sidebar";...@@ -120,7 +120,6 @@ import { SidebarInset } from "../components/ui/sidebar";
120import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";120import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
121import { useLiveRefresh } from "../hooks/useLiveRefresh";121import { useLiveRefresh } from "../hooks/useLiveRefresh";
122import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";122import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
123import { toSortableTimestamp } from "../lib/threadSort";
124import {123import {
125 pullRequestSurfaceId,124 pullRequestSurfaceId,
126 selectActiveRightPanelSurface,125 selectActiveRightPanelSurface,
...@@ -1426,51 +1425,16 @@ function PullRequestsRouteView() {...@@ -1426,51 +1425,16 @@ function PullRequestsRouteView() {
1426 ...group,1425 ...group,
1427 entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)),1426 entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)),
1428 }));1427 }));
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 }
1443 // Searching keeps its relevance order and priority groups unless the reader explicitly asks1428 // Searching keeps its relevance order and priority groups unless the reader explicitly asks
1444 // for another sort. The readiness queue is the default browse order, not a way to bury a1429 // for another sort. The readiness queue is the default browse order, not a way to bury a
1445 // closer text match.1430 // closer text match.
1446 if (sort === "ready" || sort === "updated") return enriched;1431 return sortPullRequestGroups(
1447 const entries = enriched.flatMap((group) => group.entries);1432 enriched,
1448 const hasSize = (entry: (typeof entries)[number]) =>1433 sort,
1449 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry));1434 typedParsed.text,
1450 const timestamp = (entry: (typeof entries)[number]) =>1435 (entry) =>
1451 toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0;1436 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
1452 return [1437 );
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 ];
1474 }, [groups, sort, statsByRow, typedParsed.text]);1438 }, [groups, sort, statsByRow, typedParsed.text]);
14751439
1476 const linkedSelection = useMemo(1440 const linkedSelection = useMemo(
docs/user/source-control.md+4-3
...@@ -41,9 +41,10 @@ T3 Code works with the platforms your team already uses:...@@ -41,9 +41,10 @@ T3 Code works with the platforms your team already uses:
4141
42- See if your current branch already has an open PR/MR42- See if your current branch already has an open PR/MR
43- Open several reviews from the **Pull requests** page as tabs in the right panel43- 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, and44- Your authored reviews stay at the top and use the selected sort within their group. By default,
45 conflicting reviews last. Smaller changes come first within each readiness group, and finished45 see passing and approved reviews first, passing reviews awaiting approval next, and conflicting
46 reviews follow open work when all states are visible.46 reviews last. Smaller changes come first within each readiness group, and finished reviews follow
47 open work when all states are visible.
47- Filter the list by author or labels, rank authors by merges in the loaded results, see label and48- Filter the list by author or labels, rank authors by merges in the loaded results, see label and
48 change-size context on each row, and sort the results currently shown by readiness, update time,49 change-size context on each row, and sort the results currently shown by readiness, update time,
49 creation time, or change size. Your filters, search, scope, and sort are restored when you return.50 creation time, or change size. Your filters, search, scope, and sort are restored when you return.