| author | |
| committer | |
| log | 645d58547d282eb2aaf6c48e5907625fc112386a |
| tree | 03dcf97cf11ee248de3d17c795e493af373955f7 |
| parent | 21b9dda5afb00a33e228a68d2ccc885bba7285dc |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 23 | rankPullRequestMatches, |
| 24 | 24 | rankPullRequestsByMergeReadiness, |
| 25 | 25 | scorePullRequestMatch, |
| 26 | sortPullRequestGroups, | |
| 26 | 27 | retainVisiblePullRequestStatsBatches, |
| 27 | 28 | withDiffStat, |
| 28 | 29 | resolveProjectScope, |
| ... | ... | @@ -328,8 +329,8 @@ describe("pull request grouping", () => { |
| 328 | 329 | VIEWERS, |
| 329 | 330 | ); |
| 330 | 331 | expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ |
| 331 | ["reviewRequested", 1], | |
| 332 | 332 | ["authored", 1], |
| 333 | ["reviewRequested", 1], | |
| 333 | 334 | ]); |
| 334 | 335 | }); |
| 335 | 336 | |
| ... | ... | @@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => { |
| 753 | 754 | rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), |
| 754 | 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 | }); |
| 757 | 815 | |
| 758 | 816 | describe("line counts that arrive after the rows", () => { |
| ... | ... | @@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => { |
| 843 | 901 | updatedAt: "2026-06-02T00:00:00Z", |
| 844 | 902 | }); |
| 845 | 903 | 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]); | |
| 849 | 907 | }); |
| 850 | 908 | |
| 851 | 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 | 18 | PullRequestListState, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | 20 | |
| 21 | import { toSortableTimestamp } from "../../lib/threadSort"; | |
| 22 | import type { PullRequestListSort } from "./pullRequestListPreferences"; | |
| 23 | ||
| 21 | 24 | /** |
| 22 | 25 | * A listed change request with the environment that read it. Nothing on a row says which machine |
| 23 | 26 | * 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 | 418 | buckets.others.push(entry); |
| 416 | 419 | } |
| 417 | 420 | } |
| 418 | return (["reviewRequested", "authored", "others"] as const) | |
| 421 | return (["authored", "reviewRequested", "others"] as const) | |
| 419 | 422 | .filter((key) => buckets[key].length > 0) |
| 420 | 423 | .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); |
| 421 | 424 | } |
| ... | ... | @@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority<Entry extends PullRequestListE |
| 615 | 618 | const byRecency = (left: Entry, right: Entry) => right.updatedAt.localeCompare(left.updatedAt); |
| 616 | 619 | return ( |
| 617 | 620 | [ |
| 618 | { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, | |
| 619 | 621 | { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) }, |
| 622 | { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, | |
| 620 | 623 | { key: "others", entries: others }, |
| 621 | 624 | ] as const |
| 622 | 625 | ) |
| ... | ... | @@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn |
| 1027 | 1030 | }); |
| 1028 | 1031 | } |
| 1029 | 1032 | |
| 1033 | /** Keeps authored work first while applying the selected ordering inside every involvement group. */ | |
| 1034 | export 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 | 1073 | * A row with the line counts that arrived after it did. Only where the host left them out — a |
| 1032 | 1074 | * 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 | 58 | pullRequestEntryKey, |
| 59 | 59 | pullRequestEntryViewer, |
| 60 | 60 | rankPullRequestMatches, |
| 61 | rankPullRequestsByMergeReadiness, | |
| 61 | sortPullRequestGroups, | |
| 62 | 62 | pullRequestEnvironmentSetKey, |
| 63 | 63 | readPullRequestListSnapshot, |
| 64 | 64 | resolveProjectScope, |
| ... | ... | @@ -120,7 +120,6 @@ import { SidebarInset } from "../components/ui/sidebar"; |
| 120 | 120 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; |
| 121 | 121 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; |
| 122 | 122 | import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; |
| 123 | import { toSortableTimestamp } from "../lib/threadSort"; | |
| 124 | 123 | import { |
| 125 | 124 | pullRequestSurfaceId, |
| 126 | 125 | selectActiveRightPanelSurface, |
| ... | ... | @@ -1426,51 +1425,16 @@ function PullRequestsRouteView() { |
| 1426 | 1425 | ...group, |
| 1427 | 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 | 1428 | // Searching keeps its relevance order and priority groups unless the reader explicitly asks |
| 1444 | 1429 | // for another sort. The readiness queue is the default browse order, not a way to bury a |
| 1445 | 1430 | // 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 | ); | |
| 1474 | 1438 | }, [groups, sort, statsByRow, typedParsed.text]); |
| 1475 | 1439 | |
| 1476 | 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 | 41 | |
| 42 | 42 | - See if your current branch already has an open PR/MR |
| 43 | 43 | - 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. | |
| 47 | 48 | - Filter the list by author or labels, rank authors by merges in the loaded results, see label and |
| 48 | 49 | change-size context on each row, and sort the results currently shown by readiness, update time, |
| 49 | 50 | creation time, or change size. Your filters, search, scope, and sort are restored when you return. |