| 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,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 | }); |
| 335 | 336 | ||
| ... | @@ -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 | }); |
| 757 | 815 | ||
| 758 | describe("line counts that arrive after the rows", () => { | 816 | describe("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 | }); |
| 850 | 908 | ||
| 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"; |
| 20 | 20 | ||
| 21 | import { toSortableTimestamp } from "../../lib/threadSort"; | ||
| 22 | import type { PullRequestListSort } from "./pullRequestListPreferences"; | ||
| 23 | |||
| 21 | /** | 24 | /** |
| 22 | * A listed change request with the environment that read it. Nothing on a row says which machine | 25 | * 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, or | 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,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 const | 624 | ] 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 | } |
| 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 | * A row with the line counts that arrived after it did. Only where the host left them out — a | 1073 | * 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 row | 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,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"; |
| 120 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; | 120 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; |
| 121 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; | 121 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; |
| 122 | import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; | 122 | import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; |
| 123 | import { toSortableTimestamp } from "../lib/threadSort"; | ||
| 124 | import { | 123 | import { |
| 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 asks | 1428 | // 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 a | 1429 | // 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]); |
| 1475 | 1439 | ||
| 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: |
| 41 | 41 | ||
| 42 | - See if your current branch already has an open PR/MR | 42 | - 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 panel | 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 | 44 | - 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 finished | 45 | 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 and | 48 | - 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. |