Unified · Split
| @@ -416,14 +416,6 @@ export function HomeScreen(props: HomeScreenProps) { · 415 unmodified lines | ||
| 416 | 416 | [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], |
| 417 | 417 | ); |
| 418 | 418 | |
| 419 | − const projectCwdByKey = useMemo(() => { | |
| 420 | − const map = new Map<string, string>(); | |
| 421 | − for (const project of props.projects) { | |
| 422 | − map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | |
| 423 | − } | |
| 424 | − return map; | |
| 425 | − }, [props.projects]); | |
| 426 | − | |
| 427 | 419 | const projectByKey = useMemo(() => { |
| 428 | 420 | const map = new Map<string, EnvironmentProject>(); |
| 429 | 421 | for (const project of props.projects) { |
| @@ -850,9 +842,6 @@ export function HomeScreen(props: HomeScreenProps) { · 420 unmodified lines | ||
| 850 | 842 | onPinThread={handlePinThread} |
| 851 | 843 | onUnpinThread={handleUnpinThread} |
| 852 | 844 | onMovePinnedThread={handleMovePinnedThread} |
| 853 | − projectCwd={ | |
| 854 | − projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null | |
| 855 | − } | |
| 856 | 845 | onSwipeableClose={handleSwipeableClose} |
| 857 | 846 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| 858 | 847 | /> |
| @@ -875,7 +864,6 @@ export function HomeScreen(props: HomeScreenProps) { · 16 unmodified lines | ||
| 875 | 864 | machineByEnvironmentId, |
| 876 | 865 | pinReorderEnvironmentIds, |
| 877 | 866 | projectByKey, |
| 878 | − projectCwdByKey, | |
| 879 | 867 | props.onArchiveThread, |
| 880 | 868 | props.onDeletePendingTask, |
| 881 | 869 | props.onSelectPendingTask, |
| @@ -903,7 +891,6 @@ export function HomeScreen(props: HomeScreenProps) { · 21 unmodified lines | ||
| 903 | 891 | const v2ExtraData = useMemo( |
| 904 | 892 | () => ({ |
| 905 | 893 | projectByKey, |
| 906 | − projectCwdByKey, | |
| 907 | 894 | projectTitleByProjectKey: v2ProjectTitleByProjectKey, |
| 908 | 895 | serverConfigs, |
| 909 | 896 | savedConnectionsById: props.savedConnectionsById, |
| @@ -913,7 +900,6 @@ export function HomeScreen(props: HomeScreenProps) { · 3 unmodified lines | ||
| 913 | 900 | }), |
| 914 | 901 | [ |
| 915 | 902 | projectByKey, |
| 916 | − projectCwdByKey, | |
| 917 | 903 | props.searchQuery, |
| 918 | 904 | props.savedConnectionsById, |
| 919 | 905 | serverConfigs, |
| @@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) { · 5 unmodified lines | ||
| 925 | 911 | |
| 926 | 912 | const extraData = useMemo( |
| 927 | 913 | () => ({ |
| 928 | − projectCwdByKey, | |
| 929 | 914 | savedConnectionsById: props.savedConnectionsById, |
| 930 | 915 | searchQuery: props.searchQuery, |
| 931 | 916 | threadSearchMatchByKey, |
| 932 | 917 | }), |
| 933 | − [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], | |
| 918 | + [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], | |
| 934 | 919 | ); |
| 935 | 920 | |
| 936 | 921 | const renderItem = useCallback( |
| @@ -982,10 +967,6 @@ export function HomeScreen(props: HomeScreenProps) { · 45 unmodified lines | ||
| 982 | 967 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 983 | 968 | } |
| 984 | 969 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} |
| 985 | − projectCwd={ | |
| 986 | − projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | |
| 987 | − null | |
| 988 | − } | |
| 989 | 970 | isLast={item.isLast} |
| 990 | 971 | searchMatch={threadSearchMatchByKey.get( |
| 991 | 972 | threadSearchMatchKey({ |
| @@ -1021,7 +1002,6 @@ export function HomeScreen(props: HomeScreenProps) { · 29 unmodified lines | ||
| 1021 | 1002 | handleSwipeableWillOpen, |
| 1022 | 1003 | handleRegenerateThreadTitle, |
| 1023 | 1004 | machineByEnvironmentId, |
| 1024 | − projectCwdByKey, | |
| 1025 | 1005 | props.onArchiveThread, |
| 1026 | 1006 | props.onDeletePendingTask, |
| 1027 | 1007 | props.onDeleteThread, |
| @@ -12,10 +12,8 @@ import { · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 14 | 14 | import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; |
| 15 | −import { useProject, useThreadShell } from "../../state/entities"; | |
| 16 | −import { useEnvironmentQuery } from "../../state/query"; | |
| 15 | +import { useThreadShell } from "../../state/entities"; | |
| 17 | 16 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; |
| 18 | −import { vcsEnvironment } from "../../state/vcs"; | |
| 19 | 17 | import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; |
| 20 | 18 | import { |
| 21 | 19 | dispatchHardwareKeyboardCommand, |
| @@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({ · 18 unmodified lines | ||
| 40 | 38 | const navigation = useNavigation(); |
| 41 | 39 | const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); |
| 42 | 40 | const activeThread = useThreadShell(activeThreadRef); |
| 43 | − const activeProjectRef = useMemo( | |
| 44 | − () => | |
| 45 | − activeThread === null | |
| 46 | − ? null | |
| 47 | − : { | |
| 48 | − environmentId: activeThread.environmentId, | |
| 49 | − projectId: activeThread.projectId, | |
| 50 | − }, | |
| 51 | − [activeThread], | |
| 52 | − ); | |
| 53 | − const activeProject = useProject(activeProjectRef); | |
| 54 | − const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; | |
| 55 | − const gitStatus = useEnvironmentQuery( | |
| 56 | − activeThread !== null && | |
| 57 | − activeThread.linkedPullRequest == null && | |
| 58 | − activeThread.branch !== null && | |
| 59 | − activeThreadCwd !== null | |
| 60 | − ? vcsEnvironment.status({ | |
| 61 | − environmentId: activeThread.environmentId, | |
| 62 | − input: { cwd: activeThreadCwd }, | |
| 63 | − }) | |
| 64 | − : null, | |
| 65 | − ).data; | |
| 66 | − const detectedPullRequestUrl = | |
| 67 | − activeThread?.branch != null && gitStatus?.refName === activeThread.branch | |
| 68 | − ? (gitStatus.pr?.url ?? null) | |
| 69 | − : null; | |
| 70 | 41 | const copyTarget = useMemo( |
| 71 | 42 | () => |
| 72 | 43 | activeThreadRef === null |
| 73 | 44 | ? null |
| 74 | 45 | : resolveThreadReferenceCopyTarget({ |
| 75 | 46 | threadId: activeThread?.id ?? activeThreadRef.threadId, |
| 76 | − linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, | |
| 77 | − detectedPullRequestUrl, | |
| 47 | + linkedPullRequestUrl: | |
| 48 | + (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, | |
| 78 | 49 | }), |
| 79 | − [activeThread, activeThreadRef, detectedPullRequestUrl], | |
| 50 | + [activeThread, activeThreadRef], | |
| 80 | 51 | ); |
| 81 | 52 | const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK); |
| 82 | 53 | const copyRequestIdRef = useRef(0); |
| @@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane( · 352 unmodified lines | ||
| 353 | 353 | }), |
| 354 | 354 | [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], |
| 355 | 355 | ); |
| 356 | − const projectCwdByKey = useMemo(() => { | |
| 357 | − const map = new Map<string, string>(); | |
| 358 | − for (const project of projects) { | |
| 359 | − map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); | |
| 360 | − } | |
| 361 | − return map; | |
| 362 | − }, [projects]); | |
| 363 | 356 | const projectByKey = useMemo(() => { |
| 364 | 357 | const map = new Map<string, EnvironmentProject>(); |
| 365 | 358 | for (const project of projects) { |
| @@ -742,7 +735,6 @@ function ThreadNavigationSidebarPane( · 376 unmodified lines | ||
| 742 | 735 | () => ({ |
| 743 | 736 | selectedThreadKey: props.selectedThreadKey ?? "", |
| 744 | 737 | projectByKey, |
| 745 | − projectCwdByKey, | |
| 746 | 738 | projectTitleByProjectKey, |
| 747 | 739 | savedConnectionsById, |
| 748 | 740 | serverConfigs, |
| @@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane( · 3 unmodified lines | ||
| 752 | 744 | [ |
| 753 | 745 | props.selectedThreadKey, |
| 754 | 746 | projectByKey, |
| 755 | − projectCwdByKey, | |
| 756 | 747 | projectTitleByProjectKey, |
| 757 | 748 | savedConnectionsById, |
| 758 | 749 | serverConfigs, |
| @@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane( · 155 unmodified lines | ||
| 914 | 905 | onPinThread={pinThread} |
| 915 | 906 | onUnpinThread={unpinThread} |
| 916 | 907 | onMovePinnedThread={movePinnedThread} |
| 917 | − projectCwd={projectCwdByKey.get(scopeKey) ?? null} | |
| 918 | 908 | onSwipeableClose={handleSwipeableClose} |
| 919 | 909 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| 920 | 910 | simultaneousSwipeGesture={sidebarScrollGesture} |
| @@ -1000,10 +990,6 @@ function ThreadNavigationSidebarPane( · 79 unmodified lines | ||
| 1000 | 990 | savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 1001 | 991 | } |
| 1002 | 992 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} |
| 1003 | − projectCwd={ | |
| 1004 | − projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? | |
| 1005 | − null | |
| 1006 | − } | |
| 1007 | 993 | isLast={item.isLast} |
| 1008 | 994 | searchMatch={threadSearchMatchByKey.get( |
| 1009 | 995 | threadSearchMatchKey({ |
| @@ -1054,7 +1040,6 @@ function ThreadNavigationSidebarPane( · 44 unmodified lines | ||
| 1054 | 1040 | pinThread, |
| 1055 | 1041 | pinningEnvironmentIds, |
| 1056 | 1042 | projectByKey, |
| 1057 | − projectCwdByKey, | |
| 1058 | 1043 | projectTitleByProjectKey, |
| 1059 | 1044 | regenerateThreadTitle, |
| 1060 | 1045 | props.onNewThreadInProject, |
| @@ -416,7 +416,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: { · 415 unmodified lines | ||
| 416 | 416 | readonly thread: EnvironmentThreadShell; |
| 417 | 417 | readonly environmentLabel: string | null; |
| 418 | 418 | readonly environmentMachine?: EnvironmentMachineKind; |
| 419 | − readonly projectCwd: string | null; | |
| 420 | 419 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 421 | 420 | readonly searchQuery?: string; |
| 422 | 421 | readonly isLast: boolean; |
| @@ -453,7 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { · 30 unmodified lines | ||
| 453 | 452 | const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = |
| 454 | 453 | props; |
| 455 | 454 | const status = resolveThreadStatus(thread); |
| 456 | − const pr = useThreadPr(thread, props.projectCwd); | |
| 455 | + const pr = useThreadPr(thread); | |
| 457 | 456 | const timestamp = relativeTime( |
| 458 | 457 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, |
| 459 | 458 | ); |
| @@ -377,7 +377,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 376 unmodified lines | ||
| 377 | 377 | readonly canMovePinnedDown?: boolean; |
| 378 | 378 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 379 | 379 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 380 | − readonly projectCwd?: string | null; | |
| 381 | 380 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 382 | 381 | readonly searchQuery?: string; |
| 383 | 382 | readonly simultaneousSwipeGesture?: ComponentProps< |
| @@ -403,7 +402,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 19 unmodified lines | ||
| 403 | 402 | const snoozedRow = props.snoozed === true; |
| 404 | 403 | const pinnedRow = props.pinned === true; |
| 405 | 404 | |
| 406 | − const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); | |
| 405 | + const pr = useThreadPr(thread); | |
| 407 | 406 | |
| 408 | 407 | const theme = useUniwindTheme(); |
| 409 | 408 | const screenColor = theme["--color-screen"]; |
| @@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime"; · 11 unmodified lines | ||
| 12 | 12 | import { appAtomRegistry } from "./atom-registry"; |
| 13 | 13 | import { useEnvironmentQuery } from "./query"; |
| 14 | 14 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; |
| 15 | −import { vcsEnvironment } from "./vcs"; | |
| 16 | 15 | |
| 17 | −const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | |
| 16 | +const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | |
| 18 | 17 | const MAX_THREAD_PR_SNAPSHOTS = 500; |
| 19 | 18 | |
| 20 | 19 | interface ThreadPrSnapshot { |
| @@ -23,7 +22,7 @@ interface ThreadPrSnapshot { · 2 unmodified lines | ||
| 23 | 22 | } |
| 24 | 23 | |
| 25 | 24 | // One bounded cache survives row virtualization without retaining one live |
| 26 | −// atom for every thread, branch, directory, or linked pull request ever seen. | |
| 25 | +// atom for every thread or pull request ever seen. | |
| 27 | 26 | const threadPrSnapshotsAtom = Atom.make<ReadonlyMap<string, ThreadPrSnapshot>>(new Map()).pipe( |
| 28 | 27 | Atom.keepAlive, |
| 29 | 28 | Atom.withLabel("mobile:thread-pr-snapshots"), |
| @@ -36,20 +35,13 @@ export { · 6 unmodified lines | ||
| 36 | 35 | } from "./thread-pr-presentation"; |
| 37 | 36 | |
| 38 | 37 | /** |
| 39 | − * Live PR status for a thread's branch. Subscriptions are deduplicated per | |
| 40 | − * (environmentId, cwd) by the atom family, so many rows on the same worktree | |
| 41 | − * or project root share one stream — and virtualization means only visible | |
| 42 | − * rows subscribe at all. | |
| 38 | + * Live status for a thread's server-provided PR. Visible rows share a summary | |
| 39 | + * request for the same PR in the same environment. | |
| 43 | 40 | */ |
| 44 | −export function useThreadPr( | |
| 45 | − thread: EnvironmentThreadShell, | |
| 46 | − projectCwd: string | null, | |
| 47 | −): ThreadPrPresentation | null { | |
| 48 | − const cwd = thread.worktreePath ?? projectCwd; | |
| 41 | +export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { | |
| 42 | + const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; | |
| 49 | 43 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); |
| 50 | − const snapshotIdentity = JSON.stringify( | |
| 51 | − thread.linkedPullRequest ?? { branch: thread.branch, cwd }, | |
| 52 | − ); | |
| 44 | + const snapshotIdentity = JSON.stringify(pullRequestRef); | |
| 53 | 45 | // Select this row's entry so writes for other rows do not re-render it. |
| 54 | 46 | const snapshotEntry = useAtomValue( |
| 55 | 47 | threadPrSnapshotsAtom, |
| @@ -59,45 +51,30 @@ export function useThreadPr( · 3 unmodified lines | ||
| 59 | 51 | ), |
| 60 | 52 | ); |
| 61 | 53 | const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; |
| 62 | − const gitStatus = useEnvironmentQuery( | |
| 63 | − thread.linkedPullRequest == null && thread.branch !== null && cwd !== null | |
| 64 | − ? vcsEnvironment.status({ | |
| 65 | − environmentId: thread.environmentId, | |
| 66 | − input: { cwd }, | |
| 67 | − }) | |
| 68 | − : null, | |
| 69 | − ); | |
| 70 | − const linkedPullRequest = useEnvironmentQuery( | |
| 71 | − thread.linkedPullRequest == null | |
| 54 | + const pullRequestSummary = useEnvironmentQuery( | |
| 55 | + pullRequestRef === null | |
| 72 | 56 | ? null |
| 73 | − : linkedPullRequestDetailAtom({ | |
| 57 | + : pullRequestSummaryAtom({ | |
| 74 | 58 | environmentId: thread.environmentId, |
| 75 | 59 | input: { |
| 76 | − projectId: thread.linkedPullRequest.projectId, | |
| 77 | − repository: thread.linkedPullRequest.repository, | |
| 78 | − number: thread.linkedPullRequest.number, | |
| 60 | + projectId: pullRequestRef.projectId, | |
| 61 | + repository: pullRequestRef.repository, | |
| 62 | + number: pullRequestRef.number, | |
| 79 | 63 | }, |
| 80 | 64 | }), |
| 81 | 65 | ); |
| 82 | 66 | |
| 83 | 67 | const live = useMemo<ThreadPrPresentation | null | undefined>(() => { |
| 84 | − if (thread.linkedPullRequest != null) { | |
| 85 | − const detail = linkedPullRequest.data; | |
| 86 | − return detail === null | |
| 87 | − ? undefined | |
| 88 | − : presentThreadPr(pullRequestDetailToVcsStatus(detail), { | |
| 89 | − kind: detail.provider, | |
| 90 | − name: detail.provider, | |
| 91 | − baseUrl: "", | |
| 92 | − }); | |
| 93 | − } | |
| 94 | − | |
| 95 | − const status = gitStatus.data; | |
| 96 | − if (thread.branch === null) return null; | |
| 97 | − if (status === null) return undefined; | |
| 98 | − if (status.refName !== thread.branch || !status.pr) return null; | |
| 99 | − return presentThreadPr(status.pr, status.sourceControlProvider); | |
| 100 | − }, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]); | |
| 68 | + if (pullRequestRef === null) return null; | |
| 69 | + const summary = pullRequestSummary.data; | |
| 70 | + return summary === null | |
| 71 | + ? undefined | |
| 72 | + : presentThreadPr(pullRequestDetailToVcsStatus(summary), { | |
| 73 | + kind: summary.provider, | |
| 74 | + name: summary.provider, | |
| 75 | + baseUrl: "", | |
| 76 | + }); | |
| 77 | + }, [pullRequestRef, pullRequestSummary.data]); | |
| 101 | 78 | |
| 102 | 79 | useEffect(() => { |
| 103 | 80 | if (live === undefined) return; |
| @@ -55,6 +55,7 @@ function threadDetailToShell( · 54 unmodified lines | ||
| 55 | 55 | branch: thread.branch, |
| 56 | 56 | worktreePath: thread.worktreePath, |
| 57 | 57 | linkedPullRequest: thread.linkedPullRequest ?? null, |
| 58 | + branchPullRequest: thread.branchPullRequest ?? null, | |
| 58 | 59 | latestTurn: thread.latestTurn, |
| 59 | 60 | createdAt: thread.createdAt, |
| 60 | 61 | updatedAt: thread.updatedAt, |
| @@ -67,6 +67,7 @@ import { · 66 unmodified lines | ||
| 67 | 67 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; |
| 68 | 68 | import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; |
| 69 | 69 | import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; |
| 70 | +import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; | |
| 70 | 71 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; |
| 71 | 72 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 72 | 73 | import { |
| @@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = ( · 321 unmodified lines | ||
| 394 | 395 | drainThrough: () => Effect.void, |
| 395 | 396 | }), |
| 396 | 397 | ), |
| 398 | + Layer.provideMerge( | |
| 399 | + Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { | |
| 400 | + start: () => Effect.void, | |
| 401 | + drain: Effect.void, | |
| 402 | + }), | |
| 403 | + ), | |
| 397 | 404 | Layer.provideMerge( |
| 398 | 405 | Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { |
| 399 | 406 | start: () => Effect.void, |
| @@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; · 12 unmodified lines | ||
| 13 | 13 | import * as Option from "effect/Option"; |
| 14 | 14 | import * as PlatformError from "effect/PlatformError"; |
| 15 | 15 | import * as References from "effect/References"; |
| 16 | +import * as Schema from "effect/Schema"; | |
| 16 | 17 | import * as Scope from "effect/Scope"; |
| 17 | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | import { expect } from "vite-plus/test"; |
| @@ -30,10 +31,13 @@ import { · 11 unmodified lines | ||
| 30 | 31 | TextGenerationError, |
| 31 | 32 | } from "@t3tools/contracts"; |
| 32 | 33 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 34 | +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | |
| 33 | 35 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 34 | 36 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 35 | 37 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 36 | 38 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; |
| 39 | +import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; | |
| 40 | +import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; | |
| 37 | 41 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 38 | 42 | import * as ServerConfig from "../config.ts"; |
| 39 | 43 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| @@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; · 1 unmodified line | ||
| 41 | 45 | import * as ServerSettings from "../serverSettings.ts"; |
| 42 | 46 | import * as GitManager from "./GitManager.ts"; |
| 43 | 47 | |
| 48 | +const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 49 | + | |
| 44 | 50 | interface FakeGhScenario { |
| 45 | 51 | prListSequence?: string[]; |
| 46 | 52 | prListByHeadSelector?: Record<string, string>; |
| @@ -620,6 +626,7 @@ function preparePullRequestThread( · 573 unmodified lines | ||
| 620 | 626 | |
| 621 | 627 | function makeManager(input?: { |
| 622 | 628 | ghScenario?: FakeGhScenario; |
| 629 | + sourceControlProvider?: SourceControlProvider["Service"]; | |
| 623 | 630 | textGeneration?: Partial<FakeGitTextGeneration>; |
| 624 | 631 | serverSettings?: Parameters<typeof ServerSettings.layerTest>[0]; |
| 625 | 632 | setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; |
| @@ -659,7 +666,10 @@ function makeManager(input?: { · 33 unmodified lines | ||
| 659 | 666 | ); |
| 660 | 667 | const sourceControlRegistryLayer = Layer.effect( |
| 661 | 668 | SourceControlProviderRegistry.SourceControlProviderRegistry, |
| 662 | − GitHubSourceControlProvider.make.pipe( | |
| 669 | + (input?.sourceControlProvider === undefined | |
| 670 | + ? GitHubSourceControlProvider.make | |
| 671 | + : Effect.succeed(input.sourceControlProvider) | |
| 672 | + ).pipe( | |
| 663 | 673 | Effect.map((provider) => |
| 664 | 674 | SourceControlProviderRegistry.SourceControlProviderRegistry.of({ |
| 665 | 675 | get: () => Effect.succeed(provider), |
| @@ -1146,7 +1156,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 480 unmodified lines | ||
| 1146 | 1156 | branch: "feature/saved-branch", |
| 1147 | 1157 | }); |
| 1148 | 1158 | |
| 1149 | − expect(pullRequest).toEqual({ | |
| 1159 | + expect(pullRequest).toMatchObject({ | |
| 1160 | + number: 216, | |
| 1161 | + title: "Saved branch PR", | |
| 1162 | + url: "https://github.com/pingdotgg/t3code/pull/216", | |
| 1163 | + baseRef: "main", | |
| 1164 | + headRef: "feature/saved-branch", | |
| 1150 | 1165 | state: "open", |
| 1151 | 1166 | closedAt: null, |
| 1152 | 1167 | mergedAt: null, |
| @@ -1191,7 +1206,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 38 unmodified lines | ||
| 1191 | 1206 | |
| 1192 | 1207 | const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); |
| 1193 | 1208 | |
| 1194 | − expect(pullRequest).toEqual({ | |
| 1209 | + expect(pullRequest).toMatchObject({ | |
| 1195 | 1210 | state: "merged", |
| 1196 | 1211 | closedAt: null, |
| 1197 | 1212 | mergedAt: "2026-04-07T15:00:00Z", |
| @@ -1244,7 +1259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 46 unmodified lines | ||
| 1244 | 1259 | branch: "feature/deleted-local-branch", |
| 1245 | 1260 | }); |
| 1246 | 1261 | |
| 1247 | − expect(pullRequest).toEqual({ | |
| 1262 | + expect(pullRequest).toMatchObject({ | |
| 1248 | 1263 | state: "merged", |
| 1249 | 1264 | closedAt: null, |
| 1250 | 1265 | mergedAt: null, |
| @@ -1310,7 +1325,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 59 unmodified lines | ||
| 1310 | 1325 | branch: "feature/deleted-fork-branch", |
| 1311 | 1326 | }); |
| 1312 | 1327 | |
| 1313 | − expect(pullRequest).toEqual({ | |
| 1328 | + expect(pullRequest).toMatchObject({ | |
| 1314 | 1329 | state: "merged", |
| 1315 | 1330 | closedAt: null, |
| 1316 | 1331 | mergedAt: null, |
| @@ -1432,6 +1447,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 115 unmodified lines | ||
| 1432 | 1447 | updatedAt: "2026-04-07T15:00:00Z", |
| 1433 | 1448 | }, |
| 1434 | 1449 | ]), |
| 1450 | + encodeCliJson([ | |
| 1451 | + { | |
| 1452 | + number: 221, | |
| 1453 | + title: "New PR on the same branch", | |
| 1454 | + url: "https://github.com/pingdotgg/codething-mvp/pull/221", | |
| 1455 | + baseRefName: "main", | |
| 1456 | + headRefName: "feature/shared-pr-cache", | |
| 1457 | + state: "OPEN", | |
| 1458 | + updatedAt: "2026-04-08T15:00:00Z", | |
| 1459 | + }, | |
| 1460 | + ]), | |
| 1435 | 1461 | ], |
| 1436 | 1462 | }, |
| 1437 | 1463 | }); |
| @@ -1445,6 +1471,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 7 unmodified lines | ||
| 1445 | 1471 | expect(status.pr?.state).toBe("merged"); |
| 1446 | 1472 | expect(pullRequest?.state).toBe("merged"); |
| 1447 | 1473 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); |
| 1474 | + const refreshed = yield* manager.branchPullRequest( | |
| 1475 | + { cwd: repoDir, branch: "feature/shared-pr-cache" }, | |
| 1476 | + { refresh: true }, | |
| 1477 | + ); | |
| 1478 | + expect(refreshed).toMatchObject({ | |
| 1479 | + number: 221, | |
| 1480 | + state: "open", | |
| 1481 | + repositoryKey: "github.com/pingdotgg/codething-mvp", | |
| 1482 | + }); | |
| 1483 | + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); | |
| 1448 | 1484 | }), |
| 1449 | 1485 | ); |
| 1450 | 1486 | |
| @@ -1459,7 +1495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 8 unmodified lines | ||
| 1459 | 1495 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); |
| 1460 | 1496 | yield* runGit(repoDir, ["checkout", "main"]); |
| 1461 | 1497 | |
| 1462 | − const { manager } = yield* makeManager({ | |
| 1498 | + const { manager, ghCalls } = yield* makeManager({ | |
| 1463 | 1499 | ghScenario: { |
| 1464 | 1500 | failWith: new GitHubCli.GitHubCliUnavailableError({ |
| 1465 | 1501 | command: "gh", |
| @@ -1474,6 +1510,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 8 unmodified lines | ||
| 1474 | 1510 | .pipe(Effect.flip); |
| 1475 | 1511 | |
| 1476 | 1512 | expect(error._tag).toBe("SourceControlProviderError"); |
| 1513 | + const refreshError = yield* manager | |
| 1514 | + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true }) | |
| 1515 | + .pipe(Effect.flip); | |
| 1516 | + expect(refreshError._tag).toBe("SourceControlProviderError"); | |
| 1517 | + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); | |
| 1477 | 1518 | }), |
| 1478 | 1519 | ); |
| 1479 | 1520 | |
| @@ -1594,6 +1635,186 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 114 unmodified lines | ||
| 1594 | 1635 | expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); |
| 1595 | 1636 | }); |
| 1596 | 1637 | |
| 1638 | + it.each([ | |
| 1639 | + [ | |
| 1640 | + "https://github.example.com/team/repository/pull/42?tab=files", | |
| 1641 | + "github.example.com/team/repository", | |
| 1642 | + ], | |
| 1643 | + [ | |
| 1644 | + "https://gitlab.example.com/group/subgroup/repository/-/merge_requests/42", | |
| 1645 | + "gitlab.example.com/group/subgroup/repository", | |
| 1646 | + ], | |
| 1647 | + ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"], | |
| 1648 | + [ | |
| 1649 | + "https://dev.azure.com/org/project/_git/repository/pullrequest/42", | |
| 1650 | + "dev.azure.com/org/project/_git/repository", | |
| 1651 | + ], | |
| 1652 | + [ | |
| 1653 | + "https://org.visualstudio.com/project/_git/repository/pullrequest/42", | |
| 1654 | + "org.visualstudio.com/project/_git/repository", | |
| 1655 | + ], | |
| 1656 | + [ | |
| 1657 | + "https://gitlab.example/group/pull/123/repository/-/merge_requests/42", | |
| 1658 | + "gitlab.example/group/pull/123/repository", | |
| 1659 | + ], | |
| 1660 | + ["https://github.example.com/team/repository/issues/42", null], | |
| 1661 | + ] as const)("reads the repository from the returned PR URL %s", (url, expected) => { | |
| 1662 | + expect(GitManager.pullRequestRepositoryKey(url)).toBe(expected); | |
| 1663 | + }); | |
| 1664 | + | |
| 1665 | + it.effect("distinguishes Enterprise forks with the same head branch", () => | |
| 1666 | + Effect.gen(function* () { | |
| 1667 | + const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1668 | + yield* initRepo(repoDir); | |
| 1669 | + const originDir = yield* createBareRemote(); | |
| 1670 | + const forkDir = yield* createBareRemote(); | |
| 1671 | + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | |
| 1672 | + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 1673 | + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); | |
| 1674 | + yield* runGit(repoDir, ["checkout", "-b", "feature"]); | |
| 1675 | + yield* runGit(repoDir, ["push", "-u", "fork", "feature"]); | |
| 1676 | + yield* configureVisibleRemoteUrlWithLocalRewrite( | |
| 1677 | + repoDir, | |
| 1678 | + "origin", | |
| 1679 | + "git@github.example.com:team/repository.git", | |
| 1680 | + originDir, | |
| 1681 | + ); | |
| 1682 | + yield* configureVisibleRemoteUrlWithLocalRewrite( | |
| 1683 | + repoDir, | |
| 1684 | + "fork", | |
| 1685 | + "git@github.example.com:alice/repository.git", | |
| 1686 | + forkDir, | |
| 1687 | + ); | |
| 1688 | + const output = encodeCliJson([ | |
| 1689 | + { | |
| 1690 | + number: 2, | |
| 1691 | + title: "Another fork", | |
| 1692 | + url: "https://github.example.com/team/repository/pull/2", | |
| 1693 | + baseRefName: "main", | |
| 1694 | + headRefName: "feature", | |
| 1695 | + state: "OPEN", | |
| 1696 | + updatedAt: "2026-04-08T15:00:00Z", | |
| 1697 | + isCrossRepository: true, | |
| 1698 | + headRepository: { nameWithOwner: "bob/repository" }, | |
| 1699 | + headRepositoryOwner: { login: "bob" }, | |
| 1700 | + }, | |
| 1701 | + { | |
| 1702 | + number: 1, | |
| 1703 | + title: "This fork", | |
| 1704 | + url: "https://github.example.com/team/repository/pull/1", | |
| 1705 | + baseRefName: "main", | |
| 1706 | + headRefName: "feature", | |
| 1707 | + state: "OPEN", | |
| 1708 | + updatedAt: "2026-04-07T15:00:00Z", | |
| 1709 | + isCrossRepository: true, | |
| 1710 | + headRepository: { nameWithOwner: "alice/repository" }, | |
| 1711 | + headRepositoryOwner: { login: "alice" }, | |
| 1712 | + }, | |
| 1713 | + ]); | |
| 1714 | + const { manager } = yield* makeManager({ | |
| 1715 | + ghScenario: { | |
| 1716 | + prListByHeadSelector: { | |
| 1717 | + "alice:feature": output, | |
| 1718 | + "fork:feature": output, | |
| 1719 | + feature: output, | |
| 1720 | + }, | |
| 1721 | + }, | |
| 1722 | + }); | |
| 1723 | + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({ | |
| 1724 | + number: 1, | |
| 1725 | + repositoryKey: "github.example.com/team/repository", | |
| 1726 | + }); | |
| 1727 | + }), | |
| 1728 | + ); | |
| 1729 | + | |
| 1730 | + it.effect.each([ | |
| 1731 | + "git@gitlab.com:Group/Subgroup/Fork.git", | |
| 1732 | + "https://gitlab.com/Group/Subgroup/Fork.git", | |
| 1733 | + ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) => | |
| 1734 | + Effect.gen(function* () { | |
| 1735 | + const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1736 | + yield* initRepo(repoDir); | |
| 1737 | + const originDir = yield* createBareRemote(); | |
| 1738 | + const forkDir = yield* createBareRemote(); | |
| 1739 | + const branch = "feature/NestedGroups"; | |
| 1740 | + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | |
| 1741 | + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 1742 | + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); | |
| 1743 | + yield* runGit(repoDir, ["checkout", "-b", branch]); | |
| 1744 | + yield* runGit(repoDir, ["push", "-u", "fork", branch]); | |
| 1745 | + yield* configureVisibleRemoteUrlWithLocalRewrite( | |
| 1746 | + repoDir, | |
| 1747 | + "origin", | |
| 1748 | + "git@gitlab.com:Group/Upstream/Repository.git", | |
| 1749 | + originDir, | |
| 1750 | + ); | |
| 1751 | + yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir); | |
| 1752 | + const output = encodeCliJson([ | |
| 1753 | + { | |
| 1754 | + iid: 2, | |
| 1755 | + title: "Another subgroup's fork", | |
| 1756 | + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2", | |
| 1757 | + target_branch: "main", | |
| 1758 | + source_branch: branch, | |
| 1759 | + state: "opened", | |
| 1760 | + updated_at: "2026-04-08T15:00:00Z", | |
| 1761 | + source_project_id: 102, | |
| 1762 | + target_project_id: 100, | |
| 1763 | + source_project: { path_with_namespace: "Group/Other/Fork" }, | |
| 1764 | + }, | |
| 1765 | + { | |
| 1766 | + iid: 1, | |
| 1767 | + title: "This subgroup's fork", | |
| 1768 | + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1", | |
| 1769 | + target_branch: "main", | |
| 1770 | + source_branch: branch, | |
| 1771 | + state: "opened", | |
| 1772 | + updated_at: "2026-04-07T15:00:00Z", | |
| 1773 | + source_project_id: 101, | |
| 1774 | + target_project_id: 100, | |
| 1775 | + source_project: { path_with_namespace: "Group/Subgroup/Fork" }, | |
| 1776 | + }, | |
| 1777 | + ]); | |
| 1778 | + const calls: VcsProcess.VcsProcessInput[] = []; | |
| 1779 | + const provider = yield* GitLabSourceControlProvider.make.pipe( | |
| 1780 | + Effect.provide( | |
| 1781 | + GitLabCli.layer.pipe( | |
| 1782 | + Layer.provide( | |
| 1783 | + Layer.mock(VcsProcess.VcsProcess)({ | |
| 1784 | + run: (input) => | |
| 1785 | + Effect.sync(() => { | |
| 1786 | + calls.push(input); | |
| 1787 | + return fakeGhOutput(output); | |
| 1788 | + }), | |
| 1789 | + }), | |
| 1790 | + ), | |
| 1791 | + ), | |
| 1792 | + ), | |
| 1793 | + ); | |
| 1794 | + const { manager } = yield* makeManager({ sourceControlProvider: provider }); | |
| 1795 | + | |
| 1796 | + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch })).toMatchObject({ | |
| 1797 | + number: 1, | |
| 1798 | + repositoryKey: "gitlab.com/group/upstream/repository", | |
| 1799 | + }); | |
| 1800 | + expect(calls.length).toBeGreaterThan(0); | |
| 1801 | + for (const call of calls) { | |
| 1802 | + expect(call.command).toBe("glab"); | |
| 1803 | + expect(call.args).toEqual([ | |
| 1804 | + "mr", | |
| 1805 | + "list", | |
| 1806 | + "--source-branch", | |
| 1807 | + branch, | |
| 1808 | + "--all", | |
| 1809 | + "--per-page", | |
| 1810 | + "20", | |
| 1811 | + "--output", | |
| 1812 | + "json", | |
| 1813 | + ]); | |
| 1814 | + } | |
| 1815 | + }), | |
| 1816 | + ); | |
| 1817 | + | |
| 1597 | 1818 | it.effect( |
| 1598 | 1819 | "status ignores unrelated fork PRs when the current branch tracks the same repository", |
| 1599 | 1820 | () => |
| @@ -2149,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 549 unmodified lines | ||
| 2149 | 2370 | branch: "feature/fork-settle", |
| 2150 | 2371 | }); |
| 2151 | 2372 | |
| 2152 | − expect(pullRequest).toEqual({ | |
| 2373 | + expect(pullRequest).toMatchObject({ | |
| 2153 | 2374 | state: "merged", |
| 2154 | 2375 | closedAt: null, |
| 2155 | 2376 | mergedAt: null, |
| @@ -77,6 +77,13 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti · 76 unmodified lines | ||
| 77 | 77 | readonly refreshMissingPullRequest?: boolean; |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | +export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & { | |
| 81 | + readonly repositoryKey: string | null; | |
| 82 | + readonly updatedAt: string | null; | |
| 83 | + readonly closedAt?: string | null; | |
| 84 | + readonly mergedAt?: string | null; | |
| 85 | +}; | |
| 86 | + | |
| 80 | 87 | interface SourceControlTextGenerationSettings { |
| 81 | 88 | readonly modelSelection: ModelSelection; |
| 82 | 89 | readonly style: SourceControlWritingStyleSettings; |
| @@ -96,18 +103,10 @@ export class GitManager extends Context.Service< · 13 unmodified lines | ||
| 96 | 103 | options?: GitRemoteStatusOptions, |
| 97 | 104 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; |
| 98 | 105 | /** Resolve the PR for a saved branch without changing the current checkout. */ |
| 99 | − readonly branchPullRequest: (input: { | |
| 100 | − readonly cwd: string; | |
| 101 | − readonly branch: string; | |
| 102 | − }) => Effect.Effect< | |
| 103 | − { | |
| 104 | − readonly state: "open" | "closed" | "merged"; | |
| 105 | − readonly updatedAt: string | null; | |
| 106 | − readonly closedAt?: string | null; | |
| 107 | − readonly mergedAt?: string | null; | |
| 108 | − } | null, | |
| 109 | − GitManagerServiceError | |
| 110 | − >; | |
| 106 | + readonly branchPullRequest: ( | |
| 107 | + input: { readonly cwd: string; readonly branch: string }, | |
| 108 | + options?: { readonly refresh?: boolean }, | |
| 109 | + ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>; | |
| 111 | 110 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; |
| 112 | 111 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; |
| 113 | 112 | readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; |
| @@ -214,9 +213,26 @@ interface BranchHeadContext { · 100 unmodified lines | ||
| 214 | 213 | isCrossRepository: boolean; |
| 215 | 214 | } |
| 216 | 215 | |
| 216 | +export function pullRequestRepositoryKey(value: string): string | null { | |
| 217 | + try { | |
| 218 | + const url = new URL(value); | |
| 219 | + const match = | |
| 220 | + /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( | |
| 221 | + url.pathname, | |
| 222 | + ); | |
| 223 | + if (match?.[1] === undefined) return null; | |
| 224 | + url.pathname = match[1]; | |
| 225 | + url.search = ""; | |
| 226 | + url.hash = ""; | |
| 227 | + return normalizeGitRemoteUrl(url.toString()); | |
| 228 | + } catch { | |
| 229 | + return null; | |
| 230 | + } | |
| 231 | +} | |
| 232 | + | |
| 217 | 233 | function parseRepositoryNameFromPullRequestUrl(url: string): string | null { |
| 218 | 234 | const trimmed = url.trim(); |
| 219 | − const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); | |
| 235 | + const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); | |
| 220 | 236 | const repositoryName = match?.[1]?.trim() ?? ""; |
| 221 | 237 | return repositoryName.length > 0 ? repositoryName : null; |
| 222 | 238 | } |
| @@ -254,14 +270,14 @@ function resolvePullRequestWorktreeLocalBranchName( · 31 unmodified lines | ||
| 254 | 270 | return `t3code/pr-${pullRequest.number}/${suffix}`; |
| 255 | 271 | } |
| 256 | 272 | |
| 257 | −function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | |
| 273 | +function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | |
| 258 | 274 | const trimmed = url?.trim() ?? ""; |
| 259 | 275 | if (trimmed.length === 0) { |
| 260 | 276 | return null; |
| 261 | 277 | } |
| 262 | 278 | |
| 263 | 279 | const match = |
| 264 | − /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( | |
| 280 | + /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( | |
| 265 | 281 | trimmed, |
| 266 | 282 | ); |
| 267 | 283 | const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; |
| @@ -273,6 +289,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null · 5 unmodified lines | ||
| 273 | 289 | if (trimmed.length === 0) { |
| 274 | 290 | return null; |
| 275 | 291 | } |
| 292 | + // GitLab reports the top-level group as owner. The full path distinguishes subgroups. | |
| 276 | 293 | const [ownerLogin] = trimmed.split("/"); |
| 277 | 294 | const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; |
| 278 | 295 | return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; |
| @@ -1261,7 +1278,7 @@ export const make = Effect.gen(function* () { · 982 unmodified lines | ||
| 1261 | 1278 | } |
| 1262 | 1279 | |
| 1263 | 1280 | const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); |
| 1264 | − const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); | |
| 1281 | + const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); | |
| 1265 | 1282 | return { |
| 1266 | 1283 | remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, |
| 1267 | 1284 | repositoryNameWithOwner, |
| @@ -2031,7 +2048,7 @@ export const make = Effect.gen(function* () { · 763 unmodified lines | ||
| 2031 | 2048 | }); |
| 2032 | 2049 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( |
| 2033 | 2050 | "branchPullRequest", |
| 2034 | − )(function* ({ cwd, branch }) { | |
| 2051 | + )(function* ({ cwd, branch }, options) { | |
| 2035 | 2052 | const cacheCwd = yield* normalizeStatusCacheKey(cwd); |
| 2036 | 2053 | const remotes = yield* gitCore.execute({ |
| 2037 | 2054 | operation: "GitManager.branchPullRequest.remotes", |
| @@ -2111,6 +2128,14 @@ export const make = Effect.gen(function* () { · 73 unmodified lines | ||
| 2111 | 2128 | localBranchExists, |
| 2112 | 2129 | ...(localBranchExists ? {} : { remoteName }), |
| 2113 | 2130 | }); |
| 2131 | + if (options?.refresh) { | |
| 2132 | + // A completed turn can create a PR or reuse a merged PR's branch. | |
| 2133 | + // Refresh successful answers, but keep failed lookups' retry backoff. | |
| 2134 | + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( | |
| 2135 | + Effect.orElseSucceed(() => Option.none()), | |
| 2136 | + ); | |
| 2137 | + if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); | |
| 2138 | + } | |
| 2114 | 2139 | let cached = yield* Cache.get(prLookupCache, cacheKey); |
| 2115 | 2140 | // The cached head context may have resolved on a different remote than |
| 2116 | 2141 | // the saved upstream: a branch tracking origin/main but pushed to a fork |
| @@ -2165,12 +2190,13 @@ export const make = Effect.gen(function* () { · 48 unmodified lines | ||
| 2165 | 2190 | ) { |
| 2166 | 2191 | return null; |
| 2167 | 2192 | } |
| 2168 | − const statusPr = toStatusPr(latest); | |
| 2169 | 2193 | return { |
| 2170 | − state: statusPr.state, | |
| 2171 | − updatedAt: statusPr.updatedAt, | |
| 2194 | + ...toStatusPr(latest), | |
| 2172 | 2195 | closedAt: latest.closedAt ?? null, |
| 2173 | 2196 | mergedAt: latest.mergedAt ?? null, |
| 2197 | + // Hosting CLIs can select an upstream repository instead of origin. | |
| 2198 | + // The returned PR URL names the repository that actually owns it. | |
| 2199 | + repositoryKey: pullRequestRepositoryKey(latest.url), | |
| 2174 | 2200 | }; |
| 2175 | 2201 | }); |
| 2176 | 2202 | const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | ProjectId, |
| 14 | 14 | ThreadId, |
| 15 | 15 | TurnId, |
| 16 | + type OrchestrationCommand, | |
| 16 | 17 | type OrchestrationEvent, |
| 17 | 18 | ProviderInstanceId, |
| 18 | 19 | } from "@t3tools/contracts"; |
| @@ -993,6 +994,216 @@ describe("OrchestrationEngine", () => { · 974 unmodified lines | ||
| 993 | 994 | await system.dispose(); |
| 994 | 995 | }); |
| 995 | 996 | |
| 997 | + it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)( | |
| 998 | + "rejects PR discovery completed after a newer %s command", | |
| 999 | + async (change) => { | |
| 1000 | + const system = await createOrchestrationSystem(); | |
| 1001 | + try { | |
| 1002 | + const projectId = ProjectId.make("pr-race-project"); | |
| 1003 | + const threadId = ThreadId.make("pr-race-thread"); | |
| 1004 | + const previous = { | |
| 1005 | + projectId, | |
| 1006 | + repository: "owner/repository", | |
| 1007 | + number: 1, | |
| 1008 | + url: "https://example.test/owner/repository/pull/1", | |
| 1009 | + }; | |
| 1010 | + const replacement = { | |
| 1011 | + ...previous, | |
| 1012 | + number: 2, | |
| 1013 | + url: "https://example.test/owner/repository/pull/2", | |
| 1014 | + }; | |
| 1015 | + await system.run( | |
| 1016 | + system.engine.dispatch({ | |
| 1017 | + type: "project.create", | |
| 1018 | + commandId: CommandId.make("pr-race-project-create"), | |
| 1019 | + projectId, | |
| 1020 | + title: "PR race project", | |
| 1021 | + workspaceRoot: "/tmp/pr-race-project", | |
| 1022 | + defaultModelSelection: null, | |
| 1023 | + createdAt: now(), | |
| 1024 | + }), | |
| 1025 | + ); | |
| 1026 | + await system.run( | |
| 1027 | + system.engine.dispatch({ | |
| 1028 | + type: "thread.create", | |
| 1029 | + commandId: CommandId.make("pr-race-thread-create"), | |
| 1030 | + threadId, | |
| 1031 | + projectId, | |
| 1032 | + title: "PR race thread", | |
| 1033 | + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 1034 | + runtimeMode: "full-access", | |
| 1035 | + interactionMode: "default", | |
| 1036 | + branch: "feature", | |
| 1037 | + worktreePath: null, | |
| 1038 | + createdAt: now(), | |
| 1039 | + }), | |
| 1040 | + ); | |
| 1041 | + const observed = await system.run( | |
| 1042 | + system.engine.dispatch({ | |
| 1043 | + type: "thread.meta.update", | |
| 1044 | + commandId: CommandId.make("pr-race-link"), | |
| 1045 | + threadId, | |
| 1046 | + linkedPullRequest: previous, | |
| 1047 | + }), | |
| 1048 | + ); | |
| 1049 | + const metadataChanges = { | |
| 1050 | + unlink: { linkedPullRequest: null }, | |
| 1051 | + relink: { | |
| 1052 | + linkedPullRequest: { | |
| 1053 | + ...previous, | |
| 1054 | + number: 3, | |
| 1055 | + url: "https://example.test/owner/repository/pull/3", | |
| 1056 | + }, | |
| 1057 | + }, | |
| 1058 | + branch: { branch: "another-feature" }, | |
| 1059 | + worktree: { worktreePath: "/tmp/another-worktree" }, | |
| 1060 | + project: {}, | |
| 1061 | + }; | |
| 1062 | + await system.run( | |
| 1063 | + system.engine.dispatch( | |
| 1064 | + change === "project" | |
| 1065 | + ? { | |
| 1066 | + type: "project.meta.update", | |
| 1067 | + commandId: CommandId.make("pr-race-project-move"), | |
| 1068 | + projectId, | |
| 1069 | + workspaceRoot: "/tmp/another-project-root", | |
| 1070 | + } | |
| 1071 | + : change === "delete" | |
| 1072 | + ? { type: "thread.delete", commandId: CommandId.make("pr-race-delete"), threadId } | |
| 1073 | + : { | |
| 1074 | + type: "thread.meta.update", | |
| 1075 | + commandId: CommandId.make(`pr-race-${change}`), | |
| 1076 | + threadId, | |
| 1077 | + ...metadataChanges[change], | |
| 1078 | + }, | |
| 1079 | + ), | |
| 1080 | + ); | |
| 1081 | + const command = { | |
| 1082 | + type: "thread.pull-request.sync", | |
| 1083 | + commandId: CommandId.make("pr-race-stale-sync"), | |
| 1084 | + threadId, | |
| 1085 | + projectId, | |
| 1086 | + snapshotSequence: observed.sequence, | |
| 1087 | + expected: { | |
| 1088 | + workspaceRoot: "/tmp/pr-race-project", | |
| 1089 | + branch: "feature", | |
| 1090 | + worktreePath: null, | |
| 1091 | + linkedPullRequest: previous, | |
| 1092 | + branchPullRequest: null, | |
| 1093 | + }, | |
| 1094 | + branchPullRequest: replacement, | |
| 1095 | + linkedPullRequest: replacement, | |
| 1096 | + } satisfies OrchestrationCommand; | |
| 1097 | + const error = await system.run(system.engine.dispatch(command).pipe(Effect.flip)); | |
| 1098 | + expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 1099 | + if (change === "delete") return; | |
| 1100 | + const current = (await system.readModel()).threads[0]; | |
| 1101 | + expect(current?.branchPullRequest ?? null).toBeNull(); | |
| 1102 | + expect(current?.linkedPullRequest ?? null).toEqual( | |
| 1103 | + change === "unlink" | |
| 1104 | + ? null | |
| 1105 | + : change === "relink" | |
| 1106 | + ? metadataChanges.relink.linkedPullRequest | |
| 1107 | + : previous, | |
| 1108 | + ); | |
| 1109 | + } finally { | |
| 1110 | + await system.dispose(); | |
| 1111 | + } | |
| 1112 | + }, | |
| 1113 | + ); | |
| 1114 | + | |
| 1115 | + it("saves PR associations through streaming and unrelated metadata edits", async () => { | |
| 1116 | + const system = await createOrchestrationSystem(); | |
| 1117 | + try { | |
| 1118 | + const projectId = ProjectId.make("pr-sync-project"); | |
| 1119 | + const threadId = ThreadId.make("pr-sync-thread"); | |
| 1120 | + await system.run( | |
| 1121 | + system.engine.dispatch({ | |
| 1122 | + type: "project.create", | |
| 1123 | + commandId: CommandId.make("pr-sync-project-create"), | |
| 1124 | + projectId, | |
| 1125 | + title: "PR sync project", | |
| 1126 | + workspaceRoot: "/tmp/pr-sync-project", | |
| 1127 | + defaultModelSelection: null, | |
| 1128 | + createdAt: now(), | |
| 1129 | + }), | |
| 1130 | + ); | |
| 1131 | + const created = await system.run( | |
| 1132 | + system.engine.dispatch({ | |
| 1133 | + type: "thread.create", | |
| 1134 | + commandId: CommandId.make("pr-sync-thread-create"), | |
| 1135 | + threadId, | |
| 1136 | + projectId, | |
| 1137 | + title: "PR sync thread", | |
| 1138 | + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 1139 | + runtimeMode: "full-access", | |
| 1140 | + interactionMode: "default", | |
| 1141 | + branch: "feature", | |
| 1142 | + worktreePath: null, | |
| 1143 | + createdAt: now(), | |
| 1144 | + }), | |
| 1145 | + ); | |
| 1146 | + const reference = { | |
| 1147 | + projectId, | |
| 1148 | + repository: "owner/repository", | |
| 1149 | + number: 42, | |
| 1150 | + url: "https://example.test/owner/repository/pull/42", | |
| 1151 | + }; | |
| 1152 | + const activityAt = "2026-01-01T01:00:00.000Z"; | |
| 1153 | + await system.run( | |
| 1154 | + system.engine.dispatch({ | |
| 1155 | + type: "thread.message.assistant.delta", | |
| 1156 | + commandId: CommandId.make("pr-sync-streaming-message"), | |
| 1157 | + threadId, | |
| 1158 | + messageId: MessageId.make("pr-sync-message"), | |
| 1159 | + delta: "The PR is ready.", | |
| 1160 | + createdAt: activityAt, | |
| 1161 | + }), | |
| 1162 | + ); | |
| 1163 | + await system.run( | |
| 1164 | + system.engine.dispatch({ | |
| 1165 | + type: "thread.meta.update", | |
| 1166 | + commandId: CommandId.make("pr-sync-title-and-model"), | |
| 1167 | + threadId, | |
| 1168 | + title: "Renamed thread", | |
| 1169 | + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 1170 | + }), | |
| 1171 | + ); | |
| 1172 | + await system.run( | |
| 1173 | + system.engine.dispatch({ | |
| 1174 | + type: "project.meta.update", | |
| 1175 | + commandId: CommandId.make("pr-sync-project-title"), | |
| 1176 | + projectId, | |
| 1177 | + title: "Renamed project", | |
| 1178 | + }), | |
| 1179 | + ); | |
| 1180 | + const beforeSync = (await system.readModel()).threads[0]; | |
| 1181 | + await system.run( | |
| 1182 | + system.engine.dispatch({ | |
| 1183 | + type: "thread.pull-request.sync", | |
| 1184 | + commandId: CommandId.make("pr-sync-discovery"), | |
| 1185 | + projectId, | |
| 1186 | + threadId, | |
| 1187 | + snapshotSequence: created.sequence, | |
| 1188 | + expected: { | |
| 1189 | + workspaceRoot: "/tmp/pr-sync-project", | |
| 1190 | + branch: "feature", | |
| 1191 | + worktreePath: null, | |
| 1192 | + linkedPullRequest: null, | |
| 1193 | + branchPullRequest: null, | |
| 1194 | + }, | |
| 1195 | + branchPullRequest: reference, | |
| 1196 | + }), | |
| 1197 | + ); | |
| 1198 | + const current = (await system.readModel()).threads[0]; | |
| 1199 | + expect(current?.branchPullRequest).toEqual(reference); | |
| 1200 | + expect(current?.linkedPullRequest ?? null).toBeNull(); | |
| 1201 | + expect(current?.updatedAt).toBe(beforeSync?.updatedAt); | |
| 1202 | + } finally { | |
| 1203 | + await system.dispose(); | |
| 1204 | + } | |
| 1205 | + }); | |
| 1206 | + | |
| 996 | 1207 | it("allows authoritative worktree bootstrap to assign a temporary branch", async () => { |
| 997 | 1208 | const system = await createOrchestrationSystem(); |
| 998 | 1209 | const { engine } = system; |
| @@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 184 unmodified lines | ||
| 185 | 185 | }); |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | + // The decider compares the lookup inputs. Only recreation needs an | |
| 189 | + // event check, since it can reset a thread to the same field values. | |
| 190 | + if ( | |
| 191 | + envelope.command.type === "thread.pull-request.sync" && | |
| 192 | + (yield* eventStore.hasEventAfter({ | |
| 193 | + aggregateKind: "thread", | |
| 194 | + aggregateId: envelope.command.threadId, | |
| 195 | + sequenceExclusive: envelope.command.snapshotSequence, | |
| 196 | + type: "thread.created", | |
| 197 | + })) | |
| 198 | + ) { | |
| 199 | + return yield* new OrchestrationCommandInvariantError({ | |
| 200 | + commandType: envelope.command.type, | |
| 201 | + detail: `thread ${envelope.command.threadId} was recreated before pull request discovery`, | |
| 202 | + }); | |
| 203 | + } | |
| 204 | + | |
| 188 | 205 | if ( |
| 189 | 206 | envelope.command.type === "thread.auto-settle" && |
| 190 | 207 | threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null |
| @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; · 9 unmodified lines | ||
| 10 | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; |
| 13 | +import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; | |
| 13 | 14 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 14 | 15 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 15 | 16 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| @@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => { · 49 unmodified lines | ||
| 65 | 66 | drainThrough: () => Effect.void, |
| 66 | 67 | }), |
| 67 | 68 | ), |
| 69 | + Layer.provideMerge( | |
| 70 | + Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { | |
| 71 | + start: () => { | |
| 72 | + started.push("thread-pull-request-reactor"); | |
| 73 | + return Effect.void; | |
| 74 | + }, | |
| 75 | + drain: Effect.void, | |
| 76 | + }), | |
| 77 | + ), | |
| 68 | 78 | Layer.provideMerge( |
| 69 | 79 | Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { |
| 70 | 80 | start: () => { |
| @@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => { · 24 unmodified lines | ||
| 95 | 105 | "provider-command-reactor", |
| 96 | 106 | "checkpoint-reactor", |
| 97 | 107 | "thread-deletion-reactor", |
| 108 | + "thread-pull-request-reactor", | |
| 98 | 109 | "thread-settlement-reactor", |
| 99 | 110 | "agent-awareness-relay", |
| 100 | 111 | ]); |
| @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; · 9 unmodified lines | ||
| 10 | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; |
| 13 | +import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; | |
| 13 | 14 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| 14 | 15 | |
| 15 | 16 | export const makeOrchestrationReactor = Effect.gen(function* () { |
| @@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { · 2 unmodified lines | ||
| 18 | 19 | const checkpointReactor = yield* CheckpointReactor; |
| 19 | 20 | const threadDeletionReactor = yield* ThreadDeletionReactor; |
| 20 | 21 | const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; |
| 22 | + const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; | |
| 21 | 23 | const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; |
| 22 | 24 | |
| 23 | 25 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { |
| @@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { · 1 unmodified line | ||
| 25 | 27 | yield* providerCommandReactor.start(); |
| 26 | 28 | yield* checkpointReactor.start(); |
| 27 | 29 | yield* threadDeletionReactor.start(); |
| 30 | + yield* threadPullRequestReactor.start(); | |
| 28 | 31 | yield* threadSettlementReactor.start(); |
| 29 | 32 | yield* agentAwarenessRelay.start(); |
| 30 | 33 | }); |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | MessageId, |
| 9 | 9 | ProjectId, |
| 10 | 10 | ThreadId, |
| 11 | + ThreadLinkedPullRequest, | |
| 11 | 12 | TurnId, |
| 12 | 13 | ProviderInstanceId, |
| 13 | 14 | } from "@t3tools/contracts"; |
| @@ -18,6 +19,7 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines | ||
| 18 | 19 | import * as FileSystem from "effect/FileSystem"; |
| 19 | 20 | import * as Layer from "effect/Layer"; |
| 20 | 21 | import * as Path from "effect/Path"; |
| 22 | +import * as Schema from "effect/Schema"; | |
| 21 | 23 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 22 | 24 | |
| 23 | 25 | import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; |
| @@ -59,6 +61,9 @@ const exists = (filePath: string) => · 35 unmodified lines | ||
| 59 | 61 | }); |
| 60 | 62 | |
| 61 | 63 | const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); |
| 64 | +const encodeThreadLinkedPullRequest = Schema.encodeSync( | |
| 65 | + Schema.fromJsonString(ThreadLinkedPullRequest), | |
| 66 | +); | |
| 62 | 67 | |
| 63 | 68 | it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))( |
| 64 | 69 | "OrchestrationProjectionPipeline cursor batches", |
| @@ -204,6 +209,93 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-") · 139 unmodified lines | ||
| 204 | 209 | }, |
| 205 | 210 | ); |
| 206 | 211 | |
| 212 | +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-branch-pr-projection-")))( | |
| 213 | + "branch pull request projection", | |
| 214 | + (it) => { | |
| 215 | + it.effect("persists branch pull request updates without changing manual links", () => | |
| 216 | + Effect.gen(function* () { | |
| 217 | + const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 218 | + const eventStore = yield* OrchestrationEventStore; | |
| 219 | + const sql = yield* SqlClient.SqlClient; | |
| 220 | + const now = "2026-01-01T00:00:00.000Z"; | |
| 221 | + const threadId = ThreadId.make("thread-pull-request"); | |
| 222 | + const projectId = ProjectId.make("project-pull-request"); | |
| 223 | + const eventFields = { | |
| 224 | + aggregateKind: "thread" as const, | |
| 225 | + aggregateId: threadId, | |
| 226 | + occurredAt: now, | |
| 227 | + commandId: null, | |
| 228 | + causationEventId: null, | |
| 229 | + correlationId: null, | |
| 230 | + metadata: {}, | |
| 231 | + }; | |
| 232 | + const created = yield* eventStore.append({ | |
| 233 | + ...eventFields, | |
| 234 | + type: "thread.created", | |
| 235 | + eventId: EventId.make("evt-pull-request-created"), | |
| 236 | + payload: { | |
| 237 | + threadId, | |
| 238 | + projectId, | |
| 239 | + title: "Pull request thread", | |
| 240 | + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 241 | + runtimeMode: "full-access", | |
| 242 | + interactionMode: "default", | |
| 243 | + branch: "feature", | |
| 244 | + worktreePath: null, | |
| 245 | + createdAt: now, | |
| 246 | + updatedAt: now, | |
| 247 | + }, | |
| 248 | + }); | |
| 249 | + yield* projectionPipeline.projectEvent(created); | |
| 250 | + const linkedPullRequest = { | |
| 251 | + projectId, | |
| 252 | + repository: "pingdotgg/t3code", | |
| 253 | + number: 42, | |
| 254 | + url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 255 | + }; | |
| 256 | + const branchPullRequest = { | |
| 257 | + ...linkedPullRequest, | |
| 258 | + number: 43, | |
| 259 | + url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 260 | + }; | |
| 261 | + const updates = [ | |
| 262 | + { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, | |
| 263 | + { payload: { title: "Renamed thread" }, expected: branchPullRequest }, | |
| 264 | + { payload: { branchPullRequest: null }, expected: null }, | |
| 265 | + ]; | |
| 266 | + | |
| 267 | + for (const [index, update] of updates.entries()) { | |
| 268 | + const event = yield* eventStore.append({ | |
| 269 | + ...eventFields, | |
| 270 | + type: "thread.meta-updated", | |
| 271 | + eventId: EventId.make(`evt-pull-request-update-${index}`), | |
| 272 | + payload: { threadId, updatedAt: now, ...update.payload }, | |
| 273 | + }); | |
| 274 | + yield* projectionPipeline.projectEvent(event); | |
| 275 | + | |
| 276 | + const rows = yield* sql<{ | |
| 277 | + readonly linkedPullRequest: string | null; | |
| 278 | + readonly branchPullRequest: string | null; | |
| 279 | + }>` | |
| 280 | + SELECT | |
| 281 | + linked_pull_request_json AS "linkedPullRequest", | |
| 282 | + branch_pull_request_json AS "branchPullRequest" | |
| 283 | + FROM projection_threads | |
| 284 | + WHERE thread_id = ${threadId} | |
| 285 | + `; | |
| 286 | + assert.deepEqual(rows, [ | |
| 287 | + { | |
| 288 | + linkedPullRequest: encodeThreadLinkedPullRequest(linkedPullRequest), | |
| 289 | + branchPullRequest: | |
| 290 | + update.expected === null ? null : encodeThreadLinkedPullRequest(update.expected), | |
| 291 | + }, | |
| 292 | + ]); | |
| 293 | + } | |
| 294 | + }), | |
| 295 | + ); | |
| 296 | + }, | |
| 297 | +); | |
| 298 | + | |
| 207 | 299 | it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 208 | 300 | it.effect("bootstraps all projection states and writes projection rows", () => |
| 209 | 301 | Effect.gen(function* () { |
| @@ -605,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 604 unmodified lines | ||
| 605 | 605 | branch: event.payload.branch, |
| 606 | 606 | worktreePath: event.payload.worktreePath, |
| 607 | 607 | linkedPullRequest: null, |
| 608 | + branchPullRequest: null, | |
| 608 | 609 | latestTurnId: null, |
| 609 | 610 | createdAt: event.payload.createdAt, |
| 610 | 611 | updatedAt: event.payload.updatedAt, |
| @@ -805,6 +806,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 194 unmodified lines | ||
| 805 | 806 | ...(event.payload.linkedPullRequest !== undefined |
| 806 | 807 | ? { linkedPullRequest: event.payload.linkedPullRequest } |
| 807 | 808 | : {}), |
| 809 | + ...(event.payload.branchPullRequest !== undefined | |
| 810 | + ? { branchPullRequest: event.payload.branchPullRequest } | |
| 811 | + : {}), | |
| 808 | 812 | updatedAt: event.payload.updatedAt, |
| 809 | 813 | }); |
| 810 | 814 | return; |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | MessageId, |
| 7 | 7 | ProjectId, |
| 8 | 8 | ThreadId, |
| 9 | + ThreadLinkedPullRequest, | |
| 9 | 10 | TurnId, |
| 10 | 11 | ProviderInstanceId, |
| 11 | 12 | } from "@t3tools/contracts"; |
| @@ -36,6 +37,9 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val · 24 unmodified lines | ||
| 36 | 37 | const encodeChatAttachments = Schema.encodeEffect( |
| 37 | 38 | Schema.fromJsonString(Schema.Array(ChatAttachment)), |
| 38 | 39 | ); |
| 40 | +const encodeThreadLinkedPullRequest = Schema.encodeSync( | |
| 41 | + Schema.fromJsonString(ThreadLinkedPullRequest), | |
| 42 | +); | |
| 39 | 43 | |
| 40 | 44 | const projectionSnapshotLayer = it.layer( |
| 41 | 45 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| @@ -52,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 10 unmodified lines | ||
| 52 | 56 | Effect.gen(function* () { |
| 53 | 57 | const snapshotQuery = yield* ProjectionSnapshotQuery; |
| 54 | 58 | const sql = yield* SqlClient.SqlClient; |
| 59 | + const branchPullRequest = { | |
| 60 | + projectId: asProjectId("project-1"), | |
| 61 | + repository: "pingdotgg/t3code", | |
| 62 | + number: 43, | |
| 63 | + url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 64 | + }; | |
| 55 | 65 | |
| 56 | 66 | yield* sql`DELETE FROM projection_projects`; |
| 57 | 67 | yield* sql`DELETE FROM projection_state`; |
| @@ -92,6 +102,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 34 unmodified lines | ||
| 92 | 102 | branch, |
| 93 | 103 | worktree_path, |
| 94 | 104 | linked_pull_request_json, |
| 105 | + branch_pull_request_json, | |
| 95 | 106 | latest_turn_id, |
| 96 | 107 | latest_user_message_at, |
| 97 | 108 | pending_approval_count, |
| @@ -113,6 +124,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 15 unmodified lines | ||
| 113 | 124 | NULL, |
| 114 | 125 | NULL, |
| 115 | 126 | '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', |
| 127 | + ${encodeThreadLinkedPullRequest(branchPullRequest)}, | |
| 116 | 128 | 'turn-1', |
| 117 | 129 | '2026-02-24T00:00:04.000Z', |
| 118 | 130 | 1, |
| @@ -323,6 +335,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 204 unmodified lines | ||
| 323 | 335 | number: 42, |
| 324 | 336 | url: "https://github.com/pingdotgg/t3code/pull/42", |
| 325 | 337 | }, |
| 338 | + branchPullRequest, | |
| 326 | 339 | latestTurn: { |
| 327 | 340 | turnId: asTurnId("turn-1"), |
| 328 | 341 | state: "completed", |
| @@ -451,6 +464,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 122 unmodified lines | ||
| 451 | 464 | number: 42, |
| 452 | 465 | url: "https://github.com/pingdotgg/t3code/pull/42", |
| 453 | 466 | }, |
| 467 | + branchPullRequest, | |
| 454 | 468 | latestTurn: { |
| 455 | 469 | turnId: asTurnId("turn-1"), |
| 456 | 470 | state: "completed", |
| @@ -498,6 +512,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 41 unmodified lines | ||
| 498 | 512 | assert.deepEqual(threadDetail.value, snapshot.threads[0]); |
| 499 | 513 | } |
| 500 | 514 | |
| 515 | + const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); | |
| 516 | + assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); | |
| 517 | + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); | |
| 518 | + assert.equal(threadShell._tag, "Some"); | |
| 519 | + if (threadShell._tag === "Some") { | |
| 520 | + assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest); | |
| 521 | + } | |
| 522 | + | |
| 501 | 523 | yield* sql` |
| 502 | 524 | INSERT INTO projection_thread_activities ( |
| 503 | 525 | activity_id, |
| @@ -743,6 +765,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 239 unmodified lines | ||
| 743 | 765 | Effect.gen(function* () { |
| 744 | 766 | const snapshotQuery = yield* ProjectionSnapshotQuery; |
| 745 | 767 | const sql = yield* SqlClient.SqlClient; |
| 768 | + const branchPullRequest = { | |
| 769 | + projectId: asProjectId("project-archive-test"), | |
| 770 | + repository: "pingdotgg/t3code", | |
| 771 | + number: 43, | |
| 772 | + url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 773 | + }; | |
| 746 | 774 | |
| 747 | 775 | yield* sql`DELETE FROM projection_projects`; |
| 748 | 776 | yield* sql`DELETE FROM projection_threads`; |
| @@ -849,6 +877,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 100 unmodified lines | ||
| 849 | 877 | shellSnapshot.threads.map((thread) => thread.id), |
| 850 | 878 | [ThreadId.make("thread-active")], |
| 851 | 879 | ); |
| 880 | + assert.equal(shellSnapshot.threads[0]?.branchPullRequest, null); | |
| 881 | + | |
| 882 | + yield* sql` | |
| 883 | + UPDATE projection_threads | |
| 884 | + SET branch_pull_request_json = ${encodeThreadLinkedPullRequest(branchPullRequest)} | |
| 885 | + WHERE thread_id = 'thread-archived' | |
| 886 | + `; | |
| 852 | 887 | |
| 853 | 888 | const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); |
| 854 | 889 | assert.deepEqual( |
| @@ -856,6 +891,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 1 unmodified line | ||
| 856 | 891 | [ThreadId.make("thread-archived")], |
| 857 | 892 | ); |
| 858 | 893 | assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); |
| 894 | + assert.deepEqual(archivedShellSnapshot.threads[0]?.branchPullRequest, branchPullRequest); | |
| 859 | 895 | const activeContext = yield* snapshotQuery.getThreadRuntimeContext( |
| 860 | 896 | ThreadId.make("thread-active"), |
| 861 | 897 | ); |
| @@ -116,6 +116,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( · 115 unmodified lines | ||
| 116 | 116 | Struct.assign({ |
| 117 | 117 | modelSelection: Schema.fromJsonString(ModelSelection), |
| 118 | 118 | linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), |
| 119 | + branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), | |
| 119 | 120 | }), |
| 120 | 121 | ); |
| 121 | 122 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| @@ -500,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 378 unmodified lines | ||
| 500 | 501 | branch, |
| 501 | 502 | worktree_path AS "worktreePath", |
| 502 | 503 | linked_pull_request_json AS "linkedPullRequest", |
| 504 | + branch_pull_request_json AS "branchPullRequest", | |
| 503 | 505 | latest_turn_id AS "latestTurnId", |
| 504 | 506 | created_at AS "createdAt", |
| 505 | 507 | updated_at AS "updatedAt", |
| @@ -538,6 +540,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 32 unmodified lines | ||
| 538 | 540 | branch, |
| 539 | 541 | worktree_path AS "worktreePath", |
| 540 | 542 | linked_pull_request_json AS "linkedPullRequest", |
| 543 | + branch_pull_request_json AS "branchPullRequest", | |
| 541 | 544 | latest_turn_id AS "latestTurnId", |
| 542 | 545 | created_at AS "createdAt", |
| 543 | 546 | updated_at AS "updatedAt", |
| @@ -578,6 +581,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 34 unmodified lines | ||
| 578 | 581 | branch, |
| 579 | 582 | worktree_path AS "worktreePath", |
| 580 | 583 | linked_pull_request_json AS "linkedPullRequest", |
| 584 | + branch_pull_request_json AS "branchPullRequest", | |
| 581 | 585 | latest_turn_id AS "latestTurnId", |
| 582 | 586 | created_at AS "createdAt", |
| 583 | 587 | updated_at AS "updatedAt", |
| @@ -1067,6 +1071,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 483 unmodified lines | ||
| 1067 | 1071 | branch, |
| 1068 | 1072 | worktree_path AS "worktreePath", |
| 1069 | 1073 | linked_pull_request_json AS "linkedPullRequest", |
| 1074 | + branch_pull_request_json AS "branchPullRequest", | |
| 1070 | 1075 | latest_turn_id AS "latestTurnId", |
| 1071 | 1076 | created_at AS "createdAt", |
| 1072 | 1077 | updated_at AS "updatedAt", |
| @@ -2062,6 +2067,7 @@ pending_approval_requests AS ( · 989 unmodified lines | ||
| 2062 | 2067 | interactionMode: row.interactionMode, |
| 2063 | 2068 | branch: row.branch, |
| 2064 | 2069 | worktreePath: row.worktreePath, |
| 2070 | + branchPullRequest: row.branchPullRequest, | |
| 2065 | 2071 | ...(row.linkedPullRequest === null |
| 2066 | 2072 | ? {} |
| 2067 | 2073 | : { linkedPullRequest: row.linkedPullRequest }), |
| @@ -2275,6 +2281,7 @@ pending_approval_requests AS ( · 207 unmodified lines | ||
| 2275 | 2281 | interactionMode: row.interactionMode, |
| 2276 | 2282 | branch: row.branch, |
| 2277 | 2283 | worktreePath: row.worktreePath, |
| 2284 | + branchPullRequest: row.branchPullRequest, | |
| 2278 | 2285 | ...(row.linkedPullRequest === null |
| 2279 | 2286 | ? {} |
| 2280 | 2287 | : { linkedPullRequest: row.linkedPullRequest }), |
| @@ -2415,6 +2422,7 @@ pending_approval_requests AS ( · 134 unmodified lines | ||
| 2415 | 2422 | interactionMode: row.interactionMode, |
| 2416 | 2423 | branch: row.branch, |
| 2417 | 2424 | worktreePath: row.worktreePath, |
| 2425 | + branchPullRequest: row.branchPullRequest, | |
| 2418 | 2426 | ...(row.linkedPullRequest === null |
| 2419 | 2427 | ? {} |
| 2420 | 2428 | : { linkedPullRequest: row.linkedPullRequest }), |
| @@ -2563,6 +2571,7 @@ pending_approval_requests AS ( · 142 unmodified lines | ||
| 2563 | 2571 | interactionMode: row.interactionMode, |
| 2564 | 2572 | branch: row.branch, |
| 2565 | 2573 | worktreePath: row.worktreePath, |
| 2574 | + branchPullRequest: row.branchPullRequest, | |
| 2566 | 2575 | ...(row.linkedPullRequest === null |
| 2567 | 2576 | ? {} |
| 2568 | 2577 | : { linkedPullRequest: row.linkedPullRequest }), |
| @@ -2884,6 +2893,7 @@ pending_approval_requests AS ( · 315 unmodified lines | ||
| 2884 | 2893 | interactionMode: threadRow.value.interactionMode, |
| 2885 | 2894 | branch: threadRow.value.branch, |
| 2886 | 2895 | worktreePath: threadRow.value.worktreePath, |
| 2896 | + branchPullRequest: threadRow.value.branchPullRequest, | |
| 2887 | 2897 | ...(threadRow.value.linkedPullRequest === null |
| 2888 | 2898 | ? {} |
| 2889 | 2899 | : { linkedPullRequest: threadRow.value.linkedPullRequest }), |
| @@ -3165,6 +3175,7 @@ pending_approval_requests AS ( · 275 unmodified lines | ||
| 3165 | 3175 | interactionMode: threadRow.value.interactionMode, |
| 3166 | 3176 | branch: threadRow.value.branch, |
| 3167 | 3177 | worktreePath: threadRow.value.worktreePath, |
| 3178 | + branchPullRequest: threadRow.value.branchPullRequest, | |
| 3168 | 3179 | ...(threadRow.value.linkedPullRequest === null |
| 3169 | 3180 | ? {} |
| 3170 | 3181 | : { linkedPullRequest: threadRow.value.linkedPullRequest }), |
| @@ -0,0 +1,613 @@ | ||
| 1 | +import { | |
| 2 | + EventId, | |
| 3 | + GitManagerError, | |
| 4 | + ProjectId, | |
| 5 | + ProviderInstanceId, | |
| 6 | + ThreadId, | |
| 7 | + type OrchestrationCommand, | |
| 8 | + type OrchestrationEvent, | |
| 9 | + type OrchestrationProjectShell, | |
| 10 | + type OrchestrationShellSnapshot, | |
| 11 | + type OrchestrationThreadShell, | |
| 12 | + type PullRequestRef, | |
| 13 | + type PullRequestSummary, | |
| 14 | + type ThreadLinkedPullRequest, | |
| 15 | +} from "@t3tools/contracts"; | |
| 16 | +import { describe, expect, it } from "@effect/vitest"; | |
| 17 | +import * as Crypto from "effect/Crypto"; | |
| 18 | +import * as Deferred from "effect/Deferred"; | |
| 19 | +import * as Effect from "effect/Effect"; | |
| 20 | +import * as FileSystem from "effect/FileSystem"; | |
| 21 | +import * as Layer from "effect/Layer"; | |
| 22 | +import * as PubSub from "effect/PubSub"; | |
| 23 | +import * as Queue from "effect/Queue"; | |
| 24 | +import * as Ref from "effect/Ref"; | |
| 25 | +import * as Stream from "effect/Stream"; | |
| 26 | +import { TestClock } from "effect/testing"; | |
| 27 | + | |
| 28 | +import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; | |
| 29 | +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; | |
| 30 | +import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; | |
| 31 | +import { ServerActivation } from "../serverActivation.ts"; | |
| 32 | +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | |
| 33 | +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | |
| 34 | +import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts"; | |
| 35 | + | |
| 36 | +const NOW = "2026-09-01T12:00:00.000Z"; | |
| 37 | +const PROJECT_ID = ProjectId.make("project"); | |
| 38 | +const REPOSITORY = "owner/repository"; | |
| 39 | +const REPOSITORY_KEY = `github.com/${REPOSITORY}`; | |
| 40 | +type SyncCommand = Extract<OrchestrationCommand, { type: "thread.pull-request.sync" }>; | |
| 41 | + | |
| 42 | +function reference(number: number): ThreadLinkedPullRequest { | |
| 43 | + return { | |
| 44 | + projectId: PROJECT_ID, | |
| 45 | + repository: REPOSITORY, | |
| 46 | + number, | |
| 47 | + url: `https://github.com/${REPOSITORY}/pull/${number}`, | |
| 48 | + }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function branchPullRequest( | |
| 52 | + number = 42, | |
| 53 | + state: GitBranchPullRequest["state"] = "open", | |
| 54 | +): GitBranchPullRequest { | |
| 55 | + return { | |
| 56 | + ...reference(number), | |
| 57 | + title: "Branch pull request", | |
| 58 | + baseRef: "main", | |
| 59 | + headRef: "feature", | |
| 60 | + state, | |
| 61 | + updatedAt: NOW, | |
| 62 | + repositoryKey: REPOSITORY_KEY, | |
| 63 | + }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +function summary(input: PullRequestRef, state: PullRequestSummary["state"]): PullRequestSummary { | |
| 67 | + return { | |
| 68 | + ...input, | |
| 69 | + provider: "github", | |
| 70 | + title: "Pull request", | |
| 71 | + url: reference(input.number).url, | |
| 72 | + state, | |
| 73 | + headBranch: "feature", | |
| 74 | + baseBranch: "main", | |
| 75 | + updatedAt: NOW, | |
| 76 | + }; | |
| 77 | +} | |
| 78 | + | |
| 79 | +function thread( | |
| 80 | + id: string, | |
| 81 | + overrides: Partial<OrchestrationThreadShell> = {}, | |
| 82 | +): OrchestrationThreadShell { | |
| 83 | + return { | |
| 84 | + id: ThreadId.make(id), | |
| 85 | + projectId: PROJECT_ID, | |
| 86 | + title: id, | |
| 87 | + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 88 | + runtimeMode: "full-access", | |
| 89 | + interactionMode: "default", | |
| 90 | + branch: "feature", | |
| 91 | + worktreePath: null, | |
| 92 | + latestTurn: null, | |
| 93 | + createdAt: NOW, | |
| 94 | + updatedAt: NOW, | |
| 95 | + archivedAt: null, | |
| 96 | + settledOverride: null, | |
| 97 | + settledAt: null, | |
| 98 | + session: null, | |
| 99 | + latestUserMessageAt: NOW, | |
| 100 | + hasPendingApprovals: false, | |
| 101 | + hasPendingUserInput: false, | |
| 102 | + hasActionableProposedPlan: false, | |
| 103 | + ...overrides, | |
| 104 | + }; | |
| 105 | +} | |
| 106 | + | |
| 107 | +const project = { | |
| 108 | + id: PROJECT_ID, | |
| 109 | + title: "Project", | |
| 110 | + workspaceRoot: "/workspace/project", | |
| 111 | + repositoryIdentity: { | |
| 112 | + canonicalKey: REPOSITORY_KEY, | |
| 113 | + displayName: REPOSITORY, | |
| 114 | + rootPath: "/workspace/project", | |
| 115 | + locator: { | |
| 116 | + source: "git-remote", | |
| 117 | + remoteName: "origin", | |
| 118 | + remoteUrl: `git@github.com:${REPOSITORY}.git`, | |
| 119 | + }, | |
| 120 | + }, | |
| 121 | + defaultModelSelection: null, | |
| 122 | + scripts: [], | |
| 123 | + createdAt: NOW, | |
| 124 | + updatedAt: NOW, | |
| 125 | +} satisfies OrchestrationProjectShell; | |
| 126 | + | |
| 127 | +const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options: { | |
| 128 | + readonly threads: ReadonlyArray<OrchestrationThreadShell>; | |
| 129 | + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; | |
| 130 | + readonly summary?: PullRequestService["Service"]["summary"]; | |
| 131 | + readonly existingWorktrees?: ReadonlyArray<string>; | |
| 132 | + readonly project?: OrchestrationProjectShell; | |
| 133 | + readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"]; | |
| 134 | +}) { | |
| 135 | + const activation = yield* Deferred.make<void>(); | |
| 136 | + const snapshots = yield* Ref.make<OrchestrationShellSnapshot>({ | |
| 137 | + snapshotSequence: 1, | |
| 138 | + projects: [options.project ?? project], | |
| 139 | + threads: options.threads, | |
| 140 | + updatedAt: NOW, | |
| 141 | + }); | |
| 142 | + const reads = yield* Queue.unbounded<void>(); | |
| 143 | + const events = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 144 | + const commands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]); | |
| 145 | + const branchCalls = yield* Ref.make< | |
| 146 | + ReadonlyArray<{ readonly cwd: string; readonly branch: string; readonly refresh: boolean }> | |
| 147 | + >([]); | |
| 148 | + const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); | |
| 149 | + let uuid = 0; | |
| 150 | + const dependencies = Layer.mergeAll( | |
| 151 | + Layer.mock(ProjectionSnapshotQuery)({ | |
| 152 | + getShellSnapshot: () => | |
| 153 | + Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))), | |
| 154 | + }), | |
| 155 | + Layer.mock(GitManager)({ | |
| 156 | + branchPullRequest: (input, readOptions) => | |
| 157 | + Ref.update(branchCalls, (calls) => [ | |
| 158 | + ...calls, | |
| 159 | + { ...input, refresh: readOptions?.refresh === true }, | |
| 160 | + ]).pipe( | |
| 161 | + Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), | |
| 162 | + ), | |
| 163 | + }), | |
| 164 | + Layer.mock(PullRequestService)({ | |
| 165 | + summary: (input, readOptions) => | |
| 166 | + Ref.update(summaryCalls, (calls) => [...calls, input]).pipe( | |
| 167 | + Effect.andThen( | |
| 168 | + options.summary?.(input, readOptions) ?? Effect.succeed(summary(input, "open")), | |
| 169 | + ), | |
| 170 | + ), | |
| 171 | + }), | |
| 172 | + Layer.mock(RepositoryIdentityResolver)({ | |
| 173 | + resolve: | |
| 174 | + options.resolveRepositoryIdentity ?? | |
| 175 | + (() => Effect.succeed(options.project?.repositoryIdentity ?? project.repositoryIdentity)), | |
| 176 | + }), | |
| 177 | + Layer.mock(OrchestrationEngineService)({ | |
| 178 | + subscribeDomainEvents: PubSub.subscribe(events).pipe( | |
| 179 | + Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 180 | + ), | |
| 181 | + dispatch: (command) => { | |
| 182 | + if (command.type !== "thread.pull-request.sync") { | |
| 183 | + return Effect.die(`Unexpected command: ${command.type}`); | |
| 184 | + } | |
| 185 | + return Ref.update(commands, (current) => [...current, command]).pipe( | |
| 186 | + Effect.andThen( | |
| 187 | + Ref.updateAndGet(snapshots, (snapshot) => ({ | |
| 188 | + ...snapshot, | |
| 189 | + snapshotSequence: snapshot.snapshotSequence + 1, | |
| 190 | + threads: snapshot.threads.map((current) => | |
| 191 | + current.id === command.threadId | |
| 192 | + ? { | |
| 193 | + ...current, | |
| 194 | + branchPullRequest: command.branchPullRequest, | |
| 195 | + ...(command.linkedPullRequest !== undefined | |
| 196 | + ? { linkedPullRequest: command.linkedPullRequest } | |
| 197 | + : {}), | |
| 198 | + } | |
| 199 | + : current, | |
| 200 | + ), | |
| 201 | + })), | |
| 202 | + ), | |
| 203 | + Effect.map((snapshot) => ({ sequence: snapshot.snapshotSequence })), | |
| 204 | + ); | |
| 205 | + }, | |
| 206 | + }), | |
| 207 | + Layer.succeed(ServerActivation, Deferred.await(activation)), | |
| 208 | + Layer.succeed( | |
| 209 | + Crypto.Crypto, | |
| 210 | + Crypto.make({ | |
| 211 | + randomBytes: (size) => new Uint8Array(size).fill(++uuid), | |
| 212 | + digest: (_algorithm, data) => Effect.succeed(data), | |
| 213 | + }), | |
| 214 | + ), | |
| 215 | + FileSystem.layerNoop({ | |
| 216 | + exists: (path) => Effect.succeed(options.existingWorktrees?.includes(path) ?? false), | |
| 217 | + }), | |
| 218 | + ); | |
| 219 | + | |
| 220 | + const start = Effect.fn("startThreadPullRequestHarness")(function* () { | |
| 221 | + const reactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; | |
| 222 | + yield* reactor.start(); | |
| 223 | + yield* Deferred.succeed(activation, undefined); | |
| 224 | + yield* Queue.take(reads); | |
| 225 | + yield* reactor.drain; | |
| 226 | + return reactor; | |
| 227 | + }); | |
| 228 | + | |
| 229 | + return { | |
| 230 | + start, | |
| 231 | + reads, | |
| 232 | + snapshots, | |
| 233 | + commands, | |
| 234 | + branchCalls, | |
| 235 | + summaryCalls, | |
| 236 | + publish: (event: OrchestrationEvent) => PubSub.publish(events, event), | |
| 237 | + layer: ThreadPullRequestReactor.layer.pipe(Layer.provide(dependencies)), | |
| 238 | + }; | |
| 239 | +}); | |
| 240 | + | |
| 241 | +describe("ThreadPullRequestReactor", () => { | |
| 242 | + it.effect("discovers saved branch PRs without a client and shares branch lookups", () => | |
| 243 | + Effect.scoped( | |
| 244 | + Effect.gen(function* () { | |
| 245 | + const fixture = yield* makeHarness({ | |
| 246 | + threads: [ | |
| 247 | + thread("first"), | |
| 248 | + thread("second"), | |
| 249 | + thread("archived", { archivedAt: NOW }), | |
| 250 | + thread("no-branch", { branch: null }), | |
| 251 | + ], | |
| 252 | + branchPullRequest: () => Effect.succeed(branchPullRequest()), | |
| 253 | + }); | |
| 254 | + yield* Effect.gen(function* () { | |
| 255 | + const reactor = yield* fixture.start(); | |
| 256 | + expect(yield* Ref.get(fixture.branchCalls)).toEqual([ | |
| 257 | + { cwd: project.workspaceRoot, branch: "feature", refresh: false }, | |
| 258 | + { cwd: project.workspaceRoot, branch: "feature", refresh: false }, | |
| 259 | + ]); | |
| 260 | + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ | |
| 261 | + "first", | |
| 262 | + "second", | |
| 263 | + ]); | |
| 264 | + expect((yield* Ref.get(fixture.snapshots)).threads[0]?.branchPullRequest).toEqual( | |
| 265 | + reference(42), | |
| 266 | + ); | |
| 267 | + | |
| 268 | + yield* TestClock.adjust("1 minute"); | |
| 269 | + yield* Queue.take(fixture.reads); | |
| 270 | + yield* reactor.drain; | |
| 271 | + expect(yield* Ref.get(fixture.commands)).toHaveLength(2); | |
| 272 | + }).pipe(Effect.provide(fixture.layer)); | |
| 273 | + }), | |
| 274 | + ), | |
| 275 | + ); | |
| 276 | + | |
| 277 | + it.effect("replaces terminal manual links but preserves open links and explicit unlink", () => | |
| 278 | + Effect.scoped( | |
| 279 | + Effect.gen(function* () { | |
| 280 | + const fixture = yield* makeHarness({ | |
| 281 | + threads: [ | |
| 282 | + thread("merged", { linkedPullRequest: reference(1) }), | |
| 283 | + thread("closed", { linkedPullRequest: reference(2) }), | |
| 284 | + thread("open", { linkedPullRequest: reference(3) }), | |
| 285 | + thread("unlinked", { linkedPullRequest: null }), | |
| 286 | + ], | |
| 287 | + branchPullRequest: () => Effect.succeed(branchPullRequest()), | |
| 288 | + summary: (input) => | |
| 289 | + Effect.succeed( | |
| 290 | + summary( | |
| 291 | + input, | |
| 292 | + input.number === 1 ? "merged" : input.number === 2 ? "closed" : "open", | |
| 293 | + ), | |
| 294 | + ), | |
| 295 | + }); | |
| 296 | + yield* Effect.gen(function* () { | |
| 297 | + yield* fixture.start(); | |
| 298 | + const snapshot = yield* Ref.get(fixture.snapshots); | |
| 299 | + expect(snapshot.threads.map((current) => current.linkedPullRequest)).toEqual([ | |
| 300 | + reference(42), | |
| 301 | + reference(42), | |
| 302 | + reference(3), | |
| 303 | + null, | |
| 304 | + ]); | |
| 305 | + expect( | |
| 306 | + snapshot.threads.every((current) => current.branchPullRequest?.number === 42), | |
| 307 | + ).toBe(true); | |
| 308 | + }).pipe(Effect.provide(fixture.layer)); | |
| 309 | + }), | |
| 310 | + ), | |
| 311 | + ); | |
| 312 | + | |
| 313 | + it.effect("refreshes discovery after a turn ends without client demand", () => | |
| 314 | + Effect.scoped( | |
| 315 | + Effect.gen(function* () { | |
| 316 | + const current = thread("turn-thread"); | |
| 317 | + const detected = yield* Ref.make<GitBranchPullRequest | null>(null); | |
| 318 | + const fixture = yield* makeHarness({ | |
| 319 | + threads: [current], | |
| 320 | + branchPullRequest: (_input, options) => | |
| 321 | + options?.refresh | |
| 322 | + ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) | |
| 323 | + : Ref.get(detected), | |
| 324 | + }); | |
| 325 | + yield* Effect.gen(function* () { | |
| 326 | + const reactor = yield* fixture.start(); | |
| 327 | + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 328 | + yield* fixture.publish({ | |
| 329 | + type: "thread.session-set", | |
| 330 | + sequence: 2, | |
| 331 | + eventId: EventId.make("turn-finished"), | |
| 332 | + aggregateKind: "thread", | |
| 333 | + aggregateId: current.id, | |
| 334 | + occurredAt: NOW, | |
| 335 | + commandId: null, | |
| 336 | + causationEventId: null, | |
| 337 | + correlationId: null, | |
| 338 | + metadata: {}, | |
| 339 | + payload: { | |
| 340 | + threadId: current.id, | |
| 341 | + session: { | |
| 342 | + threadId: current.id, | |
| 343 | + status: "ready", | |
| 344 | + providerName: "Codex", | |
| 345 | + runtimeMode: "full-access", | |
| 346 | + activeTurnId: null, | |
| 347 | + lastError: null, | |
| 348 | + updatedAt: NOW, | |
| 349 | + }, | |
| 350 | + }, | |
| 351 | + }); | |
| 352 | + yield* Queue.take(fixture.reads); | |
| 353 | + yield* reactor.drain; | |
| 354 | + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); | |
| 355 | + expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( | |
| 356 | + 1, | |
| 357 | + ); | |
| 358 | + }).pipe(Effect.provide(fixture.layer)); | |
| 359 | + }), | |
| 360 | + ), | |
| 361 | + ); | |
| 362 | + | |
| 363 | + it.effect("uses live worktrees and falls back to the project for removed worktrees", () => | |
| 364 | + Effect.scoped( | |
| 365 | + Effect.gen(function* () { | |
| 366 | + const fixture = yield* makeHarness({ | |
| 367 | + threads: [ | |
| 368 | + thread("live", { worktreePath: "/workspace/worktree" }), | |
| 369 | + thread("removed", { worktreePath: "/workspace/removed" }), | |
| 370 | + ], | |
| 371 | + existingWorktrees: ["/workspace/worktree"], | |
| 372 | + branchPullRequest: () => Effect.succeed(branchPullRequest()), | |
| 373 | + }); | |
| 374 | + yield* Effect.gen(function* () { | |
| 375 | + yield* fixture.start(); | |
| 376 | + expect(new Set((yield* Ref.get(fixture.branchCalls)).map((call) => call.cwd))).toEqual( | |
| 377 | + new Set(["/workspace/project", "/workspace/worktree"]), | |
| 378 | + ); | |
| 379 | + }).pipe(Effect.provide(fixture.layer)); | |
| 380 | + }), | |
| 381 | + ), | |
| 382 | + ); | |
| 383 | + | |
| 384 | + it.effect("retains only terminal PRs on shared checkouts and clears a removed branch", () => | |
| 385 | + Effect.scoped( | |
| 386 | + Effect.gen(function* () { | |
| 387 | + const fixture = yield* makeHarness({ | |
| 388 | + threads: [ | |
| 389 | + thread("terminal", { branch: "main", branchPullRequest: reference(1) }), | |
| 390 | + thread("open", { branchPullRequest: reference(2) }), | |
| 391 | + thread("worktree", { | |
| 392 | + worktreePath: "/workspace/worktree", | |
| 393 | + branchPullRequest: reference(1), | |
| 394 | + }), | |
| 395 | + thread("cleared", { | |
| 396 | + branch: null, | |
| 397 | + branchPullRequest: reference(1), | |
| 398 | + linkedPullRequest: reference(3), | |
| 399 | + }), | |
| 400 | + ], | |
| 401 | + summary: (input) => | |
| 402 | + Effect.succeed(summary(input, input.number === 1 ? "merged" : "open")), | |
| 403 | + }); | |
| 404 | + yield* Effect.gen(function* () { | |
| 405 | + yield* fixture.start(); | |
| 406 | + const snapshot = yield* Ref.get(fixture.snapshots); | |
| 407 | + expect(snapshot.threads.map((current) => current.branchPullRequest)).toEqual([ | |
| 408 | + reference(1), | |
| 409 | + null, | |
| 410 | + null, | |
| 411 | + null, | |
| 412 | + ]); | |
| 413 | + expect(snapshot.threads[3]?.linkedPullRequest).toEqual(reference(3)); | |
| 414 | + }).pipe(Effect.provide(fixture.layer)); | |
| 415 | + }), | |
| 416 | + ), | |
| 417 | + ); | |
| 418 | + | |
| 419 | + it.effect("keeps saved links on lookup failures and rejects a different repository", () => | |
| 420 | + Effect.scoped( | |
| 421 | + Effect.gen(function* () { | |
| 422 | + const fixture = yield* makeHarness({ | |
| 423 | + threads: [ | |
| 424 | + thread("failed", { branch: "failed", branchPullRequest: reference(1) }), | |
| 425 | + thread("wrong-repository", { | |
| 426 | + branch: "wrong-repository", | |
| 427 | + branchPullRequest: reference(2), | |
| 428 | + }), | |
| 429 | + thread("healthy"), | |
| 430 | + ], | |
| 431 | + branchPullRequest: ({ cwd, branch }) => | |
| 432 | + branch === "failed" | |
| 433 | + ? Effect.fail( | |
| 434 | + new GitManagerError({ | |
| 435 | + operation: "branchPullRequest", | |
| 436 | + cwd, | |
| 437 | + detail: "Lookup failed", | |
| 438 | + }), | |
| 439 | + ) | |
| 440 | + : Effect.succeed({ | |
| 441 | + ...branchPullRequest(), | |
| 442 | + repositoryKey: | |
| 443 | + branch === "wrong-repository" ? "github.com/other/repository" : REPOSITORY_KEY, | |
| 444 | + }), | |
| 445 | + }); | |
| 446 | + yield* Effect.gen(function* () { | |
| 447 | + yield* fixture.start(); | |
| 448 | + expect( | |
| 449 | + (yield* Ref.get(fixture.snapshots)).threads.map((current) => current.branchPullRequest), | |
| 450 | + ).toEqual([reference(1), reference(2), reference(42)]); | |
| 451 | + expect(yield* Ref.get(fixture.commands)).toHaveLength(1); | |
| 452 | + }).pipe(Effect.provide(fixture.layer)); | |
| 453 | + }), | |
| 454 | + ), | |
| 455 | + ); | |
| 456 | + | |
| 457 | + it.effect("retries failed settled backfills and stops querying them after success", () => | |
| 458 | + Effect.scoped( | |
| 459 | + Effect.gen(function* () { | |
| 460 | + const online = yield* Ref.make(false); | |
| 461 | + const fixture = yield* makeHarness({ | |
| 462 | + threads: [ | |
| 463 | + thread("backfill", { settledOverride: "settled", settledAt: NOW }), | |
| 464 | + thread("known", { | |
| 465 | + branch: "known", | |
| 466 | + settledOverride: "settled", | |
| 467 | + settledAt: NOW, | |
| 468 | + branchPullRequest: reference(1), | |
| 469 | + }), | |
| 470 | + ], | |
| 471 | + branchPullRequest: ({ cwd }) => | |
| 472 | + Ref.get(online).pipe( | |
| 473 | + Effect.flatMap((connected) => | |
| 474 | + connected | |
| 475 | + ? Effect.succeed(branchPullRequest(42, "merged")) | |
| 476 | + : Effect.fail( | |
| 477 | + new GitManagerError({ | |
| 478 | + operation: "branchPullRequest", | |
| 479 | + cwd, | |
| 480 | + detail: "Offline", | |
| 481 | + }), | |
| 482 | + ), | |
| 483 | + ), | |
| 484 | + ), | |
| 485 | + }); | |
| 486 | + yield* Effect.gen(function* () { | |
| 487 | + const reactor = yield* fixture.start(); | |
| 488 | + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 489 | + yield* Ref.set(online, true); | |
| 490 | + yield* TestClock.adjust("1 minute"); | |
| 491 | + yield* Queue.take(fixture.reads); | |
| 492 | + yield* reactor.drain; | |
| 493 | + expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); | |
| 494 | + yield* TestClock.adjust("1 minute"); | |
| 495 | + yield* Queue.take(fixture.reads); | |
| 496 | + yield* reactor.drain; | |
| 497 | + expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ | |
| 498 | + "feature", | |
| 499 | + "feature", | |
| 500 | + "feature", | |
| 501 | + ]); | |
| 502 | + }).pipe(Effect.provide(fixture.layer)); | |
| 503 | + }), | |
| 504 | + ), | |
| 505 | + ); | |
| 506 | + | |
| 507 | + it.effect("stops retrying a settled backfill after repeated lookup failures", () => | |
| 508 | + Effect.scoped( | |
| 509 | + Effect.gen(function* () { | |
| 510 | + const fixture = yield* makeHarness({ | |
| 511 | + threads: [thread("backfill", { settledOverride: "settled", settledAt: NOW })], | |
| 512 | + branchPullRequest: ({ cwd }) => | |
| 513 | + Effect.fail( | |
| 514 | + new GitManagerError({ operation: "branchPullRequest", cwd, detail: "No gh" }), | |
| 515 | + ), | |
| 516 | + }); | |
| 517 | + yield* Effect.gen(function* () { | |
| 518 | + const reactor = yield* fixture.start(); | |
| 519 | + for ( | |
| 520 | + let attempt = 1; | |
| 521 | + attempt < ThreadPullRequestReactor.BACKFILL_ATTEMPTS + 2; | |
| 522 | + attempt++ | |
| 523 | + ) { | |
| 524 | + yield* TestClock.adjust("1 minute"); | |
| 525 | + yield* Queue.take(fixture.reads); | |
| 526 | + yield* reactor.drain; | |
| 527 | + } | |
| 528 | + expect(yield* Ref.get(fixture.branchCalls)).toHaveLength( | |
| 529 | + ThreadPullRequestReactor.BACKFILL_ATTEMPTS, | |
| 530 | + ); | |
| 531 | + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 532 | + }).pipe(Effect.provide(fixture.layer)); | |
| 533 | + }), | |
| 534 | + ), | |
| 535 | + ); | |
| 536 | + | |
| 537 | + it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () => | |
| 538 | + Effect.scoped( | |
| 539 | + Effect.gen(function* () { | |
| 540 | + const fixture = yield* makeHarness({ | |
| 541 | + threads: [thread("azure")], | |
| 542 | + project: { | |
| 543 | + ...project, | |
| 544 | + repositoryIdentity: { | |
| 545 | + canonicalKey: "ssh.dev.azure.com/v3/org/project/repository", | |
| 546 | + displayName: "v3/org/project/repository", | |
| 547 | + name: "repository", | |
| 548 | + provider: "azure-devops", | |
| 549 | + rootPath: project.workspaceRoot, | |
| 550 | + locator: { | |
| 551 | + source: "git-remote", | |
| 552 | + remoteName: "origin", | |
| 553 | + remoteUrl: "git@ssh.dev.azure.com:v3/org/project/repository", | |
| 554 | + }, | |
| 555 | + }, | |
| 556 | + }, | |
| 557 | + branchPullRequest: () => | |
| 558 | + Effect.succeed({ | |
| 559 | + ...branchPullRequest(), | |
| 560 | + repositoryKey: "dev.azure.com/org/project/_git/repository", | |
| 561 | + url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", | |
| 562 | + }), | |
| 563 | + }); | |
| 564 | + yield* Effect.gen(function* () { | |
| 565 | + yield* fixture.start(); | |
| 566 | + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual({ | |
| 567 | + projectId: PROJECT_ID, | |
| 568 | + repository: "repository", | |
| 569 | + number: 42, | |
| 570 | + url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", | |
| 571 | + }); | |
| 572 | + }).pipe(Effect.provide(fixture.layer)); | |
| 573 | + }), | |
| 574 | + ), | |
| 575 | + ); | |
| 576 | + | |
| 577 | + it.effect.each(["primary", "branch"] as const)( | |
| 578 | + "rejects the group's links if the %s remote changes during a summary read", | |
| 579 | + (changedRemote) => | |
| 580 | + Effect.scoped( | |
| 581 | + Effect.gen(function* () { | |
| 582 | + const identity = yield* Ref.make(project.repositoryIdentity); | |
| 583 | + const detected = yield* Ref.make(branchPullRequest()); | |
| 584 | + const fixture = yield* makeHarness({ | |
| 585 | + threads: [thread("manual", { linkedPullRequest: reference(1) }), thread("automatic")], | |
| 586 | + branchPullRequest: () => Ref.get(detected), | |
| 587 | + resolveRepositoryIdentity: (_cwd, options) => | |
| 588 | + options?.refresh ? Ref.get(identity) : Effect.succeed(project.repositoryIdentity), | |
| 589 | + summary: (input) => | |
| 590 | + (changedRemote === "primary" | |
| 591 | + ? Ref.set(identity, { | |
| 592 | + ...project.repositoryIdentity, | |
| 593 | + canonicalKey: "github.com/other/repository", | |
| 594 | + displayName: "other/repository", | |
| 595 | + }) | |
| 596 | + : Ref.set(detected, { | |
| 597 | + ...branchPullRequest(99), | |
| 598 | + repositoryKey: "github.com/other/repository", | |
| 599 | + url: "https://github.com/other/repository/pull/99", | |
| 600 | + }) | |
| 601 | + ).pipe(Effect.as(summary(input, "merged"))), | |
| 602 | + }); | |
| 603 | + yield* Effect.gen(function* () { | |
| 604 | + yield* fixture.start(); | |
| 605 | + expect(yield* Ref.get(fixture.commands)).toEqual([]); | |
| 606 | + expect((yield* Ref.get(fixture.snapshots)).threads[0]?.linkedPullRequest).toEqual( | |
| 607 | + reference(1), | |
| 608 | + ); | |
| 609 | + }).pipe(Effect.provide(fixture.layer)); | |
| 610 | + }), | |
| 611 | + ), | |
| 612 | + ); | |
| 613 | +}); | |
| @@ -0,0 +1,371 @@ | ||
| 1 | +import { | |
| 2 | + CommandId, | |
| 3 | + type OrchestrationEvent, | |
| 4 | + type OrchestrationProjectShell, | |
| 5 | + type ThreadId, | |
| 6 | + type ThreadLinkedPullRequest, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | |
| 9 | +import * as Cause from "effect/Cause"; | |
| 10 | +import * as Context from "effect/Context"; | |
| 11 | +import * as Crypto from "effect/Crypto"; | |
| 12 | +import * as Effect from "effect/Effect"; | |
| 13 | +import * as FileSystem from "effect/FileSystem"; | |
| 14 | +import * as Layer from "effect/Layer"; | |
| 15 | +import * as Schedule from "effect/Schedule"; | |
| 16 | +import type * as Scope from "effect/Scope"; | |
| 17 | +import * as Stream from "effect/Stream"; | |
| 18 | + | |
| 19 | +import * as GitManager from "../git/GitManager.ts"; | |
| 20 | +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | |
| 21 | +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; | |
| 22 | +import { forkParked } from "../serverActivation.ts"; | |
| 23 | +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; | |
| 24 | +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; | |
| 25 | + | |
| 26 | +export class ThreadPullRequestReactor extends Context.Service< | |
| 27 | + ThreadPullRequestReactor, | |
| 28 | + { | |
| 29 | + readonly start: () => Effect.Effect<void, never, Scope.Scope>; | |
| 30 | + readonly drain: Effect.Effect<void>; | |
| 31 | + } | |
| 32 | +>()("t3/orchestration/ThreadPullRequestReactor") {} | |
| 33 | + | |
| 34 | +function samePullRequest( | |
| 35 | + left: ThreadLinkedPullRequest | null | undefined, | |
| 36 | + right: ThreadLinkedPullRequest | null, | |
| 37 | +): boolean { | |
| 38 | + if (left == null || right === null) return left == null && right === null; | |
| 39 | + return ( | |
| 40 | + left.projectId === right.projectId && | |
| 41 | + left.repository.toLowerCase() === right.repository.toLowerCase() && | |
| 42 | + left.number === right.number && | |
| 43 | + left.url === right.url | |
| 44 | + ); | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** Startup lookups per settled thread before discovery gives up on it. */ | |
| 48 | +export const BACKFILL_ATTEMPTS = 5; | |
| 49 | + | |
| 50 | +interface RefreshRequest { | |
| 51 | + readonly threadId: ThreadId | null; | |
| 52 | + readonly refresh: boolean; | |
| 53 | + readonly backfill?: boolean; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function canonicalRepositoryKey(key: string): string { | |
| 57 | + return key | |
| 58 | + .replace( | |
| 59 | + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, | |
| 60 | + "dev.azure.com/$1/$2/_git/$3", | |
| 61 | + ) | |
| 62 | + .replace( | |
| 63 | + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, | |
| 64 | + "dev.azure.com/$1/$2/_git/$3", | |
| 65 | + ); | |
| 66 | +} | |
| 67 | + | |
| 68 | +export function pullRequestMatchesProject( | |
| 69 | + pullRequest: GitManager.GitBranchPullRequest, | |
| 70 | + project: OrchestrationProjectShell, | |
| 71 | +): boolean { | |
| 72 | + return ( | |
| 73 | + pullRequest.repositoryKey !== null && | |
| 74 | + project.repositoryIdentity != null && | |
| 75 | + canonicalRepositoryKey(pullRequest.repositoryKey) === | |
| 76 | + canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) | |
| 77 | + ); | |
| 78 | +} | |
| 79 | + | |
| 80 | +export const make = Effect.gen(function* () { | |
| 81 | + const engine = yield* OrchestrationEngine.OrchestrationEngineService; | |
| 82 | + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | |
| 83 | + const git = yield* GitManager.GitManager; | |
| 84 | + const pullRequests = yield* PullRequestService.PullRequestService; | |
| 85 | + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 86 | + const crypto = yield* Crypto.Crypto; | |
| 87 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 88 | + // Settled threads get one link discovery at startup. Failed lookups retry on | |
| 89 | + // the periodic pass a few times, then stop until the thread changes or the | |
| 90 | + // server restarts, so a missing or logged-out CLI cannot loop forever. | |
| 91 | + const pendingBackfill = new Map<ThreadId, number>(); | |
| 92 | + const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { | |
| 93 | + for (const thread of threads) pendingBackfill.delete(thread.id); | |
| 94 | + }; | |
| 95 | + const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { | |
| 96 | + for (const thread of threads) { | |
| 97 | + const remaining = pendingBackfill.get(thread.id); | |
| 98 | + if (remaining === undefined) continue; | |
| 99 | + if (remaining <= 1) pendingBackfill.delete(thread.id); | |
| 100 | + else pendingBackfill.set(thread.id, remaining - 1); | |
| 101 | + } | |
| 102 | + }; | |
| 103 | + | |
| 104 | + const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( | |
| 105 | + request: RefreshRequest, | |
| 106 | + ) { | |
| 107 | + const snapshot = yield* snapshots.getShellSnapshot(); | |
| 108 | + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); | |
| 109 | + if (request.backfill) { | |
| 110 | + for (const thread of snapshot.threads) { | |
| 111 | + if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { | |
| 112 | + pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); | |
| 113 | + } | |
| 114 | + } | |
| 115 | + } | |
| 116 | + const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); | |
| 117 | + for (const threadId of pendingBackfill.keys()) { | |
| 118 | + if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); | |
| 119 | + } | |
| 120 | + const threads = snapshot.threads.filter( | |
| 121 | + (thread) => | |
| 122 | + thread.archivedAt === null && | |
| 123 | + (request.threadId === null || thread.id === request.threadId) && | |
| 124 | + (thread.settledOverride !== "settled" || | |
| 125 | + request.threadId !== null || | |
| 126 | + pendingBackfill.has(thread.id)) && | |
| 127 | + (thread.branch !== null || thread.branchPullRequest != null), | |
| 128 | + ); | |
| 129 | + const groups = Map.groupBy(threads, (thread) => | |
| 130 | + JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]), | |
| 131 | + ); | |
| 132 | + | |
| 133 | + yield* Effect.forEach( | |
| 134 | + groups.values(), | |
| 135 | + (group) => | |
| 136 | + Effect.gen(function* () { | |
| 137 | + const first = group[0]!; | |
| 138 | + const project = projects.get(first.projectId); | |
| 139 | + if (project === undefined) return finishBackfill(group); | |
| 140 | + const repository = PullRequestService.repositoryIdentityOf(project); | |
| 141 | + if (first.branch !== null && repository === null) return finishBackfill(group); | |
| 142 | + const worktreeExists = | |
| 143 | + first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); | |
| 144 | + const cwd = | |
| 145 | + worktreeExists && first.worktreePath !== null | |
| 146 | + ? first.worktreePath | |
| 147 | + : project.workspaceRoot; | |
| 148 | + const detected = | |
| 149 | + first.branch === null | |
| 150 | + ? null | |
| 151 | + : yield* git.branchPullRequest( | |
| 152 | + { cwd, branch: first.branch }, | |
| 153 | + { refresh: request.refresh }, | |
| 154 | + ); | |
| 155 | + // A worktree can have different remotes, and the project identity | |
| 156 | + // can lag a remote edit. Do not attach its PR to the wrong repository. | |
| 157 | + if (detected !== null && !pullRequestMatchesProject(detected, project)) { | |
| 158 | + return finishBackfill(group); | |
| 159 | + } | |
| 160 | + const detectedReference = | |
| 161 | + detected !== null && repository !== null | |
| 162 | + ? { | |
| 163 | + projectId: project.id, | |
| 164 | + repository, | |
| 165 | + number: detected.number, | |
| 166 | + url: detected.url, | |
| 167 | + } | |
| 168 | + : null; | |
| 169 | + | |
| 170 | + const plans = yield* Effect.forEach(group, (thread) => | |
| 171 | + Effect.gen(function* () { | |
| 172 | + let branchPullRequest = detectedReference; | |
| 173 | + // Shared checkouts often return to the default branch after | |
| 174 | + // a merge. Keep that thread's terminal PR across the change. | |
| 175 | + if ( | |
| 176 | + branchPullRequest === null && | |
| 177 | + thread.branch !== null && | |
| 178 | + thread.worktreePath === null && | |
| 179 | + thread.branchPullRequest != null | |
| 180 | + ) { | |
| 181 | + const previous = yield* pullRequests.summary(thread.branchPullRequest, { | |
| 182 | + recoverTransientFailure: false, | |
| 183 | + }); | |
| 184 | + if (previous.state === "merged" || previous.state === "closed") { | |
| 185 | + branchPullRequest = thread.branchPullRequest; | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + let replacement: ThreadLinkedPullRequest | undefined; | |
| 190 | + if ( | |
| 191 | + thread.linkedPullRequest != null && | |
| 192 | + detected?.state === "open" && | |
| 193 | + detectedReference !== null && | |
| 194 | + !samePullRequest(thread.linkedPullRequest, detectedReference) | |
| 195 | + ) { | |
| 196 | + const linked = yield* pullRequests.summary(thread.linkedPullRequest, { | |
| 197 | + recoverTransientFailure: false, | |
| 198 | + }); | |
| 199 | + if (linked.state === "merged" || linked.state === "closed") { | |
| 200 | + replacement = detectedReference; | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + if ( | |
| 205 | + samePullRequest(thread.branchPullRequest, branchPullRequest) && | |
| 206 | + replacement === undefined | |
| 207 | + ) { | |
| 208 | + pendingBackfill.delete(thread.id); | |
| 209 | + return null; | |
| 210 | + } | |
| 211 | + return { thread, branchPullRequest, replacement }; | |
| 212 | + }).pipe( | |
| 213 | + Effect.catchCause((cause) => | |
| 214 | + Cause.hasInterruptsOnly(cause) | |
| 215 | + ? Effect.failCause(cause) | |
| 216 | + : Effect.logWarning("thread pull request discovery failed", { | |
| 217 | + threadId: thread.id, | |
| 218 | + cause: Cause.pretty(cause), | |
| 219 | + }).pipe( | |
| 220 | + Effect.tap(() => Effect.sync(() => failBackfill([thread]))), | |
| 221 | + Effect.as(null), | |
| 222 | + ), | |
| 223 | + ), | |
| 224 | + ), | |
| 225 | + ); | |
| 226 | + const updates = plans.filter((plan) => plan !== null); | |
| 227 | + if (updates.length === 0) return; | |
| 228 | + | |
| 229 | + if (detected !== null && first.branch !== null) { | |
| 230 | + // Summary reads can outlast a remote edit. Recheck the branch and | |
| 231 | + // the project's primary remote before saving the group's links. | |
| 232 | + const current = yield* git.branchPullRequest({ cwd, branch: first.branch }); | |
| 233 | + const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { | |
| 234 | + refresh: true, | |
| 235 | + }); | |
| 236 | + if ( | |
| 237 | + current === null || | |
| 238 | + current.number !== detected.number || | |
| 239 | + current.url !== detected.url || | |
| 240 | + current.state !== detected.state || | |
| 241 | + current.repositoryKey !== detected.repositoryKey || | |
| 242 | + !pullRequestMatchesProject(current, { | |
| 243 | + ...project, | |
| 244 | + repositoryIdentity: currentIdentity, | |
| 245 | + }) | |
| 246 | + ) { | |
| 247 | + return failBackfill(updates.map((update) => update.thread)); | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + yield* Effect.forEach( | |
| 252 | + updates, | |
| 253 | + ({ thread, branchPullRequest, replacement }) => | |
| 254 | + Effect.gen(function* () { | |
| 255 | + const uuid = yield* crypto.randomUUIDv4; | |
| 256 | + yield* engine.dispatch({ | |
| 257 | + type: "thread.pull-request.sync", | |
| 258 | + commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`), | |
| 259 | + threadId: thread.id, | |
| 260 | + projectId: project.id, | |
| 261 | + snapshotSequence: snapshot.snapshotSequence, | |
| 262 | + expected: { | |
| 263 | + workspaceRoot: project.workspaceRoot, | |
| 264 | + branch: thread.branch, | |
| 265 | + worktreePath: thread.worktreePath, | |
| 266 | + linkedPullRequest: thread.linkedPullRequest ?? null, | |
| 267 | + branchPullRequest: thread.branchPullRequest ?? null, | |
| 268 | + }, | |
| 269 | + branchPullRequest, | |
| 270 | + ...(replacement !== undefined ? { linkedPullRequest: replacement } : {}), | |
| 271 | + }); | |
| 272 | + pendingBackfill.delete(thread.id); | |
| 273 | + }).pipe( | |
| 274 | + // The thread changed since the lookup. Its own events requeue it. | |
| 275 | + Effect.catchTags({ | |
| 276 | + OrchestrationCommandInvariantError: () => | |
| 277 | + Effect.sync(() => finishBackfill([thread])), | |
| 278 | + }), | |
| 279 | + Effect.catchCause((cause) => | |
| 280 | + Cause.hasInterruptsOnly(cause) | |
| 281 | + ? Effect.failCause(cause) | |
| 282 | + : Effect.logWarning("thread pull request update failed", { | |
| 283 | + threadId: thread.id, | |
| 284 | + cause: Cause.pretty(cause), | |
| 285 | + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), | |
| 286 | + ), | |
| 287 | + ), | |
| 288 | + { discard: true }, | |
| 289 | + ); | |
| 290 | + }).pipe( | |
| 291 | + Effect.catchCause((cause) => | |
| 292 | + Cause.hasInterruptsOnly(cause) | |
| 293 | + ? Effect.failCause(cause) | |
| 294 | + : Effect.logWarning("thread branch pull request lookup failed", { | |
| 295 | + threadIds: group.map((thread) => thread.id), | |
| 296 | + cause: Cause.pretty(cause), | |
| 297 | + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), | |
| 298 | + ), | |
| 299 | + ), | |
| 300 | + { concurrency: 8, discard: true }, | |
| 301 | + ); | |
| 302 | + }); | |
| 303 | + | |
| 304 | + const worker = yield* makeDrainableWorker((request: RefreshRequest) => | |
| 305 | + synchronize(request).pipe( | |
| 306 | + Effect.catchCause((cause) => | |
| 307 | + Cause.hasInterruptsOnly(cause) | |
| 308 | + ? Effect.failCause(cause) | |
| 309 | + : Effect.logWarning("thread pull request refresh failed", { | |
| 310 | + cause: Cause.pretty(cause), | |
| 311 | + }), | |
| 312 | + ), | |
| 313 | + ), | |
| 314 | + ); | |
| 315 | + | |
| 316 | + const processEvent = (event: OrchestrationEvent) => { | |
| 317 | + switch (event.type) { | |
| 318 | + case "thread.created": | |
| 319 | + case "thread.unarchived": | |
| 320 | + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); | |
| 321 | + case "thread.meta-updated": | |
| 322 | + if ( | |
| 323 | + event.payload.branchPullRequest === undefined && | |
| 324 | + (event.payload.branch !== undefined || | |
| 325 | + event.payload.worktreePath !== undefined || | |
| 326 | + event.payload.linkedPullRequest !== undefined) | |
| 327 | + ) { | |
| 328 | + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); | |
| 329 | + } | |
| 330 | + break; | |
| 331 | + case "thread.session-set": | |
| 332 | + if ( | |
| 333 | + event.payload.session.status !== "running" && | |
| 334 | + event.payload.session.status !== "starting" | |
| 335 | + ) { | |
| 336 | + return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); | |
| 337 | + } | |
| 338 | + break; | |
| 339 | + case "thread.turn-diff-completed": | |
| 340 | + case "thread.unsettled": | |
| 341 | + return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); | |
| 342 | + case "project.meta-updated": | |
| 343 | + if (event.payload.workspaceRoot !== undefined) { | |
| 344 | + return worker.enqueue({ threadId: null, refresh: false }); | |
| 345 | + } | |
| 346 | + break; | |
| 347 | + } | |
| 348 | + return Effect.void; | |
| 349 | + }; | |
| 350 | + | |
| 351 | + const start = Effect.fn("ThreadPullRequestReactor.start")(function* () { | |
| 352 | + const events = yield* engine.subscribeDomainEvents; | |
| 353 | + yield* forkParked(Stream.runForEach(events, processEvent)); | |
| 354 | + // Run without client demand. Saved branch lookups share GitManager's | |
| 355 | + // provider cache and retry backoff with status and automatic settlement. | |
| 356 | + yield* forkParked( | |
| 357 | + Effect.gen(function* () { | |
| 358 | + yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); | |
| 359 | + yield* worker.drain; | |
| 360 | + yield* Effect.gen(function* () { | |
| 361 | + yield* worker.enqueue({ threadId: null, refresh: false }); | |
| 362 | + yield* worker.drain; | |
| 363 | + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute")); | |
| 364 | + }).pipe(Effect.asVoid), | |
| 365 | + ); | |
| 366 | + }); | |
| 367 | + | |
| 368 | + return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"]; | |
| 369 | +}); | |
| 370 | + | |
| 371 | +export const layer = Layer.effect(ThreadPullRequestReactor, make); | |
| @@ -25,7 +25,7 @@ import * as Ref from "effect/Ref"; · 24 unmodified lines | ||
| 25 | 25 | import * as Stream from "effect/Stream"; |
| 26 | 26 | import { TestClock } from "effect/testing"; |
| 27 | 27 | |
| 28 | −import { GitManager } from "../git/GitManager.ts"; | |
| 28 | +import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; | |
| 29 | 29 | import { |
| 30 | 30 | PullRequestService, |
| 31 | 31 | type PullRequestMergeEvent, |
| @@ -132,6 +132,24 @@ function makePullRequestSummary(input: { · 100 unmodified lines | ||
| 132 | 132 | }; |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | +function makeBranchPullRequest( | |
| 136 | + state: GitBranchPullRequest["state"], | |
| 137 | + updatedAt: string | null = NOW, | |
| 138 | +): GitBranchPullRequest { | |
| 139 | + return { | |
| 140 | + number: 42, | |
| 141 | + title: "Branch pull request", | |
| 142 | + url: "https://example.test/owner/repository/pull/42", | |
| 143 | + baseRef: "main", | |
| 144 | + headRef: "saved-feature", | |
| 145 | + repositoryKey: "example.test/owner/repository", | |
| 146 | + state, | |
| 147 | + updatedAt, | |
| 148 | + closedAt: state === "closed" ? updatedAt : null, | |
| 149 | + mergedAt: state === "merged" ? updatedAt : null, | |
| 150 | + }; | |
| 151 | +} | |
| 152 | + | |
| 135 | 153 | interface HarnessOptions { |
| 136 | 154 | readonly snapshot: OrchestrationShellSnapshot; |
| 137 | 155 | readonly settings?: ServerSettings; |
| @@ -173,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 35 unmodified lines | ||
| 173 | 191 | return next; |
| 174 | 192 | }); |
| 175 | 193 | |
| 176 | − const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => | |
| 194 | + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input, readOptions) => | |
| 177 | 195 | Ref.update(branchCalls, (calls) => [...calls, input]).pipe( |
| 178 | − Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), | |
| 196 | + Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), | |
| 179 | 197 | ); |
| 180 | 198 | const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => |
| 181 | 199 | Effect.gen(function* () { |
| @@ -281,6 +299,83 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( · 99 unmodified lines | ||
| 281 | 299 | }); |
| 282 | 300 | |
| 283 | 301 | describe("ThreadSettlementReactor", () => { |
| 302 | + it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => | |
| 303 | + Effect.scoped( | |
| 304 | + Effect.gen(function* () { | |
| 305 | + yield* TestClock.setTime(Date.parse(NOW)); | |
| 306 | + const previous = { | |
| 307 | + projectId: PROJECT_ID, | |
| 308 | + repository: "owner/repository", | |
| 309 | + number: 1, | |
| 310 | + url: "https://example.test/owner/repository/pull/1", | |
| 311 | + }; | |
| 312 | + const project = { | |
| 313 | + ...makeProject(), | |
| 314 | + repositoryIdentity: { | |
| 315 | + canonicalKey: "example.test/owner/repository", | |
| 316 | + rootPath: "/workspace/project", | |
| 317 | + displayName: "owner/repository", | |
| 318 | + locator: { | |
| 319 | + source: "git-remote" as const, | |
| 320 | + remoteName: "origin", | |
| 321 | + remoteUrl: "https://example.test/owner/repository.git", | |
| 322 | + }, | |
| 323 | + }, | |
| 324 | + }; | |
| 325 | + const fixture = yield* makeHarness({ | |
| 326 | + snapshot: makeSnapshot( | |
| 327 | + [ | |
| 328 | + makeThread("retained-terminal", { branch: "main", branchPullRequest: previous }), | |
| 329 | + makeThread("reused-manual", { branch: "reused", linkedPullRequest: previous }), | |
| 330 | + makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }), | |
| 331 | + makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }), | |
| 332 | + makeThread("resumed-manual", { | |
| 333 | + branch: "main", | |
| 334 | + linkedPullRequest: previous, | |
| 335 | + latestUserMessageAt: "2026-08-28T00:00:00.000Z", | |
| 336 | + }), | |
| 337 | + makeThread("resumed-detected", { | |
| 338 | + branch: "main", | |
| 339 | + branchPullRequest: previous, | |
| 340 | + latestUserMessageAt: "2026-08-28T00:00:00.000Z", | |
| 341 | + }), | |
| 342 | + ], | |
| 343 | + [project], | |
| 344 | + ), | |
| 345 | + settings: { | |
| 346 | + ...DEFAULT_SERVER_SETTINGS, | |
| 347 | + sidebarAutoSettleAfterDays: null, | |
| 348 | + sidebarAutoSettleOnMerge: true, | |
| 349 | + }, | |
| 350 | + branchPullRequest: ({ branch }, options) => | |
| 351 | + Effect.succeed( | |
| 352 | + branch === "reused" | |
| 353 | + ? makeBranchPullRequest(options?.refresh ? "open" : "merged") | |
| 354 | + : branch === "foreign" | |
| 355 | + ? { | |
| 356 | + ...makeBranchPullRequest("open"), | |
| 357 | + repositoryKey: "example.test/another/repository", | |
| 358 | + } | |
| 359 | + : null, | |
| 360 | + ), | |
| 361 | + pullRequestSummary: (input) => | |
| 362 | + Effect.succeed({ | |
| 363 | + ...makePullRequestSummary({ ...input, state: "merged" }), | |
| 364 | + mergedAt: "2026-08-27T00:00:00.000Z", | |
| 365 | + }), | |
| 366 | + }); | |
| 367 | + yield* Effect.gen(function* () { | |
| 368 | + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 369 | + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 370 | + assert.deepStrictEqual( | |
| 371 | + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), | |
| 372 | + new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]), | |
| 373 | + ); | |
| 374 | + }).pipe(Effect.provide(fixture.layer)); | |
| 375 | + }), | |
| 376 | + ), | |
| 377 | + ); | |
| 378 | + | |
| 284 | 379 | it.effect("starts without clients and skips protected threads before pull request lookup", () => |
| 285 | 380 | Effect.scoped( |
| 286 | 381 | Effect.gen(function* () { |
| @@ -375,9 +470,7 @@ describe("ThreadSettlementReactor", () => { · 88 unmodified lines | ||
| 375 | 470 | }), |
| 376 | 471 | ]), |
| 377 | 472 | branchPullRequest: () => |
| 378 | − Ref.get(pullRequest).pipe( | |
| 379 | − Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })), | |
| 380 | − ), | |
| 473 | + Ref.get(pullRequest).pipe(Effect.map((state) => makeBranchPullRequest(state))), | |
| 381 | 474 | }); |
| 382 | 475 | |
| 383 | 476 | yield* Effect.gen(function* () { |
| @@ -430,10 +523,10 @@ describe("ThreadSettlementReactor", () => { · 46 unmodified lines | ||
| 430 | 523 | Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( |
| 431 | 524 | Effect.flatMap((count) => |
| 432 | 525 | count === 1 |
| 433 | − ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) | |
| 526 | + ? Effect.succeed(makeBranchPullRequest("open")) | |
| 434 | 527 | : Deferred.succeed(periodicLookupStarted, undefined).pipe( |
| 435 | 528 | Effect.andThen(Deferred.await(releasePeriodicLookup)), |
| 436 | − Effect.as({ state: "open" as const, updatedAt: NOW }), | |
| 529 | + Effect.as(makeBranchPullRequest("open")), | |
| 437 | 530 | ), |
| 438 | 531 | ), |
| 439 | 532 | ), |
| @@ -477,12 +570,7 @@ describe("ThreadSettlementReactor", () => { · 37 unmodified lines | ||
| 477 | 570 | ]), |
| 478 | 571 | branchPullRequest: () => |
| 479 | 572 | Ref.get(state).pipe( |
| 480 | − Effect.map((pullRequestState) => ({ | |
| 481 | − state: pullRequestState, | |
| 482 | − updatedAt: NOW, | |
| 483 | − closedAt: NOW, | |
| 484 | − mergedAt: NOW, | |
| 485 | − })), | |
| 573 | + Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), | |
| 486 | 574 | ), |
| 487 | 575 | onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), |
| 488 | 576 | }); |
| @@ -618,12 +706,7 @@ describe("ThreadSettlementReactor", () => { · 129 unmodified lines | ||
| 618 | 706 | : Effect.void, |
| 619 | 707 | ), |
| 620 | 708 | Effect.andThen(Ref.get(state)), |
| 621 | − Effect.map((pullRequestState) => ({ | |
| 622 | − state: pullRequestState, | |
| 623 | − updatedAt: NOW, | |
| 624 | − closedAt: NOW, | |
| 625 | − mergedAt: NOW, | |
| 626 | − })), | |
| 709 | + Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), | |
| 627 | 710 | ), |
| 628 | 711 | }); |
| 629 | 712 | |
| @@ -896,8 +979,7 @@ describe("ThreadSettlementReactor", () => { · 266 unmodified lines | ||
| 896 | 979 | makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), |
| 897 | 980 | ], |
| 898 | 981 | ), |
| 899 | − branchPullRequest: () => | |
| 900 | − Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }), | |
| 982 | + branchPullRequest: () => Effect.succeed(makeBranchPullRequest("closed")), | |
| 901 | 983 | pullRequestSummary: (input) => |
| 902 | 984 | Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), |
| 903 | 985 | }); |
| @@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts"; · 16 unmodified lines | ||
| 17 | 17 | import { forkParked } from "../serverActivation.ts"; |
| 18 | 18 | import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; |
| 19 | 19 | import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; |
| 20 | +import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; | |
| 20 | 21 | import { |
| 21 | 22 | isAutoSettlementCandidate, |
| 22 | 23 | resolveAutoSettlementAt, |
| @@ -46,10 +47,8 @@ export const make = Effect.gen(function* () { · 23 unmodified lines | ||
| 46 | 47 | const snapshot = yield* snapshots.getShellSnapshot(); |
| 47 | 48 | const now = DateTime.formatIso(yield* DateTime.now); |
| 48 | 49 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); |
| 49 | − // A merge event re-sweeps every candidate, not just the threads linked to | |
| 50 | − // the merged pull request: most threads carry no link and settle from | |
| 51 | − // their branch lookup, which would otherwise wait for the next minute's | |
| 52 | − // sweep on a possibly stale cached answer. | |
| 50 | + // A merge rechecks all candidates, including branches that discovery has | |
| 51 | + // not linked yet. Those lookups can still have cached the PR as open. | |
| 53 | 52 | const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); |
| 54 | 53 | |
| 55 | 54 | // Return the thread when it still needs a pull request decision. A rejected |
| @@ -101,14 +100,14 @@ export const make = Effect.gen(function* () { · 45 unmodified lines | ||
| 101 | 100 | }, |
| 102 | 101 | )).filter((thread) => thread !== null); |
| 103 | 102 | |
| 104 | − // Use the same cwd as the sidebar so both paths share GitManager's PR cache. | |
| 103 | + // Use the same cwd as PR discovery so both paths share GitManager's cache. | |
| 105 | 104 | const lookupCwdByThreadId = new Map<string, string>(); |
| 106 | 105 | yield* Effect.forEach( |
| 107 | 106 | lookupCandidates, |
| 108 | 107 | (thread) => |
| 109 | 108 | Effect.gen(function* () { |
| 110 | 109 | const project = projects.get(thread.projectId); |
| 111 | − if (project === undefined || thread.linkedPullRequest != null) return; | |
| 110 | + if (project === undefined || thread.branch === null) return; | |
| 112 | 111 | const worktreeExists = |
| 113 | 112 | thread.worktreePath !== null && |
| 114 | 113 | (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); |
| @@ -122,12 +121,8 @@ export const make = Effect.gen(function* () { · 7 unmodified lines | ||
| 122 | 121 | { concurrency: 8, discard: true }, |
| 123 | 122 | ); |
| 124 | 123 | if (mergedPullRequest !== null) { |
| 125 | − // The merge just confirmed a terminal state the lookup caches can still | |
| 126 | − // call open (branch answers live two minutes, the sweep runs every | |
| 127 | − // minute). Drop the swept checkouts' cached answers so the merge settles | |
| 128 | − // its branch threads now instead of on a later sweep. Threads linked to | |
| 129 | − // the merged pull request settle from the event itself below and need no | |
| 130 | − // lookup, so they are absent from this map by construction. | |
| 124 | + // The merge confirmed a state the branch cache can still call open. | |
| 125 | + // Recheck those branches now instead of waiting for cache expiry. | |
| 131 | 126 | const cwds = [...new Set(lookupCwdByThreadId.values())]; |
| 132 | 127 | yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { |
| 133 | 128 | concurrency: 8, |
| @@ -135,12 +130,15 @@ export const make = Effect.gen(function* () { · 1 unmodified line | ||
| 135 | 130 | }); |
| 136 | 131 | } |
| 137 | 132 | const lookupKey = (thread: (typeof candidates)[number]) => { |
| 138 | − if (thread.linkedPullRequest != null) { | |
| 133 | + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; | |
| 134 | + if (reference != null) { | |
| 139 | 135 | return JSON.stringify([ |
| 140 | 136 | "linked", |
| 141 | − thread.linkedPullRequest.projectId, | |
| 142 | − thread.linkedPullRequest.repository, | |
| 143 | − thread.linkedPullRequest.number, | |
| 137 | + reference.projectId, | |
| 138 | + reference.repository, | |
| 139 | + reference.number, | |
| 140 | + lookupCwdByThreadId.get(thread.id), | |
| 141 | + thread.branch, | |
| 144 | 142 | ]); |
| 145 | 143 | } |
| 146 | 144 | if (thread.branch === null) return JSON.stringify(["none", thread.id]); |
| @@ -154,35 +152,47 @@ export const make = Effect.gen(function* () { · 7 unmodified lines | ||
| 154 | 152 | const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( |
| 155 | 153 | thread: (typeof candidates)[number], |
| 156 | 154 | ) { |
| 157 | − if (thread.linkedPullRequest != null) { | |
| 158 | − // The event carries the merged state, so only the threads linked to | |
| 159 | − // that exact pull request settle from it. Every other linked thread | |
| 160 | − // falls through to a fresh summary lookup below: the merge sweep | |
| 161 | − // covers all candidates, and an unrelated merge must never settle | |
| 162 | − // them. | |
| 163 | − if ( | |
| 155 | + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; | |
| 156 | + if (reference != null) { | |
| 157 | + const matchesMerge = | |
| 164 | 158 | mergedPullRequest !== null && |
| 165 | − thread.linkedPullRequest.projectId === mergedPullRequest.projectId && | |
| 166 | − thread.linkedPullRequest.repository.toLowerCase() === | |
| 167 | − mergedPullRequest.repository.toLowerCase() && | |
| 168 | − thread.linkedPullRequest.number === mergedPullRequest.number | |
| 169 | − ) { | |
| 170 | − return { | |
| 171 | − state: "merged", | |
| 172 | − mergedAt: mergedPullRequest.mergedAt, | |
| 173 | − } satisfies SettlementPullRequest; | |
| 174 | − } | |
| 175 | − if (!projects.has(thread.linkedPullRequest.projectId)) { | |
| 159 | + reference.projectId === mergedPullRequest.projectId && | |
| 160 | + reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() && | |
| 161 | + reference.number === mergedPullRequest.number; | |
| 162 | + if (!matchesMerge && !projects.has(reference.projectId)) { | |
| 176 | 163 | return yield* Effect.die(new Error("linked pull request project not found")); |
| 177 | 164 | } |
| 178 | − const summary = yield* pullRequests.summary( | |
| 179 | − { | |
| 180 | − projectId: thread.linkedPullRequest.projectId, | |
| 181 | − repository: thread.linkedPullRequest.repository, | |
| 182 | − number: thread.linkedPullRequest.number, | |
| 183 | − }, | |
| 184 | − { recoverTransientFailure: false }, | |
| 185 | − ); | |
| 165 | + const summary = matchesMerge | |
| 166 | + ? ({ | |
| 167 | + state: "merged", | |
| 168 | + closedAt: null, | |
| 169 | + mergedAt: mergedPullRequest.mergedAt, | |
| 170 | + } satisfies SettlementPullRequest) | |
| 171 | + : yield* pullRequests.summary( | |
| 172 | + { | |
| 173 | + projectId: reference.projectId, | |
| 174 | + repository: reference.repository, | |
| 175 | + number: reference.number, | |
| 176 | + }, | |
| 177 | + { recoverTransientFailure: false }, | |
| 178 | + ); | |
| 179 | + const cwd = lookupCwdByThreadId.get(thread.id); | |
| 180 | + if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { | |
| 181 | + // A reused branch can already have a new open PR while discovery | |
| 182 | + // is replacing its old link. Do not let settlement win that race. | |
| 183 | + const current = yield* git.branchPullRequest( | |
| 184 | + { cwd, branch: thread.branch }, | |
| 185 | + { refresh: true }, | |
| 186 | + ); | |
| 187 | + const project = projects.get(thread.projectId); | |
| 188 | + if ( | |
| 189 | + current?.state === "open" && | |
| 190 | + project !== undefined && | |
| 191 | + pullRequestMatchesProject(current, project) | |
| 192 | + ) { | |
| 193 | + return current; | |
| 194 | + } | |
| 195 | + } | |
| 186 | 196 | return { |
| 187 | 197 | state: summary.state, |
| 188 | 198 | closedAt: summary.closedAt ?? null, |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | EventId, |
| 3 | 3 | MessageId, |
| 4 | + ThreadLinkedPullRequest, | |
| 4 | 5 | UserInputRequestedPayload, |
| 5 | 6 | isImportedAgentSessionMessageId, |
| 6 | 7 | type OrchestrationCommand, |
| @@ -38,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; · 31 unmodified lines | ||
| 38 | 39 | |
| 39 | 40 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 40 | 41 | const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); |
| 42 | +const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); | |
| 41 | 43 | |
| 42 | 44 | /** |
| 43 | 45 | * Blocked-on-you work derived from the thread's retained activities: an |
| @@ -836,6 +838,63 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 792 unmodified lines | ||
| 836 | 838 | }; |
| 837 | 839 | } |
| 838 | 840 | |
| 841 | + case "thread.pull-request.sync": { | |
| 842 | + const thread = yield* requireThreadNotArchived({ | |
| 843 | + readModel, | |
| 844 | + command, | |
| 845 | + threadId: command.threadId, | |
| 846 | + }); | |
| 847 | + if (thread.deletedAt !== null) { | |
| 848 | + return yield* new OrchestrationCommandInvariantError({ | |
| 849 | + commandType: command.type, | |
| 850 | + detail: `thread ${command.threadId} was deleted before pull request discovery`, | |
| 851 | + }); | |
| 852 | + } | |
| 853 | + if ( | |
| 854 | + thread.projectId !== command.projectId || | |
| 855 | + thread.branch !== command.expected.branch || | |
| 856 | + thread.worktreePath !== command.expected.worktreePath || | |
| 857 | + !threadPullRequestLinksEqual( | |
| 858 | + thread.linkedPullRequest ?? null, | |
| 859 | + command.expected.linkedPullRequest, | |
| 860 | + ) || | |
| 861 | + !threadPullRequestLinksEqual( | |
| 862 | + thread.branchPullRequest ?? null, | |
| 863 | + command.expected.branchPullRequest, | |
| 864 | + ) | |
| 865 | + ) { | |
| 866 | + return yield* new OrchestrationCommandInvariantError({ | |
| 867 | + commandType: command.type, | |
| 868 | + detail: `thread ${command.threadId} changed before pull request discovery`, | |
| 869 | + }); | |
| 870 | + } | |
| 871 | + const project = yield* requireProject({ readModel, command, projectId: command.projectId }); | |
| 872 | + if (project.deletedAt !== null || project.workspaceRoot !== command.expected.workspaceRoot) { | |
| 873 | + return yield* new OrchestrationCommandInvariantError({ | |
| 874 | + commandType: command.type, | |
| 875 | + detail: `project ${command.projectId} changed before pull request discovery`, | |
| 876 | + }); | |
| 877 | + } | |
| 878 | + const occurredAt = yield* nowIso; | |
| 879 | + return { | |
| 880 | + ...(yield* withEventBase({ | |
| 881 | + aggregateKind: "thread", | |
| 882 | + aggregateId: command.threadId, | |
| 883 | + occurredAt, | |
| 884 | + commandId: command.commandId, | |
| 885 | + })), | |
| 886 | + type: "thread.meta-updated", | |
| 887 | + payload: { | |
| 888 | + threadId: command.threadId, | |
| 889 | + branchPullRequest: command.branchPullRequest, | |
| 890 | + ...(command.linkedPullRequest !== undefined | |
| 891 | + ? { linkedPullRequest: command.linkedPullRequest } | |
| 892 | + : {}), | |
| 893 | + updatedAt: thread.updatedAt, | |
| 894 | + }, | |
| 895 | + }; | |
| 896 | + } | |
| 897 | + | |
| 839 | 898 | case "thread.title.regeneration.complete": { |
| 840 | 899 | const thread = yield* requireThread({ |
| 841 | 900 | readModel, |
| @@ -86,6 +86,7 @@ describe("orchestration projector", () => { · 85 unmodified lines | ||
| 86 | 86 | interactionMode: "default", |
| 87 | 87 | branch: null, |
| 88 | 88 | worktreePath: null, |
| 89 | + branchPullRequest: null, | |
| 89 | 90 | latestTurn: null, |
| 90 | 91 | createdAt: now, |
| 91 | 92 | updatedAt: now, |
| @@ -105,6 +106,67 @@ describe("orchestration projector", () => { · 13 unmodified lines | ||
| 105 | 106 | ]); |
| 106 | 107 | }); |
| 107 | 108 | |
| 109 | + effectIt.effect("sets and clears branch pull requests without changing manual links", () => | |
| 110 | + Effect.gen(function* () { | |
| 111 | + const now = "2026-01-01T00:00:00.000Z"; | |
| 112 | + const eventFields = { | |
| 113 | + aggregateKind: "thread" as const, | |
| 114 | + aggregateId: "thread-1", | |
| 115 | + occurredAt: now, | |
| 116 | + commandId: null, | |
| 117 | + }; | |
| 118 | + let model = yield* projectEvent( | |
| 119 | + createEmptyReadModel(now), | |
| 120 | + makeEvent({ | |
| 121 | + ...eventFields, | |
| 122 | + sequence: 1, | |
| 123 | + type: "thread.created", | |
| 124 | + payload: { | |
| 125 | + threadId: "thread-1", | |
| 126 | + projectId: "project-1", | |
| 127 | + title: "Pull request thread", | |
| 128 | + modelSelection: { provider: "codex", model: "gpt-5-codex" }, | |
| 129 | + runtimeMode: "full-access", | |
| 130 | + branch: "feature", | |
| 131 | + worktreePath: null, | |
| 132 | + createdAt: now, | |
| 133 | + updatedAt: now, | |
| 134 | + }, | |
| 135 | + }), | |
| 136 | + ); | |
| 137 | + const linkedPullRequest = { | |
| 138 | + projectId: "project-1", | |
| 139 | + repository: "pingdotgg/t3code", | |
| 140 | + number: 42, | |
| 141 | + url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 142 | + }; | |
| 143 | + const branchPullRequest = { | |
| 144 | + ...linkedPullRequest, | |
| 145 | + number: 43, | |
| 146 | + url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 147 | + }; | |
| 148 | + const updates = [ | |
| 149 | + { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, | |
| 150 | + { payload: { title: "Renamed thread" }, expected: branchPullRequest }, | |
| 151 | + { payload: { branchPullRequest: null }, expected: null }, | |
| 152 | + ]; | |
| 153 | + | |
| 154 | + for (const [index, update] of updates.entries()) { | |
| 155 | + model = yield* projectEvent( | |
| 156 | + model, | |
| 157 | + makeEvent({ | |
| 158 | + ...eventFields, | |
| 159 | + sequence: index + 2, | |
| 160 | + type: "thread.meta-updated", | |
| 161 | + payload: { threadId: "thread-1", updatedAt: now, ...update.payload }, | |
| 162 | + }), | |
| 163 | + ); | |
| 164 | + expect(model.threads[0]?.branchPullRequest).toEqual(update.expected); | |
| 165 | + expect(model.threads[0]?.linkedPullRequest).toEqual(linkedPullRequest); | |
| 166 | + } | |
| 167 | + }), | |
| 168 | + ); | |
| 169 | + | |
| 108 | 170 | it("fails when event payload cannot be decoded by runtime schema", async () => { |
| 109 | 171 | const now = "2026-01-01T00:00:00.000Z"; |
| 110 | 172 | const model = createEmptyReadModel(now); |
| @@ -336,6 +336,7 @@ export function projectEvent( · 335 unmodified lines | ||
| 336 | 336 | interactionMode: payload.interactionMode, |
| 337 | 337 | branch: payload.branch, |
| 338 | 338 | worktreePath: payload.worktreePath, |
| 339 | + branchPullRequest: null, | |
| 339 | 340 | latestTurn: null, |
| 340 | 341 | createdAt: payload.createdAt, |
| 341 | 342 | updatedAt: payload.updatedAt, |
| @@ -510,6 +511,9 @@ export function projectEvent( · 168 unmodified lines | ||
| 510 | 511 | ...(payload.linkedPullRequest !== undefined |
| 511 | 512 | ? { linkedPullRequest: payload.linkedPullRequest } |
| 512 | 513 | : {}), |
| 514 | + ...(payload.branchPullRequest !== undefined | |
| 515 | + ? { branchPullRequest: payload.branchPullRequest } | |
| 516 | + : {}), | |
| 513 | 517 | updatedAt: payload.updatedAt, |
| 514 | 518 | }), |
| 515 | 519 | })), |
| @@ -462,7 +462,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 461 unmodified lines | ||
| 462 | 462 | }), |
| 463 | 463 | ); |
| 464 | 464 | |
| 465 | − it.effect("round-trips a linked pull request through the thread row", () => | |
| 465 | + it.effect("round-trips manual and branch pull requests through the thread row", () => | |
| 466 | 466 | Effect.gen(function* () { |
| 467 | 467 | const threads = yield* ProjectionThreadRepository; |
| 468 | 468 | const linkedPullRequest = { |
| @@ -471,6 +471,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 2 unmodified lines | ||
| 471 | 471 | number: 42, |
| 472 | 472 | url: "https://github.com/pingdotgg/t3code/pull/42", |
| 473 | 473 | }; |
| 474 | + const branchPullRequest = { | |
| 475 | + ...linkedPullRequest, | |
| 476 | + number: 43, | |
| 477 | + url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 478 | + }; | |
| 474 | 479 | |
| 475 | 480 | yield* threads.upsert({ |
| 476 | 481 | threadId: ThreadId.make("thread-linked-pr"), |
| @@ -485,6 +490,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 8 unmodified lines | ||
| 485 | 490 | branch: null, |
| 486 | 491 | worktreePath: null, |
| 487 | 492 | linkedPullRequest, |
| 493 | + branchPullRequest, | |
| 488 | 494 | latestTurnId: null, |
| 489 | 495 | createdAt: "2026-03-24T00:00:00.000Z", |
| 490 | 496 | updatedAt: "2026-03-24T00:00:00.000Z", |
| @@ -504,6 +510,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 13 unmodified lines | ||
| 504 | 510 | |
| 505 | 511 | const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); |
| 506 | 512 | assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest); |
| 513 | + assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest); | |
| 514 | + | |
| 515 | + const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId }); | |
| 516 | + assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest); | |
| 507 | 517 | |
| 508 | 518 | const row = Option.getOrNull(persisted); |
| 509 | 519 | if (row === null) return yield* Effect.die("Expected linked thread row to exist."); |
| @@ -511,6 +521,12 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 1 unmodified line | ||
| 511 | 521 | |
| 512 | 522 | const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); |
| 513 | 523 | assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); |
| 524 | + assert.deepStrictEqual(Option.getOrNull(cleared)?.branchPullRequest, branchPullRequest); | |
| 525 | + | |
| 526 | + yield* threads.upsert({ ...row, branchPullRequest: null }); | |
| 527 | + const branchCleared = yield* threads.getById({ threadId: row.threadId }); | |
| 528 | + assert.strictEqual(Option.getOrNull(branchCleared)?.branchPullRequest, null); | |
| 529 | + assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest); | |
| 514 | 530 | }), |
| 515 | 531 | ); |
| 516 | 532 | }); |
| @@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( · 19 unmodified lines | ||
| 20 | 20 | Struct.assign({ |
| 21 | 21 | modelSelection: Schema.fromJsonString(ModelSelection), |
| 22 | 22 | linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), |
| 23 | + branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), | |
| 23 | 24 | }), |
| 24 | 25 | ); |
| 25 | 26 | type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; |
| @@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 15 unmodified lines | ||
| 41 | 42 | branch, |
| 42 | 43 | worktree_path, |
| 43 | 44 | linked_pull_request_json, |
| 45 | + branch_pull_request_json, | |
| 44 | 46 | latest_turn_id, |
| 45 | 47 | created_at, |
| 46 | 48 | updated_at, |
| @@ -70,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 23 unmodified lines | ||
| 70 | 72 | ${row.branch}, |
| 71 | 73 | ${row.worktreePath}, |
| 72 | 74 | ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, |
| 75 | + ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)}, | |
| 73 | 76 | ${row.latestTurnId}, |
| 74 | 77 | ${row.createdAt}, |
| 75 | 78 | ${row.updatedAt}, |
| @@ -99,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 23 unmodified lines | ||
| 99 | 102 | branch = excluded.branch, |
| 100 | 103 | worktree_path = excluded.worktree_path, |
| 101 | 104 | linked_pull_request_json = excluded.linked_pull_request_json, |
| 105 | + branch_pull_request_json = excluded.branch_pull_request_json, | |
| 102 | 106 | latest_turn_id = excluded.latest_turn_id, |
| 103 | 107 | created_at = excluded.created_at, |
| 104 | 108 | updated_at = excluded.updated_at, |
| @@ -135,6 +139,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 30 unmodified lines | ||
| 135 | 139 | branch, |
| 136 | 140 | worktree_path AS "worktreePath", |
| 137 | 141 | linked_pull_request_json AS "linkedPullRequest", |
| 142 | + branch_pull_request_json AS "branchPullRequest", | |
| 138 | 143 | latest_turn_id AS "latestTurnId", |
| 139 | 144 | created_at AS "createdAt", |
| 140 | 145 | updated_at AS "updatedAt", |
| @@ -173,6 +178,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 32 unmodified lines | ||
| 173 | 178 | branch, |
| 174 | 179 | worktree_path AS "worktreePath", |
| 175 | 180 | linked_pull_request_json AS "linkedPullRequest", |
| 181 | + branch_pull_request_json AS "branchPullRequest", | |
| 176 | 182 | latest_turn_id AS "latestTurnId", |
| 177 | 183 | created_at AS "createdAt", |
| 178 | 184 | updated_at AS "updatedAt", |
| @@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t · 58 unmodified lines | ||
| 59 | 59 | import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; |
| 60 | 60 | import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; |
| 61 | 61 | import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; |
| 62 | +import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; | |
| 62 | 63 | |
| 63 | 64 | /** |
| 64 | 65 | * Migration loader with all migrations defined inline. |
| @@ -118,6 +119,7 @@ export const migrationEntries = [ · 53 unmodified lines | ||
| 118 | 119 | [45, "ProjectionProjectsAutoPull", Migration0045], |
| 119 | 120 | [46, "RepairAutomaticSettlementTimestamps", Migration0046], |
| 120 | 121 | [47, "ProjectionProjectIcon", Migration0047], |
| 122 | + [48, "ProjectionThreadBranchPullRequest", Migration0048], | |
| 121 | 123 | ] as const; |
| 122 | 124 | |
| 123 | 125 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
| @@ -0,0 +1,16 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | +import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | + | |
| 4 | +export default Effect.gen(function* () { | |
| 5 | + const sql = yield* SqlClient.SqlClient; | |
| 6 | + const columns = yield* sql<{ readonly name: string }>` | |
| 7 | + PRAGMA table_info(projection_threads) | |
| 8 | + `; | |
| 9 | + | |
| 10 | + if (!columns.some((column) => column.name === "branch_pull_request_json")) { | |
| 11 | + yield* sql` | |
| 12 | + ALTER TABLE projection_threads | |
| 13 | + ADD COLUMN branch_pull_request_json TEXT | |
| 14 | + `; | |
| 15 | + } | |
| 16 | +}); | |
| @@ -35,6 +35,7 @@ export const ProjectionThread = Schema.Struct({ · 34 unmodified lines | ||
| 35 | 35 | branch: Schema.NullOr(Schema.String), |
| 36 | 36 | worktreePath: Schema.NullOr(Schema.String), |
| 37 | 37 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 38 | + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 38 | 39 | latestTurnId: Schema.NullOr(TurnId), |
| 39 | 40 | createdAt: IsoDateTime, |
| 40 | 41 | updatedAt: IsoDateTime, |
| @@ -38,15 +38,16 @@ const makeRepositoryIdentityResolverTestLayer = (options: { · 37 unmodified lines | ||
| 38 | 38 | ).pipe(Layer.provide(ProcessRunner.layer)); |
| 39 | 39 | |
| 40 | 40 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 41 | − it.effect("reuses the cached Git root for repeated workspace lookups", () => { | |
| 41 | + it.effect("refreshes the Git root only when requested", () => { | |
| 42 | 42 | const calls: Array<ReadonlyArray<string>> = []; |
| 43 | + let rootPath = "/repo"; | |
| 43 | 44 | const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { |
| 44 | 45 | run: (input) => |
| 45 | 46 | Effect.sync(() => { |
| 46 | 47 | calls.push(input.args); |
| 47 | 48 | return { |
| 48 | 49 | stdout: input.args.includes("rev-parse") |
| 49 | − ? "/repo\n" | |
| 50 | + ? `${rootPath}\n` | |
| 50 | 51 | : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", |
| 51 | 52 | stderr: "", |
| 52 | 53 | code: ChildProcessSpawner.ExitCode(0), |
| @@ -66,6 +67,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { · 13 unmodified lines | ||
| 66 | 67 | return Effect.gen(function* () { |
| 67 | 68 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 68 | 69 | const first = yield* resolver.resolve("/repo/packages/web"); |
| 70 | + rootPath = "/repo/packages/web"; | |
| 69 | 71 | const second = yield* resolver.resolve("/repo/packages/web"); |
| 70 | 72 | |
| 71 | 73 | expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); |
| @@ -74,6 +76,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { · 2 unmodified lines | ||
| 74 | 76 | ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], |
| 75 | 77 | ["-C", "/repo", "remote", "-v"], |
| 76 | 78 | ]); |
| 79 | + | |
| 80 | + const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true }); | |
| 81 | + expect(refreshed?.rootPath).toBe("/repo/packages/web"); | |
| 82 | + expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed); | |
| 83 | + expect(calls.slice(2)).toEqual([ | |
| 84 | + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], | |
| 85 | + ["-C", "/repo/packages/web", "remote", "-v"], | |
| 86 | + ]); | |
| 77 | 87 | }).pipe(Effect.provide(resolverLayer)); |
| 78 | 88 | }); |
| 79 | 89 | |
| @@ -197,25 +207,42 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { · 117 unmodified lines | ||
| 197 | 207 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), |
| 198 | 208 | ); |
| 199 | 209 | |
| 200 | − it.effect("prefers upstream over origin when both remotes are configured", () => | |
| 201 | − Effect.gen(function* () { | |
| 202 | − const fileSystem = yield* FileSystem.FileSystem; | |
| 203 | − const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 204 | − prefix: "t3-repository-identity-upstream-test-", | |
| 205 | − }); | |
| 206 | − | |
| 207 | − yield* git(cwd, ["init"]); | |
| 208 | − yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | |
| 209 | − yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); | |
| 210 | + it.effect.each(["add", "replace"] as const)( | |
| 211 | + "refreshes the primary upstream after %s before cache expiry", | |
| 212 | + (change) => | |
| 213 | + Effect.gen(function* () { | |
| 214 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 215 | + const cwd = yield* fileSystem.makeTempDirectoryScoped({ | |
| 216 | + prefix: "t3-repository-identity-upstream-test-", | |
| 217 | + }); | |
| 210 | 218 | |
| 211 | − const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 212 | − const identity = yield* resolver.resolve(cwd); | |
| 219 | + yield* git(cwd, ["init"]); | |
| 220 | + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | |
| 221 | + if (change === "replace") { | |
| 222 | + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/previous.git"]); | |
| 223 | + } | |
| 213 | 224 | |
| 214 | − expect(identity).not.toBeNull(); | |
| 215 | − expect(identity?.locator.remoteName).toBe("upstream"); | |
| 216 | − expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 217 | − expect(identity?.displayName).toBe("t3tools/t3code"); | |
| 218 | − }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | |
| 225 | + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 226 | + const initialIdentity = yield* resolver.resolve(cwd); | |
| 227 | + expect(initialIdentity?.canonicalKey).toBe( | |
| 228 | + change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous", | |
| 229 | + ); | |
| 230 | + | |
| 231 | + yield* git(cwd, [ | |
| 232 | + "remote", | |
| 233 | + change === "add" ? "add" : "set-url", | |
| 234 | + "upstream", | |
| 235 | + "git@github.com:T3Tools/t3code.git", | |
| 236 | + ]); | |
| 237 | + expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity); | |
| 238 | + const identity = yield* resolver.resolve(cwd, { refresh: true }); | |
| 239 | + | |
| 240 | + expect(identity).not.toBeNull(); | |
| 241 | + expect(identity?.locator.remoteName).toBe("upstream"); | |
| 242 | + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 243 | + expect(identity?.displayName).toBe("t3tools/t3code"); | |
| 244 | + expect(yield* resolver.resolve(cwd)).toEqual(identity); | |
| 245 | + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | |
| 219 | 246 | ); |
| 220 | 247 | |
| 221 | 248 | it.effect("uses the last remote path segment as the repository name for nested groups", () => |
| @@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions { · 24 unmodified lines | ||
| 25 | 25 | export class RepositoryIdentityResolver extends Context.Service< |
| 26 | 26 | RepositoryIdentityResolver, |
| 27 | 27 | { |
| 28 | − readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>; | |
| 28 | + readonly resolve: ( | |
| 29 | + cwd: string, | |
| 30 | + options?: { readonly refresh?: boolean }, | |
| 31 | + ) => Effect.Effect<RepositoryIdentity | null>; | |
| 29 | 32 | } |
| 30 | 33 | >()("t3/project/RepositoryIdentityResolver") {} |
| 31 | 34 | |
| @@ -170,9 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( · 138 unmodified lines | ||
| 170 | 173 | |
| 171 | 174 | const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( |
| 172 | 175 | "RepositoryIdentityResolver.resolve", |
| 173 | − )(function* (cwd) { | |
| 176 | + )(function* (cwd, options) { | |
| 177 | + if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); | |
| 174 | 178 | const cacheKey = yield* Cache.get(repositoryRootCache, cwd); |
| 175 | 179 | if (cacheKey === null) return null; |
| 180 | + if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); | |
| 176 | 181 | return yield* Cache.get(repositoryIdentityCache, cacheKey); |
| 177 | 182 | }); |
| 178 | 183 | |
| @@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma · 70 unmodified lines | ||
| 71 | 71 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 72 | 72 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; |
| 73 | 73 | import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; |
| 74 | +import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; | |
| 74 | 75 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 75 | 76 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 76 | 77 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| @@ -279,6 +280,7 @@ const ReactorLayerLive = Layer.empty.pipe( · 202 unmodified lines | ||
| 279 | 280 | Layer.provideMerge(CheckpointReactorLive), |
| 280 | 281 | Layer.provideMerge(ThreadDeletionReactorLive), |
| 281 | 282 | Layer.provideMerge(ThreadSettlementReactor.layer), |
| 283 | + Layer.provideMerge(ThreadPullRequestReactor.layer), | |
| 282 | 284 | Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), |
| 283 | 285 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 284 | 286 | ); |
| @@ -47,11 +47,7 @@ import { · 46 unmodified lines | ||
| 47 | 47 | sanitizeNewRefName, |
| 48 | 48 | shouldIncludeBranchPickerItem, |
| 49 | 49 | } from "./BranchToolbar.logic"; |
| 50 | −import { | |
| 51 | − ChangeRequestStatusIcon, | |
| 52 | − prStatusIndicator, | |
| 53 | − resolveThreadPr, | |
| 54 | −} from "./ThreadStatusIndicators"; | |
| 50 | +import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; | |
| 55 | 51 | import { Button } from "./ui/button"; |
| 56 | 52 | import { Switch } from "./ui/switch"; |
| 57 | 53 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; |
| @@ -617,13 +613,14 @@ export function BranchToolbarBranchSelector({ · 559 unmodified lines | ||
| 617 | 613 | }); |
| 618 | 614 | |
| 619 | 615 | // PR pill shown next to the branch selector when the active branch has one. |
| 620 | − const branchPr = resolveThreadPr({ | |
| 621 | − threadBranch: resolveBranchToolbarPrBranch({ | |
| 622 | − activeThreadBranch, | |
| 623 | − resolvedActiveBranch, | |
| 624 | − }), | |
| 625 | − gitStatus: branchStatusQuery.data ?? null, | |
| 616 | + const branchPrBranch = resolveBranchToolbarPrBranch({ | |
| 617 | + activeThreadBranch, | |
| 618 | + resolvedActiveBranch, | |
| 626 | 619 | }); |
| 620 | + const branchPr = | |
| 621 | + branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch | |
| 622 | + ? (branchStatusQuery.data.pr ?? null) | |
| 623 | + : null; | |
| 627 | 624 | const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); |
| 628 | 625 | // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's |
| 629 | 626 | // state-description tooltip. |
| @@ -60,6 +60,7 @@ import { · 59 unmodified lines | ||
| 60 | 60 | shouldDockDraftHeroForSubmission, |
| 61 | 61 | shouldReleaseTimelineAnchorForToolActivity, |
| 62 | 62 | shouldOpenProactivePullRequest, |
| 63 | + shouldRetargetThreadPullRequestPanel, | |
| 63 | 64 | shouldOpenProactiveTurnDiff, |
| 64 | 65 | shouldRenderPreviewMiniPlayer, |
| 65 | 66 | shouldShowBranchMismatchBanner, |
| @@ -249,6 +250,40 @@ describe("proactive panels", () => { · 183 unmodified lines | ||
| 249 | 250 | expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); |
| 250 | 251 | }); |
| 251 | 252 | |
| 253 | + it("follows a changed server PR link without replacing an unrelated open panel", () => { | |
| 254 | + const previous = { | |
| 255 | + projectId: ProjectId.make("project-1"), | |
| 256 | + repository: "pingdotgg/t3code", | |
| 257 | + number: 42, | |
| 258 | + url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 259 | + }; | |
| 260 | + const current = { | |
| 261 | + ...previous, | |
| 262 | + number: 43, | |
| 263 | + url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 264 | + }; | |
| 265 | + const surface = { | |
| 266 | + id: "pull-request:previous", | |
| 267 | + kind: "pull-request", | |
| 268 | + projectId: previous.projectId, | |
| 269 | + repository: "PingDotGG/T3Code", | |
| 270 | + number: previous.number, | |
| 271 | + } satisfies RightPanelSurface; | |
| 272 | + | |
| 273 | + expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true); | |
| 274 | + expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false); | |
| 275 | + expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false); | |
| 276 | + expect( | |
| 277 | + shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 }), | |
| 278 | + ).toBe(false); | |
| 279 | + expect( | |
| 280 | + shouldRetargetThreadPullRequestPanel(previous, current, { | |
| 281 | + ...surface, | |
| 282 | + projectId: "another-project", | |
| 283 | + }), | |
| 284 | + ).toBe(false); | |
| 285 | + }); | |
| 286 | + | |
| 252 | 287 | it("opens the diff only when the observed running turn settles", () => { |
| 253 | 288 | const turnId = TurnId.make("turn-1"); |
| 254 | 289 | expect( |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | type ScopedProjectRef, |
| 16 | 16 | type ScopedThreadRef, |
| 17 | 17 | type ThreadId, |
| 18 | + type ThreadLinkedPullRequest, | |
| 18 | 19 | type TurnId, |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| @@ -132,6 +133,24 @@ export function observeProactivePanelUserChoice( · 111 unmodified lines | ||
| 132 | 133 | }; |
| 133 | 134 | } |
| 134 | 135 | |
| 136 | +/** Follow a changed server link only when the panel still shows the previous linked PR. */ | |
| 137 | +export function shouldRetargetThreadPullRequestPanel( | |
| 138 | + previous: ThreadLinkedPullRequest | null, | |
| 139 | + current: ThreadLinkedPullRequest | null, | |
| 140 | + surface: RightPanelSurface | null, | |
| 141 | +): boolean { | |
| 142 | + if (previous === null || current === null || surface?.kind !== "pull-request") return false; | |
| 143 | + const previousRepository = previous.repository.toLowerCase(); | |
| 144 | + return ( | |
| 145 | + (previous.projectId !== current.projectId || | |
| 146 | + previousRepository !== current.repository.toLowerCase() || | |
| 147 | + previous.number !== current.number) && | |
| 148 | + surface.projectId === previous.projectId && | |
| 149 | + surface.repository.toLowerCase() === previousRepository && | |
| 150 | + surface.number === previous.number | |
| 151 | + ); | |
| 152 | +} | |
| 153 | + | |
| 135 | 154 | export function shouldOpenProactiveTurnDiff(input: { |
| 136 | 155 | previousRunningTurnId: TurnId | null | undefined; |
| 137 | 156 | runningTurnId: TurnId | null; |
| @@ -24,6 +24,7 @@ import { · 23 unmodified lines | ||
| 24 | 24 | type ResolvedKeybindingsConfig, |
| 25 | 25 | type ScopedThreadRef, |
| 26 | 26 | type ThreadId, |
| 27 | + type ThreadLinkedPullRequest, | |
| 27 | 28 | type TurnId, |
| 28 | 29 | type KeybindingCommand, |
| 29 | 30 | OrchestrationThreadActivity, |
| @@ -188,7 +189,7 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; · 158 unmodified lines | ||
| 188 | 189 | import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; |
| 189 | 190 | import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; |
| 190 | 191 | import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; |
| 191 | −import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; | |
| 192 | +import { RightPanelTabs } from "./RightPanelTabs"; | |
| 192 | 193 | import { AgentsPanel } from "./AgentsPanel"; |
| 193 | 194 | import { |
| 194 | 195 | deriveAgentPanelModel, |
| @@ -337,11 +338,6 @@ import { · 142 unmodified lines | ||
| 337 | 338 | shouldShowThreadErrorBanner, |
| 338 | 339 | ThreadErrorBanner, |
| 339 | 340 | } from "./chat/ThreadErrorBanner"; |
| 340 | −import { | |
| 341 | − resolveDisplayedThreadPr, | |
| 342 | − threadChangeRequestSnapshotsAtom, | |
| 343 | − useLinkedThreadPullRequest, | |
| 344 | −} from "./ThreadStatusIndicators"; | |
| 345 | 341 | import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 346 | 342 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 347 | 343 | import { |
| @@ -380,6 +376,7 @@ import { · 32 unmodified lines | ||
| 380 | 376 | shouldShowBranchMismatchBanner, |
| 381 | 377 | shouldShowPlanFollowUpPrompt, |
| 382 | 378 | shouldOpenProactivePullRequest, |
| 379 | + shouldRetargetThreadPullRequestPanel, | |
| 383 | 380 | shouldOpenProactiveTurnDiff, |
| 384 | 381 | shouldRenderPreviewMiniPlayer, |
| 385 | 382 | getStartedThreadModelChangeBlockReason, |
| @@ -1845,7 +1842,6 @@ export default function ChatView(props: ChatViewProps) { · 1459 unmodified lines | ||
| 1845 | 1842 | ); |
| 1846 | 1843 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1847 | 1844 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 1848 | − const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | |
| 1849 | 1845 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| 1850 | 1846 | readonly threadKey: string | null; |
| 1851 | 1847 | readonly messageId: MessageId | null; |
| @@ -1864,10 +1860,6 @@ export default function ChatView(props: ChatViewProps) { · 12 unmodified lines | ||
| 1864 | 1860 | const activeRightPanelSurface = useRightPanelStore((state) => |
| 1865 | 1861 | selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), |
| 1866 | 1862 | ); |
| 1867 | − const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | |
| 1868 | − const sidebarPrRefreshKeyRef = useRef<string | null>(null); | |
| 1869 | − const threadPrRelinkKeysRef = useRef(new Map<string, string>()); | |
| 1870 | − const threadPrRelinkWriteRef = useRef(Promise.resolve()); | |
| 1871 | 1863 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 1872 | 1864 | const activePreviewServerEpoch = activePreviewState.serverEpoch; |
| 1873 | 1865 | const resolvePreviewRuntimeTabId = useMemo( |
| @@ -3970,52 +3962,11 @@ export default function ChatView(props: ChatViewProps) { · 2096 unmodified lines | ||
| 3970 | 3962 | }, |
| 3971 | 3963 | [activeProject, activeThreadRef], |
| 3972 | 3964 | ); |
| 3973 | − // The thread's own change request, placed against the project it belongs to. Without a | |
| 3974 | − // project there is nothing to resolve it against, so the caller falls back to the browser. | |
| 3975 | − const persistedLinkedThreadPullRequest = isServerThread | |
| 3976 | − ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) | |
| 3977 | − : (activeThread?.linkedPullRequest ?? null); | |
| 3978 | − const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 3979 | − const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest( | |
| 3980 | − activeThreadRef?.environmentId ?? null, | |
| 3981 | − persistedLinkedThreadPullRequest, | |
| 3982 | − ); | |
| 3983 | − const replacementLinkedThreadPullRequest = useMemo(() => { | |
| 3984 | − const detected = gitStatusQuery.data?.pr; | |
| 3985 | − const threadBranch = activeThread?.branch; | |
| 3986 | − const projectId = activeProject?.id; | |
| 3987 | − if ( | |
| 3988 | − persistedLinkedThreadPullRequest === null || | |
| 3989 | − (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" && | |
| 3990 | − persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") || | |
| 3991 | − gitStatusQuery.data?.refName !== threadBranch || | |
| 3992 | − detected?.state !== "open" || | |
| 3993 | − detected.headRef !== threadBranch || | |
| 3994 | − projectId === undefined || | |
| 3995 | − activeProjectRepository === null || | |
| 3996 | − (persistedLinkedThreadPullRequest.projectId === projectId && | |
| 3997 | − persistedLinkedThreadPullRequest.repository.toLowerCase() === | |
| 3998 | − activeProjectRepository.toLowerCase() && | |
| 3999 | − persistedLinkedThreadPullRequest.number === detected.number) | |
| 4000 | − ) { | |
| 4001 | − return null; | |
| 4002 | − } | |
| 4003 | − return { | |
| 4004 | − projectId, | |
| 4005 | − repository: activeProjectRepository, | |
| 4006 | − number: detected.number, | |
| 4007 | − url: detected.url, | |
| 4008 | − }; | |
| 4009 | − }, [ | |
| 4010 | − activeProject?.id, | |
| 4011 | − activeProjectRepository, | |
| 4012 | − activeThread?.branch, | |
| 4013 | − gitStatusQuery.data, | |
| 4014 | − persistedLinkedThreadPullRequest, | |
| 4015 | − persistedLinkedThreadPullRequestStatus?.pr.state, | |
| 4016 | − ]); | |
| 3965 | + // The shell carries server PR updates even while thread detail is still loading. | |
| 3966 | + const activeThreadMetadata = activeThreadShell ?? activeThread; | |
| 4017 | 3967 | const linkedThreadPullRequest = |
| 4018 | − replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest; | |
| 3968 | + activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null; | |
| 3969 | + const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 4019 | 3970 | const linkedThreadPullRequestKey = linkedThreadPullRequest |
| 4020 | 3971 | ? JSON.stringify([ |
| 4021 | 3972 | linkedThreadPullRequest.projectId, |
| @@ -4023,68 +3974,10 @@ export default function ChatView(props: ChatViewProps) { · 1 unmodified line | ||
| 4023 | 3974 | linkedThreadPullRequest.number, |
| 4024 | 3975 | ]) |
| 4025 | 3976 | : null; |
| 4026 | − const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; | |
| 4027 | − const openThreadPullRequest = useCallback( | |
| 4028 | − (number: number) => { | |
| 4029 | − if (!supportsPullRequests || !activeThreadRef) { | |
| 4030 | − return; | |
| 4031 | − } | |
| 4032 | − const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id; | |
| 4033 | − const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository; | |
| 4034 | − if (projectId === undefined || repository === null) return; | |
| 4035 | − useRightPanelStore.getState().openPullRequest(activeThreadRef, { | |
| 4036 | − projectId, | |
| 4037 | − repository, | |
| 4038 | − number, | |
| 4039 | − }); | |
| 4040 | − }, | |
| 4041 | − [ | |
| 4042 | − activeProject, | |
| 4043 | − activeProjectRepository, | |
| 4044 | − activeThreadRef, | |
| 4045 | − linkedThreadPullRequest, | |
| 4046 | − supportsPullRequests, | |
| 4047 | − ], | |
| 4048 | − ); | |
| 4049 | − useEffect(() => { | |
| 4050 | − if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { | |
| 4051 | − return; | |
| 4052 | − } | |
| 4053 | − if (replacementLinkedThreadPullRequest === null) { | |
| 4054 | − threadPrRelinkKeysRef.current.delete(activeThreadKey); | |
| 4055 | − return; | |
| 4056 | − } | |
| 4057 | − const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; | |
| 4058 | − if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; | |
| 4059 | − threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); | |
| 4060 | − threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { | |
| 4061 | − if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; | |
| 4062 | − const result = await updateThreadMetadata({ | |
| 4063 | − environmentId: activeThreadRef.environmentId, | |
| 4064 | − input: { | |
| 4065 | − threadId: activeThreadRef.threadId, | |
| 4066 | − linkedPullRequest: replacementLinkedThreadPullRequest, | |
| 4067 | − }, | |
| 4068 | − }); | |
| 4069 | − if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; | |
| 4070 | − if (result._tag !== "Failure") return; | |
| 4071 | − threadPrRelinkKeysRef.current.delete(activeThreadKey); | |
| 4072 | − if (isAtomCommandInterrupted(result)) return; | |
| 4073 | − toastManager.add( | |
| 4074 | − stackedThreadToast({ | |
| 4075 | − type: "error", | |
| 4076 | − title: "Unable to update the thread pull request", | |
| 4077 | − description: chatActionErrorMessage(squashAtomCommandFailure(result)), | |
| 4078 | − }), | |
| 4079 | − ); | |
| 4080 | − }); | |
| 4081 | − }, [ | |
| 4082 | − activeThreadKey, | |
| 4083 | − activeThreadRef, | |
| 4084 | − isServerThread, | |
| 4085 | − replacementLinkedThreadPullRequest, | |
| 4086 | − updateThreadMetadata, | |
| 4087 | − ]); | |
| 3977 | + const observedThreadPullRequestRef = useRef<{ | |
| 3978 | + readonly threadKey: string; | |
| 3979 | + readonly reference: ThreadLinkedPullRequest | null; | |
| 3980 | + } | null>(null); | |
| 4088 | 3981 | const openProjectPullRequest = useCallback( |
| 4089 | 3982 | (number: number) => { |
| 4090 | 3983 | if ( |
| @@ -4110,6 +4003,7 @@ export default function ChatView(props: ChatViewProps) { · 19 unmodified lines | ||
| 4110 | 4003 | useEffect(() => { |
| 4111 | 4004 | if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { |
| 4112 | 4005 | proactivePanelObservationRef.current = null; |
| 4006 | + observedThreadPullRequestRef.current = null; | |
| 4113 | 4007 | return; |
| 4114 | 4008 | } |
| 4115 | 4009 | const panels = useRightPanelStore.getState(); |
| @@ -4125,20 +4019,24 @@ export default function ChatView(props: ChatViewProps) { · 9 unmodified lines | ||
| 4125 | 4019 | userActionRevision, |
| 4126 | 4020 | } = observation; |
| 4127 | 4021 | const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef); |
| 4022 | + const previousPullRequest = observedThreadPullRequestRef.current; | |
| 4023 | + observedThreadPullRequestRef.current = { | |
| 4024 | + threadKey: activeThreadKey, | |
| 4025 | + reference: linkedThreadPullRequest, | |
| 4026 | + }; | |
| 4128 | 4027 | const followSelectedPullRequest = |
| 4129 | − replacementLinkedThreadPullRequest !== null && | |
| 4130 | − openSurface?.kind === "pull-request" && | |
| 4131 | − persistedLinkedThreadPullRequest !== null && | |
| 4132 | − openSurface.projectId === persistedLinkedThreadPullRequest.projectId && | |
| 4133 | − openSurface.repository.toLowerCase() === | |
| 4134 | − persistedLinkedThreadPullRequest.repository.toLowerCase() && | |
| 4135 | − openSurface.number === persistedLinkedThreadPullRequest.number; | |
| 4028 | + previousPullRequest?.threadKey === activeThreadKey && | |
| 4029 | + shouldRetargetThreadPullRequestPanel( | |
| 4030 | + previousPullRequest.reference, | |
| 4031 | + linkedThreadPullRequest, | |
| 4032 | + openSurface, | |
| 4033 | + ); | |
| 4136 | 4034 | // Following the selected linked PR does not open an unrelated panel, so it |
| 4137 | 4035 | // remains available with proactive panels off. It still respects a later choice. |
| 4138 | − if (followSelectedPullRequest && replacementLinkedThreadPullRequest !== null) { | |
| 4036 | + if (followSelectedPullRequest && linkedThreadPullRequest !== null) { | |
| 4139 | 4037 | panels.openProactive( |
| 4140 | 4038 | activeThreadRef, |
| 4141 | − pullRequestSurface(replacementLinkedThreadPullRequest), | |
| 4039 | + pullRequestSurface(linkedThreadPullRequest), | |
| 4142 | 4040 | userActionRevision, |
| 4143 | 4041 | ); |
| 4144 | 4042 | } |
| @@ -4207,9 +4105,7 @@ export default function ChatView(props: ChatViewProps) { · 62 unmodified lines | ||
| 4207 | 4105 | linkedThreadPullRequest, |
| 4208 | 4106 | linkedThreadPullRequestKey, |
| 4209 | 4107 | onDiffPanelOpen, |
| 4210 | − persistedLinkedThreadPullRequest, | |
| 4211 | 4108 | pullRequestsCapabilityKnown, |
| 4212 | − replacementLinkedThreadPullRequest, | |
| 4213 | 4109 | settings.proactivePanelsEnabled, |
| 4214 | 4110 | shouldUseRightPanelSheet, |
| 4215 | 4111 | supportsPullRequests, |
| @@ -5223,50 +5119,6 @@ export default function ChatView(props: ChatViewProps) { · 1007 unmodified lines | ||
| 5223 | 5119 | resizeObserver.disconnect(); |
| 5224 | 5120 | }; |
| 5225 | 5121 | }, [composerOverlayElement, publishComposerOverlayHeight]); |
| 5226 | − const activeThreadPr = | |
| 5227 | − replacementLinkedThreadPullRequest !== null | |
| 5228 | − ? (gitStatusQuery.data?.pr ?? null) | |
| 5229 | − : resolveDisplayedThreadPr({ | |
| 5230 | − threadBranch: activeThread?.branch ?? null, | |
| 5231 | − gitStatus: gitStatusQuery.data ?? null, | |
| 5232 | − snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, | |
| 5233 | − retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, | |
| 5234 | − linkedPullRequest: linkedThreadPullRequest, | |
| 5235 | − linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus, | |
| 5236 | − }); | |
| 5237 | − const handlePullRequestTabStatusChange = useCallback( | |
| 5238 | − (status: Pick<PullRequestTabStatus, "repository" | "number" | "state">) => { | |
| 5239 | − if ( | |
| 5240 | − threadRepository?.toLowerCase() !== status.repository.toLowerCase() || | |
| 5241 | − activeThreadPr?.number !== status.number || | |
| 5242 | − activeThreadPr.state === status.state | |
| 5243 | − ) { | |
| 5244 | − sidebarPrRefreshKeyRef.current = null; | |
| 5245 | − return; | |
| 5246 | − } | |
| 5247 | − const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`; | |
| 5248 | − if (sidebarPrRefreshKeyRef.current === refreshKey) return; | |
| 5249 | − sidebarPrRefreshKeyRef.current = refreshKey; | |
| 5250 | − if (activeThreadRef === null || gitCwd === null) return; | |
| 5251 | − void refreshVcsStatus({ | |
| 5252 | − environmentId: activeThreadRef.environmentId, | |
| 5253 | − input: { cwd: gitCwd }, | |
| 5254 | − }).then(() => { | |
| 5255 | − if (sidebarPrRefreshKeyRef.current === refreshKey) { | |
| 5256 | − sidebarPrRefreshKeyRef.current = null; | |
| 5257 | − } | |
| 5258 | − }); | |
| 5259 | − }, | |
| 5260 | − [ | |
| 5261 | − activeThreadKey, | |
| 5262 | − activeThreadPr?.number, | |
| 5263 | − activeThreadPr?.state, | |
| 5264 | − activeThreadRef, | |
| 5265 | − gitCwd, | |
| 5266 | − refreshVcsStatus, | |
| 5267 | − threadRepository, | |
| 5268 | − ], | |
| 5269 | − ); | |
| 5270 | 5122 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); |
| 5271 | 5123 | const activeThreadReferenceCopyTarget = useMemo( |
| 5272 | 5124 | () => |
| @@ -5276,15 +5128,8 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines | ||
| 5276 | 5128 | threadId: activeThreadId, |
| 5277 | 5129 | openPanelPullRequestUrl, |
| 5278 | 5130 | linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, |
| 5279 | − detectedPullRequestUrl: activeThreadPr?.url ?? null, | |
| 5280 | 5131 | }), |
| 5281 | − [ | |
| 5282 | − activeThreadId, | |
| 5283 | − activeThreadPr?.url, | |
| 5284 | − isServerThread, | |
| 5285 | − linkedThreadPullRequest?.url, | |
| 5286 | − openPanelPullRequestUrl, | |
| 5287 | − ], | |
| 5132 | + [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl], | |
| 5288 | 5133 | ); |
| 5289 | 5134 | const copyActiveThreadReference = useCallback(() => { |
| 5290 | 5135 | const target = activeThreadReferenceCopyTarget; |
| @@ -5310,14 +5155,12 @@ export default function ChatView(props: ChatViewProps) { · 19 unmodified lines | ||
| 5310 | 5155 | }, |
| 5311 | 5156 | ); |
| 5312 | 5157 | }, [activeThreadReferenceCopyTarget]); |
| 5313 | − // The right panel offers the thread's own change request, so it can only offer it once the | |
| 5314 | − // branch has one; until then the picker says so rather than opening an empty panel. | |
| 5315 | 5158 | const addPullRequestSurface = useCallback(() => { |
| 5316 | − if (activeThreadPr === null) return; | |
| 5317 | − openThreadPullRequest(activeThreadPr.number); | |
| 5318 | − }, [activeThreadPr, openThreadPullRequest]); | |
| 5319 | − const pullRequestSurfaceAvailable = | |
| 5320 | − supportsPullRequests && activeThreadPr !== null && threadRepository !== null; | |
| 5159 | + if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null) | |
| 5160 | + return; | |
| 5161 | + useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); | |
| 5162 | + }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]); | |
| 5163 | + const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null; | |
| 5321 | 5164 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; |
| 5322 | 5165 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; |
| 5323 | 5166 | const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; |
| @@ -7849,9 +7692,9 @@ export default function ChatView(props: ChatViewProps) { · 2525 unmodified lines | ||
| 7849 | 7692 | context={ |
| 7850 | 7693 | isThreadOwnPullRequest( |
| 7851 | 7694 | { |
| 7852 | − projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null, | |
| 7853 | − repository: threadRepository, | |
| 7854 | − number: activeThreadPr?.number ?? null, | |
| 7695 | + projectId: linkedThreadPullRequest?.projectId ?? null, | |
| 7696 | + repository: linkedThreadPullRequest?.repository ?? null, | |
| 7697 | + number: linkedThreadPullRequest?.number ?? null, | |
| 7855 | 7698 | }, |
| 7856 | 7699 | { |
| 7857 | 7700 | projectId: renderedRightPanelSurface.projectId, |
| @@ -7863,9 +7706,6 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines | ||
| 7863 | 7706 | : "page" |
| 7864 | 7707 | } |
| 7865 | 7708 | composerDraftTarget={composerDraftTarget} |
| 7866 | − {...(linkedThreadPullRequest === null | |
| 7867 | − ? { onStateChange: handlePullRequestTabStatusChange } | |
| 7868 | − : {})} | |
| 7869 | 7709 | /> |
| 7870 | 7710 | ) : renderedRightPanelSurface?.kind === "agents" ? ( |
| 7871 | 7711 | <AgentsPanel |
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { | |
| 4 | − scopedThreadKey, | |
| 5 | − scopeProjectRef, | |
| 6 | − scopeThreadRef, | |
| 7 | −} from "@t3tools/client-runtime/environment"; | |
| 3 | +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 8 | 4 | import { |
| 9 | 5 | canCreateProjectInEnvironment, |
| 10 | 6 | getCloneDestinationBrowsePath, |
| @@ -81,13 +77,11 @@ import { filesystemEnvironment } from "../state/filesystem"; · 70 unmodified lines | ||
| 81 | 77 | import { projectEnvironment } from "../state/projects"; |
| 82 | 78 | import { useEnvironmentQuery } from "../state/query"; |
| 83 | 79 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 84 | −import { vcsEnvironment } from "../state/vcs"; | |
| 85 | 80 | import { useAtomCommand } from "../state/use-atom-command"; |
| 86 | 81 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 87 | 82 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 88 | −import { useProject, useProjects, useThreadShells } from "../state/entities"; | |
| 83 | +import { useProjects, useThreadShells } from "../state/entities"; | |
| 89 | 84 | import { useThreadSearch } from "../state/queries"; |
| 90 | −import * as ThreadPr from "./ThreadStatusIndicators"; | |
| 91 | 85 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 92 | 86 | import { |
| 93 | 87 | appendBrowsePathSegment, |
| @@ -605,35 +599,6 @@ function OpenCommandPaletteDialog(props: { · 511 unmodified lines | ||
| 605 | 599 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 606 | 600 | useHandleNewThread(); |
| 607 | 601 | const projects = useProjects(); |
| 608 | − const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom); | |
| 609 | − const activeThreadProject = useProject( | |
| 610 | − activeThread === null | |
| 611 | − ? null | |
| 612 | − : scopeProjectRef(activeThread.environmentId, activeThread.projectId), | |
| 613 | − ); | |
| 614 | − const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null; | |
| 615 | − const activeThreadGitStatus = useEnvironmentQuery( | |
| 616 | − activeThread != null && | |
| 617 | − activeThread.linkedPullRequest == null && | |
| 618 | − activeThread.branch !== null && | |
| 619 | − activeThreadCwd !== null | |
| 620 | − ? vcsEnvironment.status({ | |
| 621 | − environmentId: activeThread.environmentId, | |
| 622 | − input: { cwd: activeThreadCwd }, | |
| 623 | − }) | |
| 624 | − : null, | |
| 625 | − ).data; | |
| 626 | − const detectedPullRequestUrl = | |
| 627 | − activeThread == null || activeThread.linkedPullRequest != null | |
| 628 | − ? null | |
| 629 | − : (ThreadPr.resolveDisplayedThreadPr({ | |
| 630 | − threadBranch: activeThread.branch, | |
| 631 | − gitStatus: activeThreadGitStatus ?? null, | |
| 632 | − snapshot: changeRequestSnapshotByKey.get( | |
| 633 | − scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)), | |
| 634 | − ), | |
| 635 | − retainTerminalOnBranchMismatch: activeThread.worktreePath === null, | |
| 636 | − })?.url ?? null); | |
| 637 | 602 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl( |
| 638 | 603 | activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null, |
| 639 | 604 | ); |
| @@ -643,8 +608,8 @@ function OpenCommandPaletteDialog(props: { · 3 unmodified lines | ||
| 643 | 608 | : resolveThreadReferenceCopyTarget({ |
| 644 | 609 | threadId: activeThread.id, |
| 645 | 610 | openPanelPullRequestUrl, |
| 646 | − linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null, | |
| 647 | − detectedPullRequestUrl, | |
| 611 | + linkedPullRequestUrl: | |
| 612 | + activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null, | |
| 648 | 613 | }); |
| 649 | 614 | const copyActiveThreadReference = useCallback(async () => { |
| 650 | 615 | const target = activeThreadReferenceCopyTarget; |
| @@ -16,7 +16,6 @@ import { · 15 unmodified lines | ||
| 16 | 16 | ChangeRequestStatusIcon, |
| 17 | 17 | prStatusIndicator, |
| 18 | 18 | PrStatusTooltipContent, |
| 19 | − resolveThreadPr, | |
| 20 | 19 | terminalStatusFromRunningIds, |
| 21 | 20 | ThreadStatusLabel, |
| 22 | 21 | ThreadWorktreeIndicator, |
| @@ -83,7 +82,6 @@ import { isTerminalFocused } from "../lib/terminalFocus"; · 60 unmodified lines | ||
| 83 | 82 | import { isMacPlatform } from "../lib/utils"; |
| 84 | 83 | import { |
| 85 | 84 | readThreadShell, |
| 86 | − useProject, | |
| 87 | 85 | useProjects, |
| 88 | 86 | useThreadShells, |
| 89 | 87 | useThreadShellsForProjectRefs, |
| @@ -116,9 +114,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate"; · 26 unmodified lines | ||
| 116 | 114 | |
| 117 | 115 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 118 | 116 | import { projectEnvironment } from "../state/projects"; |
| 119 | −import { useEnvironmentQuery } from "../state/query"; | |
| 120 | 117 | import { threadEnvironment, useEnvironmentThread } from "../state/threads"; |
| 121 | −import { vcsEnvironment } from "../state/vcs"; | |
| 122 | 118 | import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 123 | 119 | import { |
| 124 | 120 | buildThreadRouteParams, |
| @@ -191,7 +187,6 @@ import { · 66 unmodified lines | ||
| 191 | 187 | orderItemsByPreferredIds, |
| 192 | 188 | shouldClearThreadSelectionOnMouseDown, |
| 193 | 189 | sortProjectsForSidebar, |
| 194 | − useRetainedValue, | |
| 195 | 190 | useSidebarRowSubscriptionLease, |
| 196 | 191 | useThreadJumpHintVisibility, |
| 197 | 192 | ThreadStatusPill, |
| @@ -314,7 +309,6 @@ function buildThreadJumpLabelMap(input: { · 116 unmodified lines | ||
| 314 | 309 | |
| 315 | 310 | interface SidebarThreadRowProps { |
| 316 | 311 | thread: SidebarThreadSummary; |
| 317 | − projectCwd: string | null; | |
| 318 | 312 | orderedProjectThreadKeys: readonly string[]; |
| 319 | 313 | isActive: boolean; |
| 320 | 314 | openPullRequestsInRightPanel: boolean; |
| @@ -416,25 +410,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 95 unmodified lines | ||
| 416 | 410 | const threadEnvironmentLabel = isRemoteThread |
| 417 | 411 | ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) |
| 418 | 412 | : null; |
| 419 | − // For grouped projects, the thread may belong to a different environment | |
| 420 | − // than the representative project. Look up the thread's own project cwd | |
| 421 | − // so git status (and thus PR detection) queries the correct path. | |
| 422 | − const threadProject = useProject( | |
| 423 | − useMemo( | |
| 424 | − () => scopeProjectRef(thread.environmentId, thread.projectId), | |
| 425 | − [thread.environmentId, thread.projectId], | |
| 426 | − ), | |
| 427 | − ); | |
| 428 | − const threadProjectCwd = threadProject?.workspaceRoot ?? null; | |
| 429 | − const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; | |
| 430 | − const gitStatus = useEnvironmentQuery( | |
| 431 | − leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null | |
| 432 | − ? vcsEnvironment.status({ | |
| 433 | − environmentId: thread.environmentId, | |
| 434 | − input: { cwd: gitCwd }, | |
| 435 | − }) | |
| 436 | − : null, | |
| 437 | − ); | |
| 438 | 413 | const isHighlighted = isActive || isSelected; |
| 439 | 414 | const handleOpenDiscoveredPort = useCallback( |
| 440 | 415 | (event: React.MouseEvent<HTMLButtonElement>) => { |
| @@ -470,28 +445,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP · 29 unmodified lines | ||
| 470 | 445 | }, |
| 471 | 446 | }); |
| 472 | 447 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 473 | − leaseLiveStatus ? thread.environmentId : null, | |
| 474 | − leaseLiveStatus ? thread.linkedPullRequest : null, | |
| 475 | − ); | |
| 476 | − const visibleGitStatus = useRetainedValue( | |
| 477 | − JSON.stringify([thread.environmentId, gitCwd]), | |
| 478 | − gitStatus.data, | |
| 479 | − ); | |
| 480 | − const visibleLinkedPullRequestStatus = useRetainedValue( | |
| 481 | − thread.linkedPullRequest === null | |
| 482 | − ? null | |
| 483 | − : JSON.stringify([thread.environmentId, thread.linkedPullRequest]), | |
| 484 | − linkedPullRequestStatus, | |
| 485 | − ); | |
| 486 | − const pr = | |
| 487 | − thread.linkedPullRequest == null | |
| 488 | − ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus }) | |
| 489 | − : (visibleLinkedPullRequestStatus?.pr ?? null); | |
| 490 | − const prStatus = prStatusIndicator( | |
| 491 | − pr, | |
| 492 | − visibleLinkedPullRequestStatus?.sourceControlProvider ?? | |
| 493 | − visibleGitStatus?.sourceControlProvider, | |
| 448 | + thread.environmentId, | |
| 449 | + thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 450 | + leaseLiveStatus, | |
| 494 | 451 | ); |
| 452 | + const pr = linkedPullRequestStatus?.pr ?? null; | |
| 453 | + const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); | |
| 495 | 454 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 496 | 455 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| 497 | 456 | const threadMetaClassName = isConfirmingArchive |
| @@ -940,7 +899,6 @@ interface SidebarProjectThreadListProps { · 442 unmodified lines | ||
| 940 | 899 | showEmptyThreadState: boolean; |
| 941 | 900 | shouldShowThreadPanel: boolean; |
| 942 | 901 | isThreadListExpanded: boolean; |
| 943 | − projectCwd: string; | |
| 944 | 902 | activeRouteThreadKey: string | null; |
| 945 | 903 | openPullRequestsInRightPanel: boolean; |
| 946 | 904 | threadJumpLabelByKey: ReadonlyMap<string, string>; |
| @@ -996,7 +954,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 49 unmodified lines | ||
| 996 | 954 | showEmptyThreadState, |
| 997 | 955 | shouldShowThreadPanel, |
| 998 | 956 | isThreadListExpanded, |
| 999 | − projectCwd, | |
| 1000 | 957 | activeRouteThreadKey, |
| 1001 | 958 | openPullRequestsInRightPanel, |
| 1002 | 959 | threadJumpLabelByKey, |
| @@ -1048,7 +1005,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 45 unmodified lines | ||
| 1048 | 1005 | <SidebarThreadRow |
| 1049 | 1006 | key={threadKey} |
| 1050 | 1007 | thread={thread} |
| 1051 | − projectCwd={projectCwd} | |
| 1052 | 1008 | orderedProjectThreadKeys={orderedProjectThreadKeys} |
| 1053 | 1009 | isActive={activeRouteThreadKey === threadKey} |
| 1054 | 1010 | openPullRequestsInRightPanel={openPullRequestsInRightPanel} |
| @@ -2444,7 +2400,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1389 unmodified lines | ||
| 2444 | 2400 | showEmptyThreadState={showEmptyThreadState} |
| 2445 | 2401 | shouldShowThreadPanel={shouldShowThreadPanel} |
| 2446 | 2402 | isThreadListExpanded={isThreadListExpanded} |
| 2447 | − projectCwd={project.workspaceRoot} | |
| 2448 | 2403 | activeRouteThreadKey={activeRouteThreadKey} |
| 2449 | 2404 | openPullRequestsInRightPanel={openPullRequestsInRightPanel} |
| 2450 | 2405 | threadJumpLabelByKey={threadJumpLabelByKey} |
| @@ -167,15 +167,9 @@ import { · 166 unmodified lines | ||
| 167 | 167 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 168 | 168 | import { |
| 169 | 169 | ThreadWorktreeIndicator, |
| 170 | − nextThreadChangeRequestSnapshot, | |
| 171 | 170 | prStatusIndicator, |
| 172 | − resolveDisplayedThreadPr, | |
| 173 | − resolveDisplayedThreadPrProvider, | |
| 174 | − setThreadChangeRequestSnapshot, | |
| 175 | 171 | settledPrHoverColorClass, |
| 176 | 172 | terminalStatusFromRunningIds, |
| 177 | − threadChangeRequestSnapshotsAtom, | |
| 178 | − type ThreadChangeRequestSnapshot, | |
| 179 | 173 | type TerminalStatusIndicator, |
| 180 | 174 | useLinkedThreadPullRequest, |
| 181 | 175 | } from "./ThreadStatusIndicators"; |
| @@ -795,16 +789,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 613 unmodified lines | ||
| 795 | 789 | onUnsnooze: (threadRef: ScopedThreadRef) => void; |
| 796 | 790 | onUnpin: (threadRef: ScopedThreadRef) => void; |
| 797 | 791 | onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; |
| 798 | − changeRequestSnapshot: ThreadChangeRequestSnapshot | null; | |
| 799 | − onChangeRequestSnapshot: ( | |
| 800 | − threadKey: string, | |
| 801 | − snapshot: ThreadChangeRequestSnapshot | null, | |
| 802 | − ) => void; | |
| 803 | 792 | }) { |
| 804 | 793 | const { |
| 805 | 794 | isRenaming, |
| 806 | − changeRequestSnapshot, | |
| 807 | − onChangeRequestSnapshot, | |
| 808 | 795 | onCancelRename, |
| 809 | 796 | onCommitRename, |
| 810 | 797 | onContextMenu, |
| @@ -856,8 +843,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 45 unmodified lines | ||
| 856 | 843 | |
| 857 | 844 | const gitCwd = thread.worktreePath ?? props.projectCwd; |
| 858 | 845 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 859 | − leaseLiveStatus ? thread.environmentId : null, | |
| 860 | − leaseLiveStatus ? thread.linkedPullRequest : null, | |
| 846 | + thread.environmentId, | |
| 847 | + thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 848 | + leaseLiveStatus, | |
| 861 | 849 | ); |
| 862 | 850 | const gitStatus = useEnvironmentQuery( |
| 863 | 851 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| @@ -871,15 +859,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 7 unmodified lines | ||
| 871 | 859 | JSON.stringify([thread.environmentId, gitCwd]), |
| 872 | 860 | gitStatus.data, |
| 873 | 861 | ); |
| 874 | − const retainTerminalOnBranchMismatch = thread.worktreePath === null; | |
| 875 | − const pr = resolveDisplayedThreadPr({ | |
| 876 | − threadBranch: thread.branch, | |
| 877 | − gitStatus: visibleGitStatus, | |
| 878 | − snapshot: changeRequestSnapshot, | |
| 879 | − retainTerminalOnBranchMismatch, | |
| 880 | − linkedPullRequest: thread.linkedPullRequest, | |
| 881 | − linkedPullRequestStatus, | |
| 882 | − }); | |
| 862 | + const pr = linkedPullRequestStatus?.pr ?? null; | |
| 883 | 863 | |
| 884 | 864 | // Same semantics as the legacy sidebar (never-visited counts as read): |
| 885 | 865 | // switching sidebars must not light up every historical thread as unread. |
| @@ -972,37 +952,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 86 unmodified lines | ||
| 972 | 952 | activeThreadBranch: thread.branch, |
| 973 | 953 | currentGitBranch: visibleGitStatus?.refName ?? null, |
| 974 | 954 | }); |
| 975 | − const prProvider = resolveDisplayedThreadPrProvider({ | |
| 976 | − threadBranch: thread.branch, | |
| 977 | − gitStatus: visibleGitStatus, | |
| 978 | − snapshot: changeRequestSnapshot, | |
| 979 | − retainTerminalOnBranchMismatch, | |
| 980 | − linkedPullRequest: thread.linkedPullRequest, | |
| 981 | − linkedPullRequestStatus, | |
| 982 | − }); | |
| 983 | − const prStatus = prStatusIndicator(pr, prProvider); | |
| 955 | + const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); | |
| 984 | 956 | const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; |
| 985 | − useEffect(() => { | |
| 986 | − const nextSnapshot = nextThreadChangeRequestSnapshot({ | |
| 987 | − threadBranch: thread.branch, | |
| 988 | − gitStatus: visibleGitStatus, | |
| 989 | − snapshot: changeRequestSnapshot, | |
| 990 | − retainTerminalOnBranchMismatch, | |
| 991 | − linkedPullRequest: thread.linkedPullRequest, | |
| 992 | − linkedPullRequestStatus, | |
| 993 | − }); | |
| 994 | − if (nextSnapshot === undefined) return; | |
| 995 | − onChangeRequestSnapshot(threadKey, nextSnapshot); | |
| 996 | − }, [ | |
| 997 | − changeRequestSnapshot, | |
| 998 | − visibleGitStatus, | |
| 999 | − linkedPullRequestStatus, | |
| 1000 | − onChangeRequestSnapshot, | |
| 1001 | − retainTerminalOnBranchMismatch, | |
| 1002 | − thread.branch, | |
| 1003 | − thread.linkedPullRequest, | |
| 1004 | − threadKey, | |
| 1005 | − ]); | |
| 1006 | 957 | |
| 1007 | 958 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 1008 | 959 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; |
| @@ -2104,8 +2055,6 @@ export default function Sidebar() { · 1095 unmodified lines | ||
| 2104 | 2055 | // fresh clock whenever it recomputes. |
| 2105 | 2056 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 2106 | 2057 | |
| 2107 | − const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | |
| 2108 | − | |
| 2109 | 2058 | // Project scope: one menu above the list. Scoping filters the list without |
| 2110 | 2059 | // making the header width depend on the number or length of project names. |
| 2111 | 2060 | // The selection lives in the persisted UI store next to the other sidebar |
| @@ -4092,8 +4041,6 @@ export default function Sidebar() { · 1980 unmodified lines | ||
| 4092 | 4041 | onUnsnooze={attemptUnsnooze} |
| 4093 | 4042 | onUnpin={attemptUnpin} |
| 4094 | 4043 | onAcknowledgeWoke={acknowledgeWoke} |
| 4095 | − changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} | |
| 4096 | − onChangeRequestSnapshot={setThreadChangeRequestSnapshot} | |
| 4097 | 4044 | /> |
| 4098 | 4045 | ); |
| 4099 | 4046 | }; |
| @@ -1,7 +1,5 @@ | ||
| 1 | 1 | import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | −import * as Effect from "effect/Effect"; | |
| 4 | −import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 5 | 3 | import { |
| 6 | 4 | GitMergeIcon, |
| 7 | 5 | GitPullRequestClosedIcon, |
| @@ -11,15 +9,8 @@ import { · 3 unmodified lines | ||
| 11 | 9 | |
| 12 | 10 | import { |
| 13 | 11 | ChangeRequestStatusIcon, |
| 14 | − nextThreadChangeRequestSnapshot, | |
| 15 | 12 | prStatusIndicator, |
| 16 | − resolveDisplayedThreadPr, | |
| 17 | − resolveDisplayedThreadPrProvider, | |
| 18 | − resolveThreadPr, | |
| 19 | 13 | settledPrHoverColorClass, |
| 20 | − threadChangeRequestSnapshotsEqual, | |
| 21 | − threadChangeRequestSnapshotsAtom, | |
| 22 | − type ThreadChangeRequestSnapshot, | |
| 23 | 14 | } from "./ThreadStatusIndicators"; |
| 24 | 15 | import { newestPullRequestSummary } from "../state/pullRequests"; |
| 25 | 16 | |
| @@ -57,25 +48,6 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { · 31 unmodified lines | ||
| 57 | 48 | }; |
| 58 | 49 | } |
| 59 | 50 | |
| 60 | −function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> { | |
| 61 | − return { | |
| 62 | − number: 42, | |
| 63 | − title: "Feature PR", | |
| 64 | − url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 65 | − baseRef: "main", | |
| 66 | − headRef: "feature/current", | |
| 67 | − state: "merged", | |
| 68 | − }; | |
| 69 | −} | |
| 70 | − | |
| 71 | −function snapshotFor( | |
| 72 | − branch: string, | |
| 73 | − pr: NonNullable<VcsStatusResult["pr"]>, | |
| 74 | − sourceControlProvider?: VcsStatusResult["sourceControlProvider"], | |
| 75 | −): ThreadChangeRequestSnapshot { | |
| 76 | − return { branch, pr, sourceControlProvider }; | |
| 77 | −} | |
| 78 | − | |
| 79 | 51 | function pullRequestSummary( |
| 80 | 52 | state: PullRequestSummary["state"], |
| 81 | 53 | updatedAt: string, |
| @@ -117,503 +89,6 @@ describe("shared pull request state", () => { · 35 unmodified lines | ||
| 117 | 89 | }); |
| 118 | 90 | }); |
| 119 | 91 | |
| 120 | −describe("resolveThreadPr", () => { | |
| 121 | − it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { | |
| 122 | − expect( | |
| 123 | − resolveThreadPr({ | |
| 124 | − threadBranch: "feature/other", | |
| 125 | − gitStatus: status(), | |
| 126 | − }), | |
| 127 | − ).toBeNull(); | |
| 128 | − }); | |
| 129 | − | |
| 130 | − it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { | |
| 131 | − expect( | |
| 132 | − resolveThreadPr({ | |
| 133 | − threadBranch: "stack/base", | |
| 134 | − gitStatus: status(), | |
| 135 | − }), | |
| 136 | − ).toBeNull(); | |
| 137 | − }); | |
| 138 | − | |
| 139 | − it("hides PR indicators when thread branch metadata is missing", () => { | |
| 140 | − expect( | |
| 141 | − resolveThreadPr({ | |
| 142 | − threadBranch: null, | |
| 143 | − gitStatus: status(), | |
| 144 | − }), | |
| 145 | − ).toBeNull(); | |
| 146 | − }); | |
| 147 | − | |
| 148 | − it("shows the PR when the live checkout matches the stored thread branch", () => { | |
| 149 | − const gitStatus = status(); | |
| 150 | − | |
| 151 | − expect( | |
| 152 | − resolveThreadPr({ | |
| 153 | − threadBranch: "feature/current", | |
| 154 | − gitStatus, | |
| 155 | − }), | |
| 156 | − ).toBe(gitStatus.pr); | |
| 157 | − }); | |
| 158 | −}); | |
| 159 | − | |
| 160 | −describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { | |
| 161 | − const featureBranch = "feature/current"; | |
| 162 | − const mergedPr = mergedFeaturePr(); | |
| 163 | − const linkedPullRequest = { | |
| 164 | − projectId: ProjectId.make("project-1"), | |
| 165 | − repository: "pingdotgg/t3code", | |
| 166 | − number: 42, | |
| 167 | − url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 168 | − }; | |
| 169 | − const provider = { | |
| 170 | − kind: "github" as const, | |
| 171 | − name: "GitHub", | |
| 172 | − baseUrl: "https://github.com", | |
| 173 | − }; | |
| 174 | − | |
| 175 | − it("returns the live merged PR when the checkout matches the feature branch", () => { | |
| 176 | − const gitStatus = status({ | |
| 177 | − refName: featureBranch, | |
| 178 | − pr: mergedPr, | |
| 179 | − sourceControlProvider: provider, | |
| 180 | − }); | |
| 181 | − | |
| 182 | − expect( | |
| 183 | − resolveDisplayedThreadPr({ | |
| 184 | − threadBranch: featureBranch, | |
| 185 | − gitStatus, | |
| 186 | − snapshot: undefined, | |
| 187 | − retainTerminalOnBranchMismatch: true, | |
| 188 | − }), | |
| 189 | − ).toBe(mergedPr); | |
| 190 | − expect( | |
| 191 | − resolveDisplayedThreadPrProvider({ | |
| 192 | − threadBranch: featureBranch, | |
| 193 | − gitStatus, | |
| 194 | − snapshot: undefined, | |
| 195 | − retainTerminalOnBranchMismatch: true, | |
| 196 | − }), | |
| 197 | − ).toEqual(provider); | |
| 198 | − }); | |
| 199 | − | |
| 200 | − it("shows a linked pull request when the checkout has a different branch", () => { | |
| 201 | − const linkedPullRequestStatus = { | |
| 202 | − pr: mergedPr, | |
| 203 | − sourceControlProvider: provider, | |
| 204 | − }; | |
| 205 | − | |
| 206 | − expect( | |
| 207 | − resolveDisplayedThreadPr({ | |
| 208 | − threadBranch: "feature/other", | |
| 209 | − gitStatus: status({ refName: "feature/other", pr: null }), | |
| 210 | − snapshot: undefined, | |
| 211 | − retainTerminalOnBranchMismatch: false, | |
| 212 | − linkedPullRequest, | |
| 213 | − linkedPullRequestStatus, | |
| 214 | − }), | |
| 215 | − ).toEqual(mergedPr); | |
| 216 | − expect( | |
| 217 | − resolveDisplayedThreadPrProvider({ | |
| 218 | − threadBranch: "feature/other", | |
| 219 | − gitStatus: status({ refName: "feature/other", pr: null }), | |
| 220 | − snapshot: undefined, | |
| 221 | − retainTerminalOnBranchMismatch: false, | |
| 222 | − linkedPullRequest, | |
| 223 | − linkedPullRequestStatus, | |
| 224 | − }), | |
| 225 | − ).toEqual(provider); | |
| 226 | − expect( | |
| 227 | − nextThreadChangeRequestSnapshot({ | |
| 228 | − threadBranch: "feature/other", | |
| 229 | − gitStatus: status({ refName: "feature/other", pr: null }), | |
| 230 | − snapshot: undefined, | |
| 231 | − retainTerminalOnBranchMismatch: false, | |
| 232 | − linkedPullRequest, | |
| 233 | − linkedPullRequestStatus, | |
| 234 | − }), | |
| 235 | − ).toEqual({ | |
| 236 | − branch: "feature/other", | |
| 237 | − pr: mergedPr, | |
| 238 | − sourceControlProvider: provider, | |
| 239 | − linkedPullRequest, | |
| 240 | − }); | |
| 241 | − }); | |
| 242 | − | |
| 243 | − it("keeps a matching linked pull request snapshot while its status reloads", () => { | |
| 244 | − const snapshot = { | |
| 245 | − ...snapshotFor(featureBranch, mergedPr, provider), | |
| 246 | − linkedPullRequest, | |
| 247 | − }; | |
| 248 | − | |
| 249 | − expect( | |
| 250 | − resolveDisplayedThreadPr({ | |
| 251 | − threadBranch: null, | |
| 252 | − gitStatus: null, | |
| 253 | − snapshot, | |
| 254 | − retainTerminalOnBranchMismatch: false, | |
| 255 | − linkedPullRequest, | |
| 256 | − linkedPullRequestStatus: null, | |
| 257 | − }), | |
| 258 | − ).toEqual(mergedPr); | |
| 259 | − expect( | |
| 260 | − nextThreadChangeRequestSnapshot({ | |
| 261 | − threadBranch: null, | |
| 262 | − gitStatus: null, | |
| 263 | − snapshot, | |
| 264 | − retainTerminalOnBranchMismatch: false, | |
| 265 | − linkedPullRequest, | |
| 266 | − linkedPullRequestStatus: null, | |
| 267 | − }), | |
| 268 | − ).toBeUndefined(); | |
| 269 | − }); | |
| 270 | − | |
| 271 | − it("clears an old snapshot when a different pull request is linked", () => { | |
| 272 | − const snapshot = { | |
| 273 | − ...snapshotFor(featureBranch, mergedPr, provider), | |
| 274 | − linkedPullRequest: { ...linkedPullRequest, number: 41 }, | |
| 275 | − }; | |
| 276 | − | |
| 277 | − expect( | |
| 278 | − nextThreadChangeRequestSnapshot({ | |
| 279 | − threadBranch: featureBranch, | |
| 280 | − gitStatus: null, | |
| 281 | − snapshot, | |
| 282 | − retainTerminalOnBranchMismatch: true, | |
| 283 | − linkedPullRequest, | |
| 284 | − linkedPullRequestStatus: null, | |
| 285 | − }), | |
| 286 | − ).toBeNull(); | |
| 287 | − }); | |
| 288 | − | |
| 289 | − it("removes a linked pull request snapshot after the link is cleared", () => { | |
| 290 | − const snapshot = { | |
| 291 | − ...snapshotFor(featureBranch, mergedPr, provider), | |
| 292 | − linkedPullRequest, | |
| 293 | − }; | |
| 294 | − | |
| 295 | − expect( | |
| 296 | − resolveDisplayedThreadPr({ | |
| 297 | − threadBranch: featureBranch, | |
| 298 | − gitStatus: status({ refName: "main", pr: null }), | |
| 299 | − snapshot, | |
| 300 | − retainTerminalOnBranchMismatch: true, | |
| 301 | − }), | |
| 302 | − ).toBeNull(); | |
| 303 | − expect( | |
| 304 | − nextThreadChangeRequestSnapshot({ | |
| 305 | − threadBranch: featureBranch, | |
| 306 | − gitStatus: null, | |
| 307 | − snapshot, | |
| 308 | − retainTerminalOnBranchMismatch: true, | |
| 309 | − }), | |
| 310 | − ).toBeNull(); | |
| 311 | − }); | |
| 312 | − | |
| 313 | − it("after caching a merged PR, resolves main status back to the cached feature PR", () => { | |
| 314 | − const matchingStatus = status({ | |
| 315 | − refName: featureBranch, | |
| 316 | − pr: mergedPr, | |
| 317 | − sourceControlProvider: provider, | |
| 318 | − }); | |
| 319 | − const cached = nextThreadChangeRequestSnapshot({ | |
| 320 | − threadBranch: featureBranch, | |
| 321 | − gitStatus: matchingStatus, | |
| 322 | − snapshot: undefined, | |
| 323 | − retainTerminalOnBranchMismatch: true, | |
| 324 | − }); | |
| 325 | − expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); | |
| 326 | − | |
| 327 | − const mainStatus = status({ | |
| 328 | − refName: "main", | |
| 329 | − isDefaultRef: true, | |
| 330 | − pr: { | |
| 331 | − number: 99, | |
| 332 | − title: "Unrelated main PR", | |
| 333 | − url: "https://github.com/pingdotgg/t3code/pull/99", | |
| 334 | − baseRef: "main", | |
| 335 | − headRef: "main", | |
| 336 | − state: "open", | |
| 337 | − }, | |
| 338 | − sourceControlProvider: provider, | |
| 339 | − }); | |
| 340 | − | |
| 341 | − expect( | |
| 342 | − resolveDisplayedThreadPr({ | |
| 343 | − threadBranch: featureBranch, | |
| 344 | − gitStatus: mainStatus, | |
| 345 | − snapshot: cached as ThreadChangeRequestSnapshot, | |
| 346 | − retainTerminalOnBranchMismatch: true, | |
| 347 | − }), | |
| 348 | − ).toEqual(mergedPr); | |
| 349 | − expect( | |
| 350 | − resolveDisplayedThreadPrProvider({ | |
| 351 | − threadBranch: featureBranch, | |
| 352 | − gitStatus: mainStatus, | |
| 353 | − snapshot: cached as ThreadChangeRequestSnapshot, | |
| 354 | − retainTerminalOnBranchMismatch: true, | |
| 355 | − }), | |
| 356 | − ).toEqual(provider); | |
| 357 | − }); | |
| 358 | − | |
| 359 | − it("never attaches a PR reported by main to the feature thread", () => { | |
| 360 | − const mainPr = { | |
| 361 | − number: 99, | |
| 362 | − title: "Unrelated main PR", | |
| 363 | − url: "https://github.com/pingdotgg/t3code/pull/99", | |
| 364 | − baseRef: "develop", | |
| 365 | − headRef: "main", | |
| 366 | − state: "merged" as const, | |
| 367 | − }; | |
| 368 | − expect( | |
| 369 | − resolveDisplayedThreadPr({ | |
| 370 | − threadBranch: featureBranch, | |
| 371 | − gitStatus: status({ refName: "main", pr: mainPr }), | |
| 372 | − snapshot: undefined, | |
| 373 | − retainTerminalOnBranchMismatch: true, | |
| 374 | − }), | |
| 375 | − ).toBeNull(); | |
| 376 | − expect( | |
| 377 | − nextThreadChangeRequestSnapshot({ | |
| 378 | − threadBranch: featureBranch, | |
| 379 | − gitStatus: status({ refName: "main", pr: mainPr }), | |
| 380 | − snapshot: undefined, | |
| 381 | − retainTerminalOnBranchMismatch: true, | |
| 382 | − }), | |
| 383 | − ).toBeNull(); | |
| 384 | − }); | |
| 385 | − | |
| 386 | − it("does not show a cached open PR across a branch mismatch", () => { | |
| 387 | − const openSnapshot = snapshotFor(featureBranch, { | |
| 388 | − ...mergedPr, | |
| 389 | − state: "open", | |
| 390 | − title: "Still open", | |
| 391 | − }); | |
| 392 | − | |
| 393 | − expect( | |
| 394 | − resolveDisplayedThreadPr({ | |
| 395 | − threadBranch: featureBranch, | |
| 396 | − gitStatus: status({ refName: "main", pr: null }), | |
| 397 | − snapshot: openSnapshot, | |
| 398 | − retainTerminalOnBranchMismatch: true, | |
| 399 | − }), | |
| 400 | − ).toBeNull(); | |
| 401 | − }); | |
| 402 | − | |
| 403 | − it("retains a cached closed PR across a branch mismatch", () => { | |
| 404 | − const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; | |
| 405 | − const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); | |
| 406 | − | |
| 407 | − expect( | |
| 408 | − resolveDisplayedThreadPr({ | |
| 409 | − threadBranch: featureBranch, | |
| 410 | − gitStatus: status({ refName: "main", pr: null }), | |
| 411 | − snapshot: closedSnapshot, | |
| 412 | − retainTerminalOnBranchMismatch: true, | |
| 413 | − }), | |
| 414 | − ).toEqual(closedPr); | |
| 415 | − }); | |
| 416 | − | |
| 417 | − it("does not retain or display a terminal PR when a worktree switches branches", () => { | |
| 418 | − const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 419 | − const mismatchedStatus = status({ refName: "feature/other", pr: null }); | |
| 420 | − | |
| 421 | − expect( | |
| 422 | − resolveDisplayedThreadPr({ | |
| 423 | − threadBranch: featureBranch, | |
| 424 | − gitStatus: mismatchedStatus, | |
| 425 | − snapshot: terminalSnapshot, | |
| 426 | − retainTerminalOnBranchMismatch: false, | |
| 427 | − }), | |
| 428 | − ).toBeNull(); | |
| 429 | − expect( | |
| 430 | − resolveDisplayedThreadPrProvider({ | |
| 431 | − threadBranch: featureBranch, | |
| 432 | − gitStatus: mismatchedStatus, | |
| 433 | − snapshot: terminalSnapshot, | |
| 434 | − retainTerminalOnBranchMismatch: false, | |
| 435 | − }), | |
| 436 | − ).toBeUndefined(); | |
| 437 | − expect( | |
| 438 | − nextThreadChangeRequestSnapshot({ | |
| 439 | − threadBranch: featureBranch, | |
| 440 | − gitStatus: mismatchedStatus, | |
| 441 | − snapshot: terminalSnapshot, | |
| 442 | − retainTerminalOnBranchMismatch: false, | |
| 443 | − }), | |
| 444 | − ).toBeNull(); | |
| 445 | − }); | |
| 446 | − | |
| 447 | − it("retains a local terminal snapshot when thread metadata follows the new branch", () => { | |
| 448 | − const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); | |
| 449 | − | |
| 450 | − expect( | |
| 451 | − resolveDisplayedThreadPr({ | |
| 452 | − threadBranch: featureBranch, | |
| 453 | − gitStatus: status({ refName: "main", pr: null }), | |
| 454 | − snapshot: otherBranchSnapshot, | |
| 455 | − retainTerminalOnBranchMismatch: true, | |
| 456 | − }), | |
| 457 | − ).toEqual(mergedPr); | |
| 458 | − }); | |
| 459 | − | |
| 460 | − it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { | |
| 461 | − const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 462 | − | |
| 463 | − expect( | |
| 464 | − nextThreadChangeRequestSnapshot({ | |
| 465 | − threadBranch: "main", | |
| 466 | − gitStatus: status({ refName: "main", pr: null }), | |
| 467 | − snapshot: terminalSnapshot, | |
| 468 | − retainTerminalOnBranchMismatch: true, | |
| 469 | − }), | |
| 470 | − ).toBeUndefined(); | |
| 471 | − expect( | |
| 472 | − resolveDisplayedThreadPr({ | |
| 473 | − threadBranch: "main", | |
| 474 | − gitStatus: status({ refName: "main", pr: null }), | |
| 475 | − snapshot: terminalSnapshot, | |
| 476 | − retainTerminalOnBranchMismatch: true, | |
| 477 | − }), | |
| 478 | − ).toEqual(mergedPr); | |
| 479 | − }); | |
| 480 | − | |
| 481 | − it("clears an open snapshot when a local thread moves to a branch with no PR", () => { | |
| 482 | − const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); | |
| 483 | − | |
| 484 | − expect( | |
| 485 | − nextThreadChangeRequestSnapshot({ | |
| 486 | − threadBranch: "main", | |
| 487 | − gitStatus: status({ refName: "main", pr: null }), | |
| 488 | − snapshot: openSnapshot, | |
| 489 | − retainTerminalOnBranchMismatch: true, | |
| 490 | − }), | |
| 491 | − ).toBeNull(); | |
| 492 | − }); | |
| 493 | − | |
| 494 | − it("clears an open snapshot when a local checkout moves to a different branch", () => { | |
| 495 | − const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); | |
| 496 | − | |
| 497 | − expect( | |
| 498 | − nextThreadChangeRequestSnapshot({ | |
| 499 | − threadBranch: featureBranch, | |
| 500 | − gitStatus: status({ refName: "main", pr: null }), | |
| 501 | − snapshot: openSnapshot, | |
| 502 | − retainTerminalOnBranchMismatch: true, | |
| 503 | − }), | |
| 504 | − ).toBeNull(); | |
| 505 | − }); | |
| 506 | − | |
| 507 | − it("clears a retained snapshot when the thread branch is cleared", () => { | |
| 508 | − const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 509 | − | |
| 510 | − expect( | |
| 511 | − nextThreadChangeRequestSnapshot({ | |
| 512 | − threadBranch: null, | |
| 513 | − gitStatus: status({ refName: "main", pr: null }), | |
| 514 | − snapshot: terminalSnapshot, | |
| 515 | − retainTerminalOnBranchMismatch: true, | |
| 516 | − }), | |
| 517 | − ).toBeNull(); | |
| 518 | − expect( | |
| 519 | − resolveDisplayedThreadPr({ | |
| 520 | − threadBranch: null, | |
| 521 | − gitStatus: status({ refName: "main", pr: null }), | |
| 522 | − snapshot: terminalSnapshot, | |
| 523 | − retainTerminalOnBranchMismatch: true, | |
| 524 | − }), | |
| 525 | − ).toBeNull(); | |
| 526 | − expect( | |
| 527 | − resolveDisplayedThreadPrProvider({ | |
| 528 | − threadBranch: null, | |
| 529 | − gitStatus: status({ refName: "main", pr: null }), | |
| 530 | − snapshot: terminalSnapshot, | |
| 531 | − retainTerminalOnBranchMismatch: true, | |
| 532 | − }), | |
| 533 | − ).toBeUndefined(); | |
| 534 | − }); | |
| 535 | − | |
| 536 | − it("does not erase a terminal snapshot when VCS data is missing", () => { | |
| 537 | − const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); | |
| 538 | − | |
| 539 | − expect( | |
| 540 | − nextThreadChangeRequestSnapshot({ | |
| 541 | − threadBranch: featureBranch, | |
| 542 | − gitStatus: null, | |
| 543 | − snapshot: terminalSnapshot, | |
| 544 | − retainTerminalOnBranchMismatch: true, | |
| 545 | − }), | |
| 546 | − ).toBeUndefined(); | |
| 547 | − expect( | |
| 548 | − resolveDisplayedThreadPr({ | |
| 549 | − threadBranch: featureBranch, | |
| 550 | − gitStatus: null, | |
| 551 | − snapshot: terminalSnapshot, | |
| 552 | − retainTerminalOnBranchMismatch: true, | |
| 553 | − }), | |
| 554 | − ).toEqual(mergedPr); | |
| 555 | − }); | |
| 556 | − | |
| 557 | − it("retains a merged PR after a main checkout", () => { | |
| 558 | − const matchingStatus = status({ | |
| 559 | − refName: featureBranch, | |
| 560 | − pr: mergedPr, | |
| 561 | − sourceControlProvider: provider, | |
| 562 | − }); | |
| 563 | − const cached = nextThreadChangeRequestSnapshot({ | |
| 564 | − threadBranch: featureBranch, | |
| 565 | − gitStatus: matchingStatus, | |
| 566 | − snapshot: undefined, | |
| 567 | − retainTerminalOnBranchMismatch: true, | |
| 568 | − }); | |
| 569 | − expect(cached).not.toBeNull(); | |
| 570 | − expect(cached).not.toBeUndefined(); | |
| 571 | − | |
| 572 | − const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); | |
| 573 | − const displayed = resolveDisplayedThreadPr({ | |
| 574 | − threadBranch: "main", | |
| 575 | − gitStatus: mainStatus, | |
| 576 | − snapshot: cached as ThreadChangeRequestSnapshot, | |
| 577 | − retainTerminalOnBranchMismatch: true, | |
| 578 | − }); | |
| 579 | − expect(displayed?.state).toBe("merged"); | |
| 580 | − }); | |
| 581 | − | |
| 582 | − it("refreshes a cached snapshot when a pull request becomes ready", () => { | |
| 583 | − const readyPr = { ...mergedPr, state: "open" as const }; | |
| 584 | − const draftPr = { ...readyPr, isDraft: true }; | |
| 585 | − | |
| 586 | − expect( | |
| 587 | − threadChangeRequestSnapshotsEqual( | |
| 588 | − snapshotFor(featureBranch, draftPr), | |
| 589 | − snapshotFor(featureBranch, readyPr), | |
| 590 | − ), | |
| 591 | − ).toBe(false); | |
| 592 | − }); | |
| 593 | −}); | |
| 594 | − | |
| 595 | −describe("threadChangeRequestSnapshotsAtom", () => { | |
| 596 | − it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => | |
| 597 | − Effect.gen(function* () { | |
| 598 | − const registry = AtomRegistry.make(); | |
| 599 | − const threadKey = "environment-1:thread-1"; | |
| 600 | − const snapshot = snapshotFor("feature/current", mergedFeaturePr()); | |
| 601 | − | |
| 602 | − const unmount = registry.mount(threadChangeRequestSnapshotsAtom); | |
| 603 | − registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); | |
| 604 | − unmount(); | |
| 605 | − | |
| 606 | − yield* Effect.yieldNow; | |
| 607 | − | |
| 608 | − const remount = registry.mount(threadChangeRequestSnapshotsAtom); | |
| 609 | − expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); | |
| 610 | − | |
| 611 | − remount(); | |
| 612 | − registry.dispose(); | |
| 613 | − }), | |
| 614 | − ); | |
| 615 | −}); | |
| 616 | − | |
| 617 | 92 | describe("prStatusIndicator", () => { |
| 618 | 93 | it("formats PR tooltips with number, uppercase status, and title", () => { |
| 619 | 94 | expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ |
| @@ -1,8 +1,4 @@ | ||
| 1 | −import { | |
| 2 | − scopeProjectRef, | |
| 3 | − scopedThreadKey, | |
| 4 | − scopeThreadRef, | |
| 5 | −} from "@t3tools/client-runtime/environment"; | |
| 1 | +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 6 | 2 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; |
| 7 | 3 | import { |
| 8 | 4 | type EnvironmentId, |
| @@ -10,17 +6,13 @@ import { · 1 unmodified line | ||
| 10 | 6 | type ThreadLinkedPullRequest, |
| 11 | 7 | type VcsStatusResult, |
| 12 | 8 | } from "@t3tools/contracts"; |
| 13 | −import { Atom } from "effect/unstable/reactivity"; | |
| 14 | 9 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 15 | 10 | import { useMemo } from "react"; |
| 16 | −import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 17 | 11 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | 12 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 19 | −import { useProject } from "../state/entities"; | |
| 20 | 13 | import { useEnvironmentQuery } from "../state/query"; |
| 21 | 14 | import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; |
| 22 | 15 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 23 | −import { vcsEnvironment } from "../state/vcs"; | |
| 24 | 16 | import { useUiStateStore } from "../uiStateStore"; |
| 25 | 17 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; |
| 26 | 18 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; |
| @@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus { · 24 unmodified lines | ||
| 51 | 43 | readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>; |
| 52 | 44 | } |
| 53 | 45 | |
| 46 | +/** Keep cached summaries visible when an offscreen row stops live queries. */ | |
| 54 | 47 | export function useLinkedThreadPullRequest( |
| 55 | 48 | environmentId: EnvironmentId | null, |
| 56 | 49 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 50 | + enabled = true, | |
| 57 | 51 | ): LinkedThreadPullRequestStatus | null { |
| 58 | 52 | const queried = useEnvironmentQuery( |
| 59 | − environmentId === null || linkedPullRequest == null | |
| 53 | + !enabled || environmentId === null || linkedPullRequest == null | |
| 60 | 54 | ? null |
| 61 | 55 | : linkedPullRequestDetailAtom({ |
| 62 | 56 | environmentId, |
| @@ -178,271 +172,6 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } · 115 unmodified lines | ||
| 178 | 172 | ); |
| 179 | 173 | } |
| 180 | 174 | |
| 181 | −export function resolveThreadPr(input: { | |
| 182 | − threadBranch: string | null; | |
| 183 | − gitStatus: VcsStatusResult | null; | |
| 184 | −}): ThreadPr | null { | |
| 185 | − const { threadBranch, gitStatus } = input; | |
| 186 | − if (gitStatus === null) { | |
| 187 | − return null; | |
| 188 | − } | |
| 189 | − | |
| 190 | − if (threadBranch === null || gitStatus.refName !== threadBranch) { | |
| 191 | − return null; | |
| 192 | − } | |
| 193 | − | |
| 194 | − return gitStatus.pr ?? null; | |
| 195 | −} | |
| 196 | − | |
| 197 | −/** | |
| 198 | − * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement | |
| 199 | − * partitions move them, so terminal PR metadata must live above the row. | |
| 200 | − */ | |
| 201 | −export interface ThreadChangeRequestSnapshot { | |
| 202 | − readonly branch: string; | |
| 203 | − readonly pr: NonNullable<ThreadPr>; | |
| 204 | − readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; | |
| 205 | − readonly linkedPullRequest?: ThreadLinkedPullRequest; | |
| 206 | −} | |
| 207 | − | |
| 208 | −export const threadChangeRequestSnapshotsAtom = Atom.make< | |
| 209 | − ReadonlyMap<string, ThreadChangeRequestSnapshot> | |
| 210 | −>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); | |
| 211 | − | |
| 212 | −function isTerminalChangeRequestState( | |
| 213 | − state: NonNullable<ThreadPr>["state"], | |
| 214 | −): state is "merged" | "closed" { | |
| 215 | − return state === "merged" || state === "closed"; | |
| 216 | −} | |
| 217 | − | |
| 218 | −function sourceControlProvidersEqual( | |
| 219 | − left: VcsStatusResult["sourceControlProvider"] | undefined, | |
| 220 | − right: VcsStatusResult["sourceControlProvider"] | undefined, | |
| 221 | −): boolean { | |
| 222 | − if (left === right) return true; | |
| 223 | − if (left == null || right == null) return left == null && right == null; | |
| 224 | − return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; | |
| 225 | −} | |
| 226 | − | |
| 227 | −function linkedPullRequestsEqual( | |
| 228 | − left: ThreadLinkedPullRequest | null | undefined, | |
| 229 | − right: ThreadLinkedPullRequest | null | undefined, | |
| 230 | −): boolean { | |
| 231 | − if (left == null || right == null) return left == null && right == null; | |
| 232 | − return ( | |
| 233 | − left.projectId === right.projectId && | |
| 234 | − left.repository === right.repository && | |
| 235 | − left.number === right.number && | |
| 236 | − left.url === right.url | |
| 237 | − ); | |
| 238 | −} | |
| 239 | − | |
| 240 | −export function threadChangeRequestSnapshotsEqual( | |
| 241 | − left: ThreadChangeRequestSnapshot, | |
| 242 | − right: ThreadChangeRequestSnapshot, | |
| 243 | −): boolean { | |
| 244 | − return ( | |
| 245 | − left.branch === right.branch && | |
| 246 | − left.pr.number === right.pr.number && | |
| 247 | − left.pr.title === right.pr.title && | |
| 248 | − left.pr.url === right.pr.url && | |
| 249 | − left.pr.baseRef === right.pr.baseRef && | |
| 250 | − left.pr.headRef === right.pr.headRef && | |
| 251 | − left.pr.state === right.pr.state && | |
| 252 | − left.pr.isDraft === right.pr.isDraft && | |
| 253 | − (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && | |
| 254 | − sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && | |
| 255 | − linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) | |
| 256 | − ); | |
| 257 | −} | |
| 258 | − | |
| 259 | −export function setThreadChangeRequestSnapshot( | |
| 260 | − threadKey: string, | |
| 261 | − snapshot: ThreadChangeRequestSnapshot | null, | |
| 262 | −): void { | |
| 263 | − appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { | |
| 264 | − const existing = current.get(threadKey); | |
| 265 | − if (snapshot === null) { | |
| 266 | − if (existing === undefined) return [false, current]; | |
| 267 | − const next = new Map(current); | |
| 268 | − next.delete(threadKey); | |
| 269 | − return [true, next]; | |
| 270 | − } | |
| 271 | − if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { | |
| 272 | − return [false, current]; | |
| 273 | − } | |
| 274 | − const next = new Map(current); | |
| 275 | − next.set(threadKey, snapshot); | |
| 276 | − return [true, next]; | |
| 277 | − }); | |
| 278 | −} | |
| 279 | − | |
| 280 | −/** | |
| 281 | − * Authoritative snapshot update from live VCS status. | |
| 282 | − * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone | |
| 283 | − * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear | |
| 284 | − * - snapshot: matching branch reports a PR — store/replace | |
| 285 | − */ | |
| 286 | −export function nextThreadChangeRequestSnapshot(input: { | |
| 287 | − threadBranch: string | null; | |
| 288 | − gitStatus: VcsStatusResult | null; | |
| 289 | − snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 290 | − retainTerminalOnBranchMismatch: boolean; | |
| 291 | − linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; | |
| 292 | − linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; | |
| 293 | −}): ThreadChangeRequestSnapshot | null | undefined { | |
| 294 | − const { | |
| 295 | − threadBranch, | |
| 296 | − gitStatus, | |
| 297 | − snapshot, | |
| 298 | − retainTerminalOnBranchMismatch, | |
| 299 | − linkedPullRequest, | |
| 300 | − linkedPullRequestStatus, | |
| 301 | − } = input; | |
| 302 | − if (linkedPullRequest != null) { | |
| 303 | − if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { | |
| 304 | − return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) | |
| 305 | − ? undefined | |
| 306 | − : null; | |
| 307 | − } | |
| 308 | − return { | |
| 309 | − branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, | |
| 310 | − pr: linkedPullRequestStatus.pr, | |
| 311 | − sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, | |
| 312 | − linkedPullRequest, | |
| 313 | − }; | |
| 314 | − } | |
| 315 | − if (gitStatus === null) { | |
| 316 | − return snapshot?.linkedPullRequest === undefined ? undefined : null; | |
| 317 | − } | |
| 318 | − if (threadBranch === null) { | |
| 319 | − return null; | |
| 320 | − } | |
| 321 | − if (gitStatus.refName !== threadBranch) { | |
| 322 | − return retainTerminalOnBranchMismatch && | |
| 323 | − snapshot != null && | |
| 324 | − snapshot.linkedPullRequest === undefined && | |
| 325 | − isTerminalChangeRequestState(snapshot.pr.state) | |
| 326 | − ? undefined | |
| 327 | − : null; | |
| 328 | − } | |
| 329 | − if (gitStatus.pr == null) { | |
| 330 | − if ( | |
| 331 | − retainTerminalOnBranchMismatch && | |
| 332 | − snapshot != null && | |
| 333 | − snapshot.linkedPullRequest === undefined && | |
| 334 | − isTerminalChangeRequestState(snapshot.pr.state) | |
| 335 | − ) { | |
| 336 | − return undefined; | |
| 337 | − } | |
| 338 | − return null; | |
| 339 | − } | |
| 340 | − return { | |
| 341 | − branch: threadBranch, | |
| 342 | − pr: gitStatus.pr, | |
| 343 | − sourceControlProvider: gitStatus.sourceControlProvider, | |
| 344 | − }; | |
| 345 | −} | |
| 346 | − | |
| 347 | −/** | |
| 348 | − * Live PR when the checkout matches the thread branch; otherwise, for local | |
| 349 | − * checkouts only, a cached merged/closed PR for the thread. Local thread | |
| 350 | − * metadata follows the shared checkout, so the cached branch intentionally | |
| 351 | − * survives that metadata changing to the newly checked-out branch. Open PRs | |
| 352 | − * are never retained — their state can still change. | |
| 353 | − */ | |
| 354 | −export function resolveDisplayedThreadPr(input: { | |
| 355 | − threadBranch: string | null; | |
| 356 | − gitStatus: VcsStatusResult | null; | |
| 357 | − snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 358 | − retainTerminalOnBranchMismatch: boolean; | |
| 359 | − linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; | |
| 360 | − linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; | |
| 361 | −}): ThreadPr | null { | |
| 362 | − const { | |
| 363 | − threadBranch, | |
| 364 | − gitStatus, | |
| 365 | − snapshot, | |
| 366 | − retainTerminalOnBranchMismatch, | |
| 367 | − linkedPullRequest, | |
| 368 | − linkedPullRequestStatus, | |
| 369 | − } = input; | |
| 370 | − if (linkedPullRequest != null) { | |
| 371 | − return ( | |
| 372 | − linkedPullRequestStatus?.pr ?? | |
| 373 | − (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) | |
| 374 | − ? (snapshot?.pr ?? null) | |
| 375 | − : null) | |
| 376 | − ); | |
| 377 | − } | |
| 378 | − if ( | |
| 379 | − threadBranch !== null && | |
| 380 | − gitStatus !== null && | |
| 381 | − gitStatus.refName === threadBranch && | |
| 382 | − gitStatus.pr != null | |
| 383 | − ) { | |
| 384 | − return gitStatus.pr; | |
| 385 | − } | |
| 386 | − | |
| 387 | − if ( | |
| 388 | − threadBranch !== null && | |
| 389 | − retainTerminalOnBranchMismatch && | |
| 390 | − snapshot != null && | |
| 391 | − snapshot.linkedPullRequest === undefined && | |
| 392 | − isTerminalChangeRequestState(snapshot.pr.state) | |
| 393 | − ) { | |
| 394 | − return snapshot.pr; | |
| 395 | − } | |
| 396 | − | |
| 397 | − return null; | |
| 398 | −} | |
| 399 | − | |
| 400 | −export function resolveDisplayedThreadPrProvider(input: { | |
| 401 | − threadBranch: string | null; | |
| 402 | − gitStatus: VcsStatusResult | null; | |
| 403 | − snapshot: ThreadChangeRequestSnapshot | null | undefined; | |
| 404 | − retainTerminalOnBranchMismatch: boolean; | |
| 405 | − linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; | |
| 406 | − linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; | |
| 407 | −}): VcsStatusResult["sourceControlProvider"] | undefined { | |
| 408 | − const { | |
| 409 | − threadBranch, | |
| 410 | − gitStatus, | |
| 411 | − snapshot, | |
| 412 | − retainTerminalOnBranchMismatch, | |
| 413 | − linkedPullRequest, | |
| 414 | − linkedPullRequestStatus, | |
| 415 | − } = input; | |
| 416 | − if (linkedPullRequest != null) { | |
| 417 | − return ( | |
| 418 | − linkedPullRequestStatus?.sourceControlProvider ?? | |
| 419 | − (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) | |
| 420 | − ? snapshot?.sourceControlProvider | |
| 421 | − : undefined) | |
| 422 | − ); | |
| 423 | − } | |
| 424 | − if ( | |
| 425 | − threadBranch !== null && | |
| 426 | − gitStatus !== null && | |
| 427 | − gitStatus.refName === threadBranch && | |
| 428 | − gitStatus.pr != null | |
| 429 | − ) { | |
| 430 | − return gitStatus.sourceControlProvider; | |
| 431 | − } | |
| 432 | − | |
| 433 | − if ( | |
| 434 | − threadBranch !== null && | |
| 435 | − retainTerminalOnBranchMismatch && | |
| 436 | − snapshot != null && | |
| 437 | − snapshot.linkedPullRequest === undefined && | |
| 438 | − isTerminalChangeRequestState(snapshot.pr.state) | |
| 439 | − ) { | |
| 440 | − return snapshot.sourceControlProvider; | |
| 441 | − } | |
| 442 | − | |
| 443 | − return undefined; | |
| 444 | −} | |
| 445 | − | |
| 446 | 175 | export function terminalStatusFromRunningIds( |
| 447 | 176 | runningTerminalIds: ReadonlyArray<string>, |
| 448 | 177 | ): TerminalStatusIndicator | null { |
| @@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 102 unmodified lines | ||
| 551 | 280 | const lastVisitedAt = useUiStateStore( |
| 552 | 281 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| 553 | 282 | ); |
| 554 | − const threadProject = useProject( | |
| 555 | − useMemo( | |
| 556 | − () => scopeProjectRef(thread.environmentId, thread.projectId), | |
| 557 | − [thread.environmentId, thread.projectId], | |
| 558 | − ), | |
| 559 | − ); | |
| 560 | − const threadProjectCwd = threadProject?.workspaceRoot ?? null; | |
| 561 | − const gitCwd = thread.worktreePath ?? threadProjectCwd; | |
| 562 | − const linkedPullRequest = useLinkedThreadPullRequest( | |
| 283 | + const pullRequest = useLinkedThreadPullRequest( | |
| 563 | 284 | thread.environmentId, |
| 564 | − thread.linkedPullRequest, | |
| 565 | − ); | |
| 566 | − const gitStatus = useEnvironmentQuery( | |
| 567 | − thread.linkedPullRequest == null && | |
| 568 | − (thread.branch != null || thread.worktreePath !== null) && | |
| 569 | − gitCwd !== null | |
| 570 | − ? vcsEnvironment.status({ | |
| 571 | − environmentId: thread.environmentId, | |
| 572 | − input: { cwd: gitCwd }, | |
| 573 | − }) | |
| 574 | − : null, | |
| 575 | − ); | |
| 576 | − const pr = | |
| 577 | − thread.linkedPullRequest == null | |
| 578 | − ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | |
| 579 | − : (linkedPullRequest?.pr ?? null); | |
| 580 | − const prStatus = prStatusIndicator( | |
| 581 | − pr, | |
| 582 | − linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | |
| 285 | + thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 583 | 286 | ); |
| 287 | + const pr = pullRequest?.pr ?? null; | |
| 288 | + const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); | |
| 584 | 289 | const threadStatus = resolveThreadStatusPill({ |
| 585 | 290 | thread: { |
| 586 | 291 | ...thread, |
| @@ -8,7 +8,6 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type PullRequestListEntry, |
| 9 | 9 | type PullRequestUpdateMethod, |
| 10 | 10 | type PullRequestRef, |
| 11 | − type PullRequestState, | |
| 12 | 11 | resolveEnvironmentMachineKind, |
| 13 | 12 | type ScopedThreadRef, |
| 14 | 13 | } from "@t3tools/contracts"; |
| @@ -454,7 +453,6 @@ export function PullRequestDetailPanel({ · 439 unmodified lines | ||
| 454 | 453 | refreshToken: forcedRefreshToken = 0, |
| 455 | 454 | onActed, |
| 456 | 455 | onClose, |
| 457 | − onStateChange, | |
| 458 | 456 | context = "page", |
| 459 | 457 | composerDraftTarget, |
| 460 | 458 | }: { |
| @@ -482,8 +480,6 @@ export function PullRequestDetailPanel({ · 21 unmodified lines | ||
| 482 | 480 | onActed?: () => void; |
| 483 | 481 | /** Page-owned detail columns use this to clear the selected pull request. */ |
| 484 | 482 | onClose?: () => void; |
| 485 | − /** Keeps surrounding inferred thread state in step with refreshed host state. */ | |
| 486 | − onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; | |
| 487 | 483 | /** |
| 488 | 484 | * Beside a thread, the checkout affordance disappears: the panel is showing that thread's |
| 489 | 485 | * own pull request, so the branch is already under the reader's feet — and checking it out |
| @@ -714,14 +710,6 @@ export function PullRequestDetailPanel({ · 224 unmodified lines | ||
| 714 | 710 | } |
| 715 | 711 | activityRevision.current = next; |
| 716 | 712 | }, [activityQuery.refresh, coreDetail, tabScopeKey]); |
| 717 | − useLayoutEffect(() => { | |
| 718 | − if (!resolvedCoreDetail) return; | |
| 719 | − onStateChange?.({ | |
| 720 | − repository: resolvedCoreDetail.repository, | |
| 721 | − number: resolvedCoreDetail.number, | |
| 722 | − state: resolvedCoreDetail.state, | |
| 723 | − }); | |
| 724 | − }, [onStateChange, resolvedCoreDetail]); | |
| 725 | 713 | // Reuse activity and diff until core detail reports a changed revision. Keyed by |
| 726 | 714 | // the pull request rather than by the panel, because this one panel shows a different pull |
| 727 | 715 | // request every time it is opened. |
| @@ -49,8 +49,13 @@ in the warning. Changing a rule does not reopen already settled threads. · 48 unmodified lines | ||
| 49 | 49 | |
| 50 | 50 | ## Link a pull request |
| 51 | 51 | |
| 52 | +The server finds the PR for each unsettled thread's saved branch, even when your | |
| 53 | +apps are closed. Settled threads keep their saved links. Update the server if | |
| 54 | +automatic branch links do not appear. | |
| 55 | + | |
| 52 | 56 | On web and desktop, right-click a pull request link in a thread and choose |
| 53 | −**Link to thread**. Use **Unlink from thread** on the same link to remove it. | |
| 57 | +**Link to thread** to select a different PR. Use **Unlink from thread** on the | |
| 58 | +same link to return to the branch PR, if one exists. | |
| 54 | 59 | The linked pull request participates in automatic settlement. |
| 55 | 60 | |
| 56 | 61 | ## Find and reference work |
| @@ -306,50 +306,65 @@ describe("applyThreadDetailEvent", () => { · 305 unmodified lines | ||
| 306 | 306 | } |
| 307 | 307 | }); |
| 308 | 308 | |
| 309 | − it("sets and clears a linked pull request", () => { | |
| 310 | − const linkedPullRequest = { | |
| 311 | − projectId: ProjectId.make("project-1"), | |
| 312 | − repository: "pingdotgg/t3code", | |
| 313 | − number: 42, | |
| 314 | − url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 315 | − }; | |
| 316 | − const linked = applyThreadDetailEvent(baseThread, { | |
| 317 | − ...baseEventFields, | |
| 318 | − sequence: 5, | |
| 319 | − occurredAt: "2026-04-01T05:00:00.000Z", | |
| 320 | − aggregateKind: "thread", | |
| 321 | − aggregateId: ThreadId.make("thread-1"), | |
| 322 | − type: "thread.meta-updated", | |
| 323 | − payload: { | |
| 324 | − threadId: ThreadId.make("thread-1"), | |
| 325 | − linkedPullRequest, | |
| 326 | − updatedAt: "2026-04-01T05:00:00.000Z", | |
| 327 | − }, | |
| 328 | − }); | |
| 309 | + it.each(["linkedPullRequest", "branchPullRequest"] as const)( | |
| 310 | + "sets and clears %s without changing the other link", | |
| 311 | + (field) => { | |
| 312 | + const linkedPullRequest = { | |
| 313 | + projectId: ProjectId.make("project-1"), | |
| 314 | + repository: "pingdotgg/t3code", | |
| 315 | + number: 42, | |
| 316 | + url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 317 | + }; | |
| 318 | + const otherField = | |
| 319 | + field === "linkedPullRequest" ? "branchPullRequest" : "linkedPullRequest"; | |
| 320 | + const otherPullRequest = { | |
| 321 | + ...linkedPullRequest, | |
| 322 | + number: 43, | |
| 323 | + url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 324 | + }; | |
| 325 | + const linked = applyThreadDetailEvent( | |
| 326 | + { ...baseThread, [otherField]: otherPullRequest }, | |
| 327 | + { | |
| 328 | + ...baseEventFields, | |
| 329 | + sequence: 5, | |
| 330 | + occurredAt: "2026-04-01T05:00:00.000Z", | |
| 331 | + aggregateKind: "thread", | |
| 332 | + aggregateId: ThreadId.make("thread-1"), | |
| 333 | + type: "thread.meta-updated", | |
| 334 | + payload: { | |
| 335 | + threadId: ThreadId.make("thread-1"), | |
| 336 | + [field]: linkedPullRequest, | |
| 337 | + updatedAt: "2026-04-01T05:00:00.000Z", | |
| 338 | + }, | |
| 339 | + }, | |
| 340 | + ); | |
| 329 | 341 | |
| 330 | − expect(linked.kind).toBe("updated"); | |
| 331 | − if (linked.kind !== "updated") return; | |
| 332 | − expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest); | |
| 342 | + expect(linked.kind).toBe("updated"); | |
| 343 | + if (linked.kind !== "updated") return; | |
| 344 | + expect(linked.thread[field]).toEqual(linkedPullRequest); | |
| 345 | + expect(linked.thread[otherField]).toEqual(otherPullRequest); | |
| 333 | 346 | |
| 334 | − const cleared = applyThreadDetailEvent(linked.thread, { | |
| 335 | − ...baseEventFields, | |
| 336 | − sequence: 6, | |
| 337 | − occurredAt: "2026-04-01T06:00:00.000Z", | |
| 338 | − aggregateKind: "thread", | |
| 339 | − aggregateId: ThreadId.make("thread-1"), | |
| 340 | − type: "thread.meta-updated", | |
| 341 | − payload: { | |
| 342 | − threadId: ThreadId.make("thread-1"), | |
| 343 | − linkedPullRequest: null, | |
| 344 | − updatedAt: "2026-04-01T06:00:00.000Z", | |
| 345 | − }, | |
| 346 | − }); | |
| 347 | + const cleared = applyThreadDetailEvent(linked.thread, { | |
| 348 | + ...baseEventFields, | |
| 349 | + sequence: 6, | |
| 350 | + occurredAt: "2026-04-01T06:00:00.000Z", | |
| 351 | + aggregateKind: "thread", | |
| 352 | + aggregateId: ThreadId.make("thread-1"), | |
| 353 | + type: "thread.meta-updated", | |
| 354 | + payload: { | |
| 355 | + threadId: ThreadId.make("thread-1"), | |
| 356 | + [field]: null, | |
| 357 | + updatedAt: "2026-04-01T06:00:00.000Z", | |
| 358 | + }, | |
| 359 | + }); | |
| 347 | 360 | |
| 348 | − expect(cleared.kind).toBe("updated"); | |
| 349 | − if (cleared.kind === "updated") { | |
| 350 | − expect(cleared.thread.linkedPullRequest).toBeNull(); | |
| 351 | − } | |
| 352 | − }); | |
| 361 | + expect(cleared.kind).toBe("updated"); | |
| 362 | + if (cleared.kind === "updated") { | |
| 363 | + expect(cleared.thread[field]).toBeNull(); | |
| 364 | + expect(cleared.thread[otherField]).toEqual(otherPullRequest); | |
| 365 | + } | |
| 366 | + }, | |
| 367 | + ); | |
| 353 | 368 | }); |
| 354 | 369 | |
| 355 | 370 | describe("thread.message-sent", () => { |
| @@ -97,6 +97,7 @@ export function applyThreadDetailEvent( · 96 unmodified lines | ||
| 97 | 97 | interactionMode: event.payload.interactionMode, |
| 98 | 98 | branch: event.payload.branch, |
| 99 | 99 | worktreePath: event.payload.worktreePath, |
| 100 | + branchPullRequest: null, | |
| 100 | 101 | latestTurn: null, |
| 101 | 102 | createdAt: event.payload.createdAt, |
| 102 | 103 | updatedAt: event.payload.updatedAt, |
| @@ -240,6 +241,9 @@ export function applyThreadDetailEvent( · 137 unmodified lines | ||
| 240 | 241 | ...(event.payload.linkedPullRequest !== undefined |
| 241 | 242 | ? { linkedPullRequest: event.payload.linkedPullRequest } |
| 242 | 243 | : {}), |
| 244 | + ...(event.payload.branchPullRequest !== undefined | |
| 245 | + ? { branchPullRequest: event.payload.branchPullRequest } | |
| 246 | + : {}), | |
| 243 | 247 | updatedAt: event.payload.updatedAt, |
| 244 | 248 | }, |
| 245 | 249 | }; |
| @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | 4 | import * as Schema from "effect/Schema"; |
| 5 | +import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; | |
| 5 | 6 | |
| 6 | 7 | import { |
| 7 | 8 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| @@ -837,6 +838,47 @@ it.effect("accepts an internal title regeneration completion", () => · 829 unmodified lines | ||
| 837 | 838 | }), |
| 838 | 839 | ); |
| 839 | 840 | |
| 841 | +it.effect("accepts pull request synchronization only as an internal command", () => | |
| 842 | + Effect.gen(function* () { | |
| 843 | + const pullRequest = { | |
| 844 | + projectId: ProjectId.make("project-1"), | |
| 845 | + repository: "pingdotgg/t3code", | |
| 846 | + number: 42, | |
| 847 | + url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 848 | + }; | |
| 849 | + const command = { | |
| 850 | + type: "thread.pull-request.sync" as const, | |
| 851 | + commandId: CommandId.make("cmd-pull-request-sync"), | |
| 852 | + threadId: ThreadId.make("thread-1"), | |
| 853 | + projectId: pullRequest.projectId, | |
| 854 | + snapshotSequence: 12, | |
| 855 | + expected: { | |
| 856 | + workspaceRoot: "/workspace/project", | |
| 857 | + branch: "feature", | |
| 858 | + worktreePath: null, | |
| 859 | + linkedPullRequest: null, | |
| 860 | + branchPullRequest: null, | |
| 861 | + }, | |
| 862 | + branchPullRequest: pullRequest, | |
| 863 | + linkedPullRequest: pullRequest, | |
| 864 | + }; | |
| 865 | + | |
| 866 | + assert.deepStrictEqual(yield* decodeOrchestrationCommand(command), command); | |
| 867 | + assert.ok(yield* decodeClientOrchestrationCommand(command).pipe(Effect.flip)); | |
| 868 | + | |
| 869 | + const cleared = { ...command, branchPullRequest: null }; | |
| 870 | + assert.deepStrictEqual(yield* decodeOrchestrationCommand(cleared), cleared); | |
| 871 | + | |
| 872 | + const metadata = yield* decodeClientOrchestrationCommand({ | |
| 873 | + type: "thread.meta.update", | |
| 874 | + commandId: "cmd-forged-branch-pull-request", | |
| 875 | + threadId: "thread-1", | |
| 876 | + branchPullRequest: pullRequest, | |
| 877 | + }); | |
| 878 | + assert.isFalse("branchPullRequest" in metadata); | |
| 879 | + }), | |
| 880 | +); | |
| 881 | + | |
| 840 | 882 | it.effect("rejects an explicit title combined with title regeneration", () => |
| 841 | 883 | Effect.gen(function* () { |
| 842 | 884 | const result = yield* Effect.exit( |
| @@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({ · 493 unmodified lines | ||
| 494 | 494 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 495 | 495 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 496 | 496 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 497 | + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 497 | 498 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 498 | 499 | createdAt: IsoDateTime, |
| 499 | 500 | updatedAt: IsoDateTime, |
| @@ -572,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({ · 72 unmodified lines | ||
| 572 | 573 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 573 | 574 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 574 | 575 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 576 | + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 575 | 577 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 576 | 578 | createdAt: IsoDateTime, |
| 577 | 579 | updatedAt: IsoDateTime, |
| @@ -1183,6 +1185,23 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ · 605 unmodified lines | ||
| 1183 | 1185 | title: Schema.optional(TrimmedNonEmptyString), |
| 1184 | 1186 | }); |
| 1185 | 1187 | |
| 1188 | +const ThreadPullRequestSyncCommand = Schema.Struct({ | |
| 1189 | + type: Schema.Literal("thread.pull-request.sync"), | |
| 1190 | + commandId: CommandId, | |
| 1191 | + threadId: ThreadId, | |
| 1192 | + projectId: ProjectId, | |
| 1193 | + snapshotSequence: NonNegativeInt, | |
| 1194 | + expected: Schema.Struct({ | |
| 1195 | + workspaceRoot: TrimmedNonEmptyString, | |
| 1196 | + branch: Schema.NullOr(TrimmedNonEmptyString), | |
| 1197 | + worktreePath: Schema.NullOr(TrimmedNonEmptyString), | |
| 1198 | + linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), | |
| 1199 | + branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), | |
| 1200 | + }), | |
| 1201 | + branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), | |
| 1202 | + linkedPullRequest: Schema.optional(ThreadLinkedPullRequest), | |
| 1203 | +}); | |
| 1204 | + | |
| 1186 | 1205 | const InternalOrchestrationCommand = Schema.Union([ |
| 1187 | 1206 | ThreadAutoSettleCommand, |
| 1188 | 1207 | ThreadSessionSetCommand, |
| @@ -1194,6 +1213,7 @@ const InternalOrchestrationCommand = Schema.Union([ · 5 unmodified lines | ||
| 1194 | 1213 | ThreadActivityAppendCommand, |
| 1195 | 1214 | ThreadRevertCompleteCommand, |
| 1196 | 1215 | ThreadTitleRegenerationCompleteCommand, |
| 1216 | + ThreadPullRequestSyncCommand, | |
| 1197 | 1217 | ]); |
| 1198 | 1218 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; |
| 1199 | 1219 | |
| @@ -1367,6 +1387,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ · 167 unmodified lines | ||
| 1367 | 1387 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1368 | 1388 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1369 | 1389 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 1390 | + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), | |
| 1370 | 1391 | updatedAt: IsoDateTime, |
| 1371 | 1392 | }); |
| 1372 | 1393 | |
| @@ -9,18 +9,16 @@ describe("resolveThreadReferenceCopyTarget", () => { · 8 unmodified lines | ||
| 9 | 9 | threadId: "thread-1", |
| 10 | 10 | openPanelPullRequestUrl: null, |
| 11 | 11 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 12 | − detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 13 | 12 | }), |
| 14 | 13 | ).toBeNull(); |
| 15 | 14 | }); |
| 16 | 15 | |
| 17 | − it("prefers the open panel pull request over linked and detected pull requests", () => { | |
| 16 | + it("prefers the open panel pull request over the thread pull request", () => { | |
| 18 | 17 | expect( |
| 19 | 18 | resolveThreadReferenceCopyTarget({ |
| 20 | 19 | threadId: "thread-1", |
| 21 | 20 | openPanelPullRequestUrl: "https://github.com/t3/pr/14", |
| 22 | 21 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 23 | − detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 24 | 22 | }), |
| 25 | 23 | ).toMatchObject({ |
| 26 | 24 | kind: "pull-request", |
| @@ -29,12 +27,11 @@ describe("resolveThreadReferenceCopyTarget", () => { · 2 unmodified lines | ||
| 29 | 27 | }); |
| 30 | 28 | }); |
| 31 | 29 | |
| 32 | − it("prefers a durable linked pull request", () => { | |
| 30 | + it("uses the thread pull request when no panel is open", () => { | |
| 33 | 31 | expect( |
| 34 | 32 | resolveThreadReferenceCopyTarget({ |
| 35 | 33 | threadId: "thread-1", |
| 36 | 34 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 37 | − detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 38 | 35 | }), |
| 39 | 36 | ).toMatchObject({ |
| 40 | 37 | kind: "pull-request", |
| @@ -43,18 +40,6 @@ describe("resolveThreadReferenceCopyTarget", () => { · 2 unmodified lines | ||
| 43 | 40 | }); |
| 44 | 41 | }); |
| 45 | 42 | |
| 46 | − it("uses a pull request detected from the active branch", () => { | |
| 47 | − expect( | |
| 48 | − resolveThreadReferenceCopyTarget({ | |
| 49 | − threadId: "thread-1", | |
| 50 | − detectedPullRequestUrl: "https://github.com/t3/pr/13", | |
| 51 | − }), | |
| 52 | − ).toMatchObject({ | |
| 53 | − kind: "pull-request", | |
| 54 | − value: "https://github.com/t3/pr/13", | |
| 55 | − }); | |
| 56 | − }); | |
| 57 | − | |
| 58 | 43 | it("falls back to the thread ID", () => { |
| 59 | 44 | expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({ |
| 60 | 45 | kind: "thread", |
| @@ -11,11 +11,9 @@ export function resolveThreadReferenceCopyTarget(input: { · 10 unmodified lines | ||
| 11 | 11 | /** Undefined means no PR panel; null means its URL is not available yet. */ |
| 12 | 12 | readonly openPanelPullRequestUrl?: string | null | undefined; |
| 13 | 13 | readonly linkedPullRequestUrl?: string | null; |
| 14 | − readonly detectedPullRequestUrl?: string | null; | |
| 15 | 14 | }): ThreadReferenceCopyTarget | null { |
| 16 | 15 | if (input.openPanelPullRequestUrl === null) return null; |
| 17 | − const pullRequestUrl = | |
| 18 | − input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl; | |
| 16 | + const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl; | |
| 19 | 17 | return pullRequestUrl |
| 20 | 18 | ? { |
| 21 | 19 | kind: "pull-request", |