diff --git a/UPSTREAM.md b/UPSTREAM.md index 8a93cbef6..57de9b473 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -37,26 +37,31 @@ revision remains literal ancestry of owned `main`; it is not merely a reviewed or observed tip. The current T3 alignment is recorded in -[`docs/internals/2026-09-09-upstream-sync-6c583620.md`](docs/internals/2026-09-09-upstream-sync-6c583620.md) -and in `upstream-state.json`. This local candidate preserves all 16 official commits after -`12391bd0d38eef6655b7a9f8945d0cb5febadc2b` through -`6c583620ff7ad3235b135af7107c0543467eecfa`. Merge -`72a99fadb7d42c34fd470d82827583b239335a46` retains owned main -`083993b13b42718873721fe90c92d8cd87fedd64` and the exact official target as its parents. +[`docs/internals/2026-09-10-upstream-sync-f814983c.md`](docs/internals/2026-09-10-upstream-sync-f814983c.md) +and in `upstream-state.json`. This local candidate preserves all 23 official commits after +`6c583620ff7ad3235b135af7107c0543467eecfa` through +`f814983c262b42bd79247bae377a709925c70d63`. Merge +`b4fd31d503250e89bdd103f111ff20a16395e70a` retains owned main +`7cefb603ef43fe0c4555826ceeb67b0e2338c668` and the exact official target as its parents. Automated qualification passed; the owner will test the isolated candidate before PR delivery. No push, PR, main merge, or publication is implied. The -[preceding alignment](docs/internals/2026-09-08-upstream-sync-349ce301-review.md) remains in +[preceding alignment](docs/internals/2026-09-09-upstream-sync-6c583620.md) remains in ancestry. Later observed upstream tips do not move `integrationBase` by themselves. ## Receiving T3 updates -The local alignment through `6c583620` is documented in the -[2026-09-09 review](docs/internals/2026-09-09-upstream-sync-6c583620.md). -It adopts composer loading stability, shared setup wizards, terminal-link browser overrides, -lazy keyring loading, bounded event-replay memory, mobile thread arrangement, and Android/iOS -notification improvements. Scient provider lifecycle, fork behavior, identity, and cloud/mobile -publication holds remain. The receipt records qualification and remaining platform/manual gates. +The local alignment through `f814983c` is documented in the +[2026-09-10 review](docs/internals/2026-09-10-upstream-sync-f814983c.md). +It adopts multiple thread PR links, linked-thread search, GitHub stack actions, restart-safe PR +read caching, model bulk controls, image zoom/pan, preview-close PiP, diff palettes, and desktop/mobile +interaction fixes. Scient provider lifecycle, fork behavior, rich content, pending-save protection, +identity, and cloud/mobile publication holds remain. + +Migration IDs are immutable local history, not an upstream synchronization counter. The new T3 +multi-PR migration is registered as Scient `53`, after existing `51` and `52`; retired development +ID `50` is not reused. Never renumber recorded steps to match T3. The receipt records synthetic +upgrade/backfill tests and the remaining platform/manual gates. The canonical procedure and stop conditions live in the [T3 upstream alignment protocol](docs/internals/upstream-alignment-protocol.md). The short form is: diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index c8cb50318..ef3b4f4e8 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -111,6 +111,33 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed's ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that mix up link shapes", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://extension/attacker"), + electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -123,9 +150,10 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), + electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 261769dc3..0bd0cecec 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record = { "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", }; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, +// `zed://ssh//`) must reach the OS handler; every other non-web +// scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// Zed's host sits in the first path segment, so it needs its own userinfo ban. +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; + const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && url.username.length === 0 && url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; + (url.protocol === "zed:" + ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index b4e545fad..1a4f11b8c 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -1,5 +1,4 @@ import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; -import { BlurView } from "expo-blur"; import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { StyleProp, ViewStyle } from "react-native"; @@ -8,11 +7,11 @@ import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { appBlurTargetRef } from "../lib/appBlurTarget"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { cn } from "../lib/cn"; import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; +import { GlassBackdrop } from "./GlassBackdrop"; const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; @@ -79,8 +78,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const anchorRef = useRef(null); const overlayRef = useRef(null); - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); const close = useCallback(() => { @@ -227,16 +224,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }), }} > - {/* Frosted backdrop: blur of the app content behind the menu, - washed with the translucent card tone so rows keep contrast. */} - - + {/* keyboardShouldPersistTaps: the menu often opens over an active editor; the first item tap must act, not just dismiss the keyboard. */} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 7d32c7023..4da3a97c2 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -78,6 +78,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch"; import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; +import IconStack2 from "@tabler/icons-react-native/IconStack2"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; @@ -100,6 +101,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.right.circle": IconArrowRightCircle, "arrow.triangle.branch": IconGitBranch, "arrow.triangle.pull": IconGitPullRequest, + "square.3.layers.3d": IconStack2, "arrow.turn.left.up": IconArrowBackUp, "arrow.up": IconArrowUp, "arrow.up.circle": IconArrowUpCircle, diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index 8cc7f8523..50fbcd253 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -32,7 +32,7 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly iconNode?: ReactNode; readonly label: string; - readonly maxWidth?: number; + readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; readonly static?: boolean; diff --git a/apps/mobile/src/components/GlassBackdrop.tsx b/apps/mobile/src/components/GlassBackdrop.tsx new file mode 100644 index 000000000..f18ee7908 --- /dev/null +++ b/apps/mobile/src/components/GlassBackdrop.tsx @@ -0,0 +1,53 @@ +import { BlurView } from "expo-blur"; +import { useContext, type RefObject } from "react"; +import { Platform, StyleSheet, View, type ColorValue } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { GlassBlurTargetContext } from "../lib/glassBlurTarget"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; + +/** Frosted backdrop for containers that clip their children to their shape. */ +export function GlassBackdrop(props: { + readonly fallbackColor?: ColorValue; + readonly blurTarget?: RefObject; +}) { + const { themeAppearance } = useAppearancePreferences(); + const inheritedBlurTarget = useContext(GlassBlurTargetContext); + const target = props.blurTarget ?? inheritedBlurTarget; + const supportsBlur = + Platform.OS === "ios" || + (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); + const colorStyle = + props.fallbackColor === undefined + ? undefined + : { backgroundColor: themeColorWithAlpha(String(props.fallbackColor), 1) }; + + return ( + <> + {/* Android samples a separate target. An opaque backing prevents any + transparent pixels in that sample from exposing the unblurred feed. + iOS samples its actual backdrop, so a backing there would hide it. */} + {Platform.OS === "android" ? ( + + ) : null} + {supportsBlur ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index 577c43aa8..014a76058 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -1,17 +1,17 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; -import type { ReactNode, Ref } from "react"; +import type { ReactNode, Ref, RefObject } from "react"; import { Platform, useColorScheme, View, type ColorValue, - type StyleProp, type ViewProps, type ViewStyle, } from "react-native"; import { withUniwind } from "uniwind"; import { cn } from "../lib/cn"; +import { GlassBackdrop } from "./GlassBackdrop"; // Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. const ThemedGlassView = withUniwind(GlassView, { @@ -26,8 +26,9 @@ interface GlassSurfaceProps extends ViewProps { readonly tintColor?: ColorValue; readonly tintColorClassName?: string; readonly chrome?: "default" | "none"; - /** Styling used only when native Liquid Glass is unavailable. */ - readonly fallbackStyle?: StyleProp; + /** Base color for the frosted tint, or solid fill when blur is unavailable. */ + readonly fallbackColor?: ColorValue; + readonly blurTarget?: RefObject; /** Uniwind styling used only when native Liquid Glass is unavailable. */ readonly fallbackClassName?: string; } @@ -39,7 +40,8 @@ export function GlassSurface({ chrome = "default", tintColor, tintColorClassName, - fallbackStyle, + fallbackColor, + blurTarget, fallbackClassName, className, style, @@ -95,14 +97,13 @@ export function GlassSurface({ {...props} ref={ref} className={cn( - chrome === "none" - ? "border-0 border-transparent bg-transparent" - : "border border-border bg-glass-surface", + chrome === "none" ? "border-0 border-transparent" : "border border-border", fallbackClassName, className, )} - style={[surfaceStyle, fallbackStyle, style]} + style={[surfaceStyle, style]} > + {children} ); diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index ccb28446e..f49dea32b 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -31,6 +31,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 429a46a66..71f7e9f7e 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 52d88f33a..e03b40aae 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -36,6 +36,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index c66d775c1..1a7d9d80e 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -318,6 +319,9 @@ export function buildHomeThreadGroups(input: { : group.threads.filter( (thread) => thread.title.toLocaleLowerCase().includes(query) || + threadPullRequestSearchTerms(thread).some((term) => + term.toLocaleLowerCase().includes(query), + ) || input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index ad29e0f32..585f55a12 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -44,6 +44,7 @@ export function HardwareKeyboardCommandProvider({ ? null : resolveThreadReferenceCopyTarget({ threadId: activeThread?.id ?? activeThreadRef.threadId, + pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, }), diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index bca54694b..bc2e6fc3c 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,3 +1,4 @@ +import { BlurTargetView } from "expo-blur"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import type { MenuAction } from "@react-native-menu/menu"; @@ -154,6 +155,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ }>; export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { + const terminalBlurTarget = useRef(null); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); @@ -1260,7 +1262,21 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) /> ) : ( <> - + + - + {isAccessoryVisible ? ( - - - } - label={flow.selectedModelOption?.label ?? "Choose model"} - maxWidth={152} - onPress={settingsSheetPresentation.open} - /> + + + + } + label={flow.selectedModelOption?.label ?? "Choose model"} + maxWidth="100%" + onPress={settingsSheetPresentation.open} + /> + {flow.planModeEnabled ? ( ) : null} - + )} - + } label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth={152} + maxWidth="100%" onPress={openSettings} /> diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b5ab95c3b..3a2c49ace 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -27,6 +27,8 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; +import { BlurTargetView } from "expo-blur"; +import { GlassBlurTargetContext } from "../../lib/glassBlurTarget"; import { memo, useCallback, @@ -833,7 +835,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, scrollMessageToEnd]); const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; - const { themeAppearance } = useAppearancePreferences(); + const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { @@ -865,17 +867,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleFeedTouchCancel = useCallback(() => { feedTouchStartRef.current = null; }, []); + const feedBlurTarget = useRef(null); return ( {showContent ? ( - + - + ) : ( )} @@ -1029,41 +1041,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : undefined } > - + + + diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 40798d3db..28d5d0406 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2867,13 +2867,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} - // LegendList swaps its position and size component types when this - // becomes undefined, remounting the feed and replaying row entrances. - // Keep those containers mounted while ordinary updates stay immediate. + // Android can retain stale native row positions when layout transitions + // race the measurements arriving during sync, even with duration 0. + // Keep its rows on LegendList's non-animated positioning path. On iOS, + // keep a transition installed between disclosures so containers don't remount. itemLayoutAnimation={ - disclosureToggleSettling - ? THREAD_FEED_LAYOUT_TRANSITION - : THREAD_FEED_IMMEDIATE_TRANSITION + Platform.OS === "android" + ? undefined + : disclosureToggleSettling + ? THREAD_FEED_LAYOUT_TRANSITION + : THREAD_FEED_IMMEDIATE_TRANSITION } onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 5aefccb4b..881f461f2 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -4,6 +4,10 @@ import { getGitActionDisabledReason, requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, +} from "@t3tools/shared/threadPullRequests"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { CommonActions, @@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const isInspector = presentation === "inspector"; const environmentId = EnvironmentId.make(props.route.params.environmentId); const threadId = ThreadId.make(props.route.params.threadId); - const { selectedThread } = useThreadSelection(); + const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const supportsLinkedPrSnapshots = + selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true; + const linkedPrChains = useMemo( + () => + resolveThreadPullRequestChains( + supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [], + ), + [selectedThread?.pullRequests, supportsLinkedPrSnapshots], + ); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const theme = useUniwindTheme(); @@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { /> + {linkedPrChains.length > 0 ? ( + + + Linked pull requests + + {linkedPrChains.map((chain) => ( + + {chain.layers.length > 1 ? ( + + + + {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs + · bottom to top + + + ) : null} + {chain.layers.map((link, index) => ( + + {index > 0 ? : null} + { + void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { + if (!opened) + Alert.alert("Unable to open PR", "The pull request could not be opened."); + }); + }} + /> + + ))} + + ))} + + ) : null} + {currentWorktreePath ? : null} ); diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 00e6049a0..8478771e1 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -45,11 +45,11 @@ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; function pullRequestTintColor( - pr: Pick, + pr: Pick, colorScheme: "light" | "dark", ) { const dark = colorScheme === "dark"; - if (pr.state === "open" && pr.isDraft === true) { + if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) { return dark ? "#a1a1aa" : "#71717a"; } switch (pr.state) { @@ -57,6 +57,7 @@ function pullRequestTintColor( return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; + case null: case "closed": return dark ? "#a1a1aa" : "#71717a"; } @@ -619,15 +620,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {pr !== null ? ( - - + + {pr.kind === "stack" ? ( + + ) : ( + + )} )} {pr ? ( - - #{pr.label} - + + {pr.kind === "stack" || pr.others > 0 ? ( + + ) : null} + + {pr.kind === "stack" || pr.others > 0 ? pr.label : `#${pr.label}`} + + ) : null} {props.providerInstance ? ( 0 && !thread.title.toLocaleLowerCase().includes(query) && + !threadPullRequestSearchTerms(thread).some((term) => + term.toLocaleLowerCase().includes(query), + ) && input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/lib/glassBlurTarget.ts b/apps/mobile/src/lib/glassBlurTarget.ts new file mode 100644 index 000000000..4e6f7d3e3 --- /dev/null +++ b/apps/mobile/src/lib/glassBlurTarget.ts @@ -0,0 +1,6 @@ +import { createContext, type RefObject } from "react"; +import type { View } from "react-native"; + +// Android cannot sample a target that contains the BlurView itself. Keep the +// feed in a separate target, shared by the composer and its popovers. +export const GlassBlurTargetContext = createContext | undefined>(undefined); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23bfc36fb..0ae9936aa 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -314,6 +314,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 0ab9986b2..7f0241f94 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -167,7 +167,7 @@ export type ThreadFeedEntry = readonly summaryKind: ToolGroupSummaryKind; readonly toolSurface?: WorkLogEntry["toolSurface"]; readonly toolIcon?: WorkLogEntry["toolIcon"]; - readonly summaryToolIcon?: "browser" | "t3-code"; + readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 100391cbf..5532d6e2c 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -144,6 +144,7 @@ export function pendingThreadCreationShell( runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, branch: creation.branch, + pullRequests: [], worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath, linkedPullRequest: null, latestTurn: null, diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index fd7a17181..fc310d070 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -1,16 +1,28 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { + ExecutionEnvironmentCapabilities, + ThreadPullRequestLink, + VcsStatusResult, +} from "@t3tools/contracts"; import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; +import { + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestBadge, +} from "@t3tools/shared/threadPullRequests"; + export type ThreadPr = NonNullable; export interface ThreadPrPresentation { readonly number: number; - readonly state: ThreadPr["state"]; + readonly state: ThreadPr["state"] | null; + readonly kind: "pull-request" | "stack"; + readonly others: number; readonly isDraft: boolean; /** Provider-side last activity, bounding when a terminal state landed. */ readonly updatedAt: string | null; readonly url: string; - /** Compact pull request number label, e.g. "3774". */ + /** Compact pull request number or linked count, e.g. "3774" or "+2". */ readonly label: string; /** Full, provider-aware label for assistive technologies. */ readonly accessibilityLabel: string; @@ -30,6 +42,8 @@ export function presentThreadPr( const presentation = resolveChangeRequestPresentation(provider); const isDraft = pr.state === "open" && pr.isDraft === true; return { + kind: "pull-request", + others: 0, number: pr.number, state: pr.state, isDraft, @@ -40,3 +54,60 @@ export function presentThreadPr( textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } + +/** Persisted links render immediately, including links awaiting their first host sync. */ +export function presentThreadLinkedPullRequests( + links: ReadonlyArray, +): ThreadPrPresentation | null { + const link = resolveThreadCurrentPullRequestLink(links); + const badge = resolveThreadPullRequestBadge(links); + if (link === null || badge === null) return null; + const snapshot = link.snapshot; + const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null); + const isDraft = snapshot?.isDraft === true && state === "open"; + const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null; + const label = + badge.kind === "stack" + ? String(badge.layers) + : linkedCount !== null + ? `+${linkedCount}` + : String(link.number); + return { + kind: badge.kind, + others: badge.kind === "pull-request" ? badge.others : 0, + number: link.number, + state, + isDraft, + updatedAt: snapshot?.updatedAt ?? null, + url: link.url, + label, + accessibilityLabel: + badge.kind === "stack" + ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}` + : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`, + textClassName: + linkedCount !== null || state === null || isDraft + ? "text-foreground-muted" + : PR_STATE_TEXT_CLASS[state], + }; +} + +/** Only the array capability replaces legacy references with persisted snapshots. */ +export function resolveThreadPrSource( + thread: Pick, + capabilities: + | Pick + | undefined, +) { + const supportsSnapshots = capabilities?.threadPullRequests === true; + const linkedPresentation = supportsSnapshots + ? presentThreadLinkedPullRequests(thread.pullRequests) + : null; + const pullRequestRef = + linkedPresentation !== null + ? null + : ((supportsSnapshots + ? thread.branchPullRequest + : (thread.linkedPullRequest ?? thread.branchPullRequest)) ?? null); + return { linkedPresentation, pullRequestRef }; +} diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da7..e66f69042 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() { ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + // A pull request the action opens is linked to the thread it ran beside. + threadId: thread.id, }); if (AsyncResult.isFailure(result)) { return result; diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index f6fddfdc5..e36bd7d81 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,7 +1,11 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { presentThreadPr } from "./thread-pr-presentation"; +import { + presentThreadLinkedPullRequests, + presentThreadPr, + resolveThreadPrSource, +} from "./thread-pr-presentation"; const pullRequest: NonNullable = { number: 3774, @@ -43,3 +47,144 @@ describe("presentThreadPr", () => { }); }); }); + +function linkedPr( + number: number, + overrides: Partial = {}, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number, + url: `https://github.com/t3tools/t3code/pull/${number}`, + source: "manual", + linkedAt: "2026-09-08T00:00:00.000Z", + stack: null, + snapshot: { + state: "open", + title: `Change ${number}`, + headBranch: `change-${number}`, + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-09-08T00:00:00.000Z", + }, + ...overrides, + }; +} + +describe("presentThreadLinkedPullRequests", () => { + it("renders unsynced links with neutral pending status", () => { + expect(presentThreadLinkedPullRequests([linkedPr(1, { snapshot: null })])).toMatchObject({ + number: 1, + label: "1", + state: null, + textClassName: "text-foreground-muted", + accessibilityLabel: "#1 pull request status pending", + }); + }); + + it("counts unrelated links without labelling them a stack", () => { + expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({ + kind: "pull-request", + label: "+2", + others: 1, + textClassName: "text-foreground-muted", + }); + }); + + it("uses the top of a derived stack even when its bottom was linked later", () => { + const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" }); + const top = linkedPr(2); + expect( + presentThreadLinkedPullRequests([ + bottom, + { + ...top, + snapshot: { ...top.snapshot!, baseBranch: "change-1" }, + }, + ]), + ).toMatchObject({ kind: "stack", label: "2", number: 2, url: top.url }); + }); + + it("hides dismissed stack members", () => { + expect( + presentThreadLinkedPullRequests([linkedPr(1, { source: "stack-dismissed" })]), + ).toBeNull(); + }); + + it("retains merged state from the persisted snapshot", () => { + const link = linkedPr(1); + expect( + presentThreadLinkedPullRequests([ + { ...link, snapshot: { ...link.snapshot!, state: "merged" } }, + ]), + ).toMatchObject({ state: "merged", textClassName: "text-adaptive-violet-600-400" }); + }); +}); + +describe("resolveThreadPrSource compatibility", () => { + const legacyRef = { + projectId: ProjectId.make("project"), + repository: "t3tools/t3code", + number: 1, + url: "https://github.com/t3tools/t3code/pull/1", + }; + const branchRef = { ...legacyRef, number: 2, url: "https://github.com/t3tools/t3code/pull/2" }; + + it("polls the legacy reference when only the older linking capability exists", () => { + expect( + resolveThreadPrSource( + { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, + { threadPullRequestLinking: true }, + ), + ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef }); + }); + + it("keeps the legacy reference for a server without either capability", () => { + expect(resolveThreadPrSource({ pullRequests: [], linkedPullRequest: legacyRef }, {})).toEqual({ + linkedPresentation: null, + pullRequestRef: legacyRef, + }); + }); + + it("ignores stale snapshots after reconnecting to a server without array support", () => { + expect( + resolveThreadPrSource( + { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef }, + { threadPullRequestLinking: true, threadPullRequests: false }, + ), + ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef }); + }); + + it("uses snapshots without polling when both capabilities are advertised", () => { + expect( + resolveThreadPrSource( + { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, + { threadPullRequestLinking: true, threadPullRequests: true }, + ), + ).toMatchObject({ linkedPresentation: { number: 3 }, pullRequestRef: null }); + }); + + it("does not revive a removed modern link from the compatibility reference", () => { + expect( + resolveThreadPrSource( + { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, + { threadPullRequests: true }, + ), + ).toEqual({ linkedPresentation: null, pullRequestRef: branchRef }); + }); + + it("does not poll when a modern link is waiting for its first snapshot", () => { + expect( + resolveThreadPrSource( + { + pullRequests: [linkedPr(3, { snapshot: null })], + linkedPullRequest: legacyRef, + branchPullRequest: branchRef, + }, + { threadPullRequests: true }, + ), + ).toMatchObject({ linkedPresentation: { number: 3, state: null }, pullRequestRef: null }); + }); +}); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index e824ce562..c903abbb0 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -10,8 +10,13 @@ import { useCallback, useEffect, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; +import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; -import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; +import { + resolveThreadPrSource, + presentThreadPr, + type ThreadPrPresentation, +} from "./thread-pr-presentation"; const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); const MAX_THREAD_PR_SNAPSHOTS = 500; @@ -35,11 +40,26 @@ export { } from "./thread-pr-presentation"; /** - * Live status for a thread's server-provided PR. Visible rows share a summary - * request for the same PR in the same environment. + * Linked PRs use server snapshots. Branch fallback and legacy references share + * a live summary request across visible rows in the same environment. */ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { - const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; + const supportsLinks = useAtomValue( + serverEnvironment.configValueAtom(thread.environmentId), + (config) => config?.environment.capabilities.threadPullRequests === true, + ); + const { linkedPresentation, pullRequestRef } = useMemo( + () => + resolveThreadPrSource( + { + pullRequests: thread.pullRequests, + linkedPullRequest: thread.linkedPullRequest, + branchPullRequest: thread.branchPullRequest, + }, + { threadPullRequests: supportsLinks }, + ), + [thread.pullRequests, thread.linkedPullRequest, thread.branchPullRequest, supportsLinks], + ); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const snapshotIdentity = JSON.stringify(pullRequestRef); // Select this row's entry so writes for other rows do not re-render it. @@ -101,5 +121,5 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio }); }, [live, snapshotIdentity, threadKey]); - return live === undefined ? snapshot : live; + return linkedPresentation ?? (live === undefined ? snapshot : live); } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 5684989ef..a6116701d 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -64,6 +64,7 @@ function threadDetailToShell( branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, + pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 54aad6ada..a381fbe13 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -72,6 +72,7 @@ import { } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -430,6 +431,13 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { + start: () => Effect.void, + drain: Effect.void, + requestSync: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 312198845..0a035f949 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -84,6 +84,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 19f6b4c74..daa58b528 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -173,6 +173,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadForkTitleOverride).toBe(true); + expect(second.capabilities.threadPullRequests).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index e01319193..a54922a18 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -232,6 +232,8 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadForkTitleOverride: true, threadForkRecovery: true, + threadPullRequests: true, + pullRequestStackActions: true, threadPullRequestLinking: true, environmentIcon: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts new file mode 100644 index 000000000..2c33d8279 --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -0,0 +1,230 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type GitRunStackedActionResult, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test")); + +const project: OrchestrationProjectShell = { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + repositoryIdentity: { + canonicalKey: "github.acme.test/platform/api", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.acme.test:Platform/API.git", + }, + provider: "github", + displayName: "Platform/API", + owner: "Platform", + name: "API", + }, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +const thread: OrchestrationThreadShell = { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}; + +function prResult(pr: GitRunStackedActionResult["pr"]): Pick { + return { pr }; +} + +const makeDependencies = ( + dispatch: OrchestrationEngineShape["dispatch"], + threadShell: OrchestrationThreadShell | null = thread, +) => + Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + ); + +const recordingDispatch = Effect.fn("recordingDispatch")(function* () { + const commands = yield* Ref.make>([]); + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Ref.update(commands, (recorded) => [...recorded, command]).pipe(Effect.as({ sequence: 1 })); + return { commands, dispatch }; +}); + +describe("createdPullRequestKey", () => { + it("reads host and repository from the URL when it is recognisable", () => { + expect( + createdPullRequestKey( + prResult({ + status: "created", + number: 12, + url: "https://github.com/Other/Fork/pull/12", + }), + project, + ), + ).toEqual({ + host: "github.com", + repository: "other/fork", + number: 12, + url: "https://github.com/Other/Fork/pull/12", + }); + }); + + it("falls back to the project's host and repository for an unreadable URL", () => { + expect( + createdPullRequestKey( + prResult({ status: "opened_existing", number: 3, url: "https://ghe.internal/x/3" }), + project, + ), + ).toEqual({ + host: "github.acme.test", + repository: "platform/api", + number: 3, + url: "https://ghe.internal/x/3", + }); + expect( + createdPullRequestKey( + prResult({ status: "created", number: 3, url: "https://ghe.internal/x/3" }), + undefined, + ), + ).toBeNull(); + }); + + it("yields nothing when no pull request came out of the action", () => { + expect( + createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project), + ).toBeNull(); + expect( + createdPullRequestKey( + prResult({ status: "created", url: "https://github.com/a/b/pull/1" }), + project, + ), + ).toBeNull(); + expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull(); + }); +}); + +describe("linkCreatedPullRequest", () => { + it.effect("links a created pull request to the thread with source created", () => + Effect.gen(function* () { + const { commands, dispatch } = yield* recordingDispatch(); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ + status: "created", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }), + commandId, + }).pipe(Effect.provide(makeDependencies(dispatch))); + + expect(yield* Ref.get(commands)).toEqual([ + { + type: "thread.pull-request.link", + commandId: "server:pr-created-link:test", + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "created", + }, + ]); + }), + ); + + it.effect("dispatches nothing when the action produced no pull request", () => + Effect.gen(function* () { + const { commands, dispatch } = yield* recordingDispatch(); + const dependencies = makeDependencies(dispatch); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ status: "skipped_not_requested" }), + commandId, + }).pipe(Effect.provide(dependencies)); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }), + commandId, + }).pipe(Effect.provide(dependencies)); + + expect(yield* Ref.get(commands)).toEqual([]); + }), + ); + + it.effect("swallows an already-linked rejection and other dispatch failures", () => + Effect.gen(function* () { + const rejecting: OrchestrationEngineShape["dispatch"] = (command) => + Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "already linked", + }), + ); + const result = prResult({ + status: "opened_existing", + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + }); + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(rejecting)), + ); + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(() => Effect.die(new Error("engine down")))), + ); + // A thread that vanished between the action and the link is not an error either. + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(() => Effect.die(new Error("unreachable")), null)), + ); + }), + ); +}); diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts new file mode 100644 index 000000000..c74bc5cf6 --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -0,0 +1,98 @@ +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { + type CommandId, + pullRequestHostOf, + type GitRunStackedActionResult, + type OrchestrationProjectShell, + type SourceControlProviderKind, + type ThreadId, +} from "@t3tools/contracts"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; + +export interface CreatedPullRequestKey { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** + * The identity a stacked action's pull request should be linked under, or + * null when the action did not leave one behind. Reading the host and + * repository from the URL keeps the link host-level even when the checkout's + * remote differs from where the PR was opened (a fork, say); the project is + * only consulted when the URL is one this cannot read. + */ +export function createdPullRequestKey( + result: Pick, + project: OrchestrationProjectShell | undefined, +): CreatedPullRequestKey | null { + const { status, number, url } = result.pr; + if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) { + return null; + } + const parsed = parseChangeRequestUrl(url); + if (parsed !== null) return { ...parsed, url }; + const identity = project?.repositoryIdentity; + const kind = identity?.provider as SourceControlProviderKind | undefined; + const repository = sourceControlRepositorySelector(identity); + if (!identity || kind === undefined || repository === null) return null; + return { + host: pullRequestHostOf(identity, kind), + repository: repository.toLowerCase(), + number, + url, + }; +} + +/** + * Links the pull request a `create_pr`-shaped action produced to the thread it + * ran beside. Never fails: the git action already succeeded and its result is + * on its way to the client, so a link that cannot be made is logged and + * dropped. A duplicate link is the decider saying the thread already knew. + */ +export const linkCreatedPullRequest = (input: { + readonly threadId: ThreadId; + readonly result: Pick; + readonly commandId: Effect.Effect; +}): Effect.Effect< + void, + never, + OrchestrationEngine.OrchestrationEngineService | ProjectionSnapshotQuery.ProjectionSnapshotQuery +> => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const thread = yield* snapshots.getThreadShellById(input.threadId); + if (Option.isNone(thread) || thread.value.projectId === null) return; + const project = Option.getOrUndefined( + yield* snapshots.getProjectShellById(thread.value.projectId), + ); + const key = createdPullRequestKey(input.result, project); + if (key === null) return; + const commandId = yield* input.commandId; + yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId, + threadId: input.threadId, + ...key, + source: "created", + }) + .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void })); + }).pipe( + Effect.withSpan("linkCreatedPullRequest"), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.logWarning("failed to link created pull request to thread", { + threadId: input.threadId, + }), + ), + ); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 133e71bc3..885629930 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -6,12 +6,15 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerConfig from "../config.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -48,6 +51,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), Layer.provideMerge(NodeServices.layer), ); +const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.none()), + }), + Layer.mock(OrchestrationEngineService)({}), + NodeServices.layer, + ), + ), +); const snapshotResult = { url: "http://example.test/", @@ -345,6 +360,38 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () => ).pipe(Effect.provide(TestLayer)), ); +it.effect( + "registers the pull request toolkit and surfaces a missing capability as a tool error", + () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const names = server.tools.map(({ tool }) => tool.name); + expect(names).toEqual( + expect.arrayContaining([ + "link_pull_request", + "unlink_pull_request", + "list_thread_pull_requests", + ]), + ); + const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request"); + expect(linkTool?.tool.annotations?.idempotentHint).toBe(true); + expect(linkTool?.tool.annotations?.openWorldHint).toBe(false); + expect(linkTool?.tool.description).toContain("Register every pull request you open"); + + const denied = yield* server + .callTool({ name: "list_thread_pull_requests", arguments: {} }) + .pipe( + // A preview-only credential: the token predates the toolkit or was minted elsewhere. + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(denied.isError).toBe(true); + expect(denied.content).toEqual([ + { type: "text", text: "MCP credential does not grant the pull-requests capability." }, + ]); + }).pipe(Effect.provide(PullRequestsTestLayer)), +); + it.effect("keeps the snapshot text under the agent's output ceiling", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 7e025438d..bb0c7bafe 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -34,6 +34,8 @@ import { ScientSkillsToolkitHandlersLive } from "./toolkits/skills/handlers.ts"; import { ScientSkillsToolkit } from "./toolkits/skills/tools.ts"; import { ScientDocumentsToolkitHandlersLive } from "./toolkits/documents/handlers.ts"; import { ScientDocumentsToolkit } from "./toolkits/documents/tools.ts"; +import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; +import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -455,6 +457,9 @@ export const ScientDocumentsToolkitRegistrationLive = McpServer.toolkit( ScientDocumentsToolkit, ).pipe(Layer.provide(ScientDocumentsToolkitHandlersLive)); +export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe( + Layer.provide(PullRequestsToolkitHandlersLive), +); const McpTransportLive = McpServer.layerHttp({ name: "Scient", version: packageJson.version, @@ -467,4 +472,5 @@ export const layer = Layer.mergeAll( ScientSourcesToolkitRegistrationLive, ScientSkillsToolkitRegistrationLive, ScientDocumentsToolkitRegistrationLive, + PullRequestsToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 569917325..123944206 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + McpCapabilityUnavailableError, PreviewAutomationUnavailableError, ProviderInstanceId, ThreadId, @@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav expect(error.message).toBe("MCP credential does not grant the preview capability."); }); }); + +it.effect("reports other missing capabilities with the neutral error", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), + issuedAt: 1, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + + expect(error).toBeInstanceOf(McpCapabilityUnavailableError); + expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId }); + + const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + ); + expect(scope).toBe(invocation); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 5661ab3e2..039d15b7c 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + McpCapabilityUnavailableError, PreviewAutomationUnavailableError, type ProviderInstanceId, type ThreadId, @@ -10,6 +11,7 @@ import * as Effect from "effect/Effect"; export type McpCapability = | "preview" + | "pull-requests" | "documents:build" | "skills:read" | "sources:read" @@ -46,18 +48,32 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} -export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( - capability: "preview", -) { - const invocation = yield* McpInvocationContext; - if (!invocation.capabilities.has(capability)) { - return yield* new PreviewAutomationUnavailableError({ - capability, - environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, - }); - } - return invocation; -}); +/** The error a missing capability surfaces as; preview keeps its own so the broker can route it. */ +export type McpCapabilityError = C extends "preview" + ? PreviewAutomationUnavailableError + : McpCapabilityUnavailableError; + +const missingCapability = ( + invocation: McpInvocationScope, + capability: McpCapability, +): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => { + const fields = { + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }; + return capability === "preview" + ? new PreviewAutomationUnavailableError({ capability, ...fields }) + : new McpCapabilityUnavailableError({ capability, ...fields }); +}; + +export const requireMcpCapability = ( + capability: C, +): Effect.Effect, McpInvocationContext> => + Effect.flatMap(McpInvocationContext, (invocation) => + invocation.capabilities.has(capability) + ? Effect.succeed(invocation) + : // The conditional type narrows what the literal argument decided at runtime. + Effect.fail(missingCapability(invocation, capability) as McpCapabilityError), + ).pipe(Effect.withSpan("mcp.requireCapability")); diff --git a/apps/server/src/mcp/toolkits/documents/handlers.test.ts b/apps/server/src/mcp/toolkits/documents/handlers.test.ts index fe384300c..4906bb360 100644 --- a/apps/server/src/mcp/toolkits/documents/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/documents/handlers.test.ts @@ -217,6 +217,7 @@ const makeThread = (input: { hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, + pullRequests: [], }); const makeQuery = (input: { diff --git a/apps/server/src/mcp/toolkits/documents/latexHandler.test.ts b/apps/server/src/mcp/toolkits/documents/latexHandler.test.ts index 274bf7e55..bbc3d2c25 100644 --- a/apps/server/src/mcp/toolkits/documents/latexHandler.test.ts +++ b/apps/server/src/mcp/toolkits/documents/latexHandler.test.ts @@ -123,6 +123,7 @@ const makeThread = (input: { hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, + pullRequests: [], }); const makeQuery = (input: { diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts new file mode 100644 index 000000000..6908bee53 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -0,0 +1,431 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import type { Tool } from "effect/unstable/ai"; + +import { OrchestrationCommandInvariantError } from "../../../orchestration/Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts"; +import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(7), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +const invocation = ( + capabilities: ReadonlyArray, +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment-1"), + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(capabilities), + issuedAt: 1, +}); + +function makeProject( + repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/T3Code.git", + }, + provider: "github", + displayName: "T3Tools/T3Code", + owner: "T3Tools", + name: "T3Code", + }, +): OrchestrationProjectShell { + return { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + repositoryIdentity, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; +} + +function makeThread(pullRequests: ReadonlyArray): OrchestrationThreadShell { + return { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function makeLink( + number: number, + overrides: Partial & { + readonly headBranch?: string; + readonly baseBranch?: string; + } = {}, +): ThreadPullRequestLink { + const { headBranch, baseBranch, ...rest } = overrides; + return { + host: "github.com", + repository: "t3tools/t3code", + number, + url: `https://github.com/t3tools/t3code/pull/${number}`, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + snapshot: + headBranch === undefined + ? null + : { + state: "open", + title: `PR ${number}`, + headBranch, + baseBranch: baseBranch ?? "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-08-27T00:00:00.000Z", + }, + stack: null, + ...rest, + }; +} + +interface HarnessOptions { + readonly thread?: OrchestrationThreadShell | null; + readonly project?: OrchestrationProjectShell | null; + readonly reject?: (command: OrchestrationCommand) => OrchestrationCommandInvariantError | null; +} + +const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( + options: HarnessOptions = {}, +) { + const commands = yield* Ref.make>([]); + const thread = options.thread === undefined ? makeThread([]) : options.thread; + const project = options.project === undefined ? makeProject() : options.project; + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Effect.gen(function* () { + const rejection = options.reject?.(command) ?? null; + if (rejection !== null) return yield* rejection; + yield* Ref.update(commands, (recorded) => [...recorded, command]); + return { sequence: 1 }; + }); + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: (threadId) => + Effect.succeed(threadId === THREAD_ID ? Option.fromNullishOr(thread) : Option.none()), + getProjectShellById: () => Effect.succeed(Option.fromNullishOr(project)), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + const toolkit = yield* PullRequestsToolkit.pipe( + Effect.provide(PullRequestsToolkitHandlersLive.pipe(Layer.provide(dependencies))), + ); + const call = ( + name: Name, + params: Parameters>[1], + capabilities: ReadonlyArray = ["pull-requests"], + ) => + toolkit.handle(name, params).pipe( + Stream.unwrap, + Stream.runCollect, + // Failure mode is "error", so a delivered result is always the success shape. + Effect.map( + (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>, + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)), + Effect.provide(dependencies), + ); + return { commands, call }; +}); + +describe("pull request toolkit handlers", () => { + it.effect("rejects a historical null-project thread without dispatching", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ thread: { ...makeThread([]), projectId: null } }); + const error = yield* harness + .call("link_pull_request", { + url: "https://github.com/t3tools/t3code/pull/123", + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID }); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("refuses a credential without the pull-requests capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call("list_thread_pull_requests", {}, ["preview"]) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "McpCapabilityUnavailableError", + capability: "pull-requests", + threadId: THREAD_ID, + }); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("links by URL with source agent on the token's thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_pull_request", { + url: "https://github.com/T3Tools/T3Code/pull/123/files", + }); + expect(result).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + url: "https://github.com/T3Tools/T3Code/pull/123/files", + alreadyLinked: false, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.pull-request.link", + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 123, + source: "agent", + }, + ]); + }), + ); + + it.effect("links by repository and number, defaulting the host to the project's", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_pull_request", { + repository: "T3Tools/Other", + number: 7, + }); + expect(result).toEqual({ + host: "github.com", + repository: "t3tools/other", + number: 7, + url: "https://github.com/t3tools/other/pull/7", + alreadyLinked: false, + }); + }), + ); + + it.effect("builds the URL in the project host's own shape", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "gitlab.com/group/sub/project", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@gitlab.com:group/sub/project.git", + }, + provider: "gitlab", + displayName: "group/sub/project", + }), + }); + const result = yield* harness.call("link_pull_request", { + repository: "group/sub/project", + number: 42, + }); + expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); + expect(result.host).toBe("gitlab.com"); + }), + ); + + it.effect("rejects a target that names neither a URL nor repository and number", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call("link_pull_request", { repository: "x/y" }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestTargetIncompleteError" }); + const unknown = yield* harness + .call("link_pull_request", { + url: "https://github.com/t3tools/t3code/issues/1?token=private-value", + }) + .pipe(Effect.flip); + expect(unknown).toMatchObject({ _tag: "PullRequestUrlInvalidError" }); + expect(unknown.message).not.toContain("private-value"); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("treats a duplicate link as alreadyLinked rather than an error", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([makeLink(123)]), + reject: (command) => + command.type === "thread.pull-request.link" + ? new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "already linked", + }) + : null, + }); + const result = yield* harness.call("link_pull_request", { + url: "https://github.com/t3tools/t3code/pull/123", + }); + expect(result.alreadyLinked).toBe(true); + }), + ); + + it.effect("unlinks a linked pull request and reports a missing one as wasLinked=false", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([makeLink(5)]), + reject: (command) => + command.type === "thread.pull-request.unlink" && command.number !== 5 + ? new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "not linked", + }) + : null, + }); + const linked = yield* harness.call("unlink_pull_request", { + repository: "t3tools/t3code", + number: 5, + }); + expect(linked).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 5, + wasLinked: true, + }); + const missing = yield* harness.call("unlink_pull_request", { + url: "https://github.com/t3tools/t3code/pull/9", + }); + expect(missing.wasLinked).toBe(false); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.pull-request.unlink", number: 5 }, + ]); + }), + ); + + it.effect("fails cleanly when the token's thread no longer exists", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ thread: null }); + const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID }); + }), + ); + + it.effect("lists visible links with host state and derived chain order", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([ + makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }), + makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }), + makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }), + makeLink(9, { source: "stack-dismissed" }), + makeLink(10), + ]), + }); + const result = yield* harness.call("list_thread_pull_requests", {}); + expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]); + expect(result.pullRequests[0]).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 3, + url: "https://github.com/t3tools/t3code/pull/3", + source: "agent", + state: "open", + title: "PR 3", + headBranch: "feat-c", + baseBranch: "feat-b", + isDraft: false, + stack: { kind: "derived", position: 3, size: 3 }, + }); + expect(result.pullRequests[3]).toMatchObject({ + number: 10, + state: null, + title: null, + headBranch: null, + stack: null, + }); + expect(result.chains).toEqual([ + { kind: "derived", numbers: [1, 2, 3] }, + { kind: "derived", numbers: [10] }, + ]); + }), + ); +}); + +describe("listThreadPullRequests", () => { + it("reports a native stack position for each member", () => { + const stack = { + kind: "native" as const, + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [ + { number: 1, headBranch: "a", state: "open" as const }, + { number: 2, headBranch: "b", state: "open" as const }, + ], + }; + const result = listThreadPullRequests({ + pullRequests: [ + makeLink(2, { stack, source: "stack" }), + makeLink(1, { stack, source: "created" }), + ], + }); + expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([ + [2, { kind: "native", position: 2, size: 2 }], + [1, { kind: "native", position: 1, size: 2 }], + ]); + expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]); + }); +}); + +it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => { + const cause = new Error("database internals"); + const failure = new PullRequestLinkFailedError({ cause }); + expect(failure.message).toBe("Could not link the pull request."); + expect(failure.cause).toBe(cause); +}); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts new file mode 100644 index 000000000..1008bec06 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -0,0 +1,245 @@ +import { + CommandId, + pullRequestHostOf, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type SourceControlProviderKind, + type ThreadId, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { + type ListThreadPullRequestsResult, + PullRequestLinkFailedError, + PullRequestUrlInvalidError, + PullRequestTargetIncompleteError, + PullRequestHostRequiredError, + PullRequestUnlinkFailedError, + PullRequestListFailedError, + type PullRequestTargetInput, + PullRequestThreadNotFoundError, + PullRequestsToolkit, + type ThreadPullRequestEntry, +} from "./tools.ts"; + +interface ResolvedTarget { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** The project's host and provider supply defaults for a repository-and-number input. */ +function projectHostAndProvider(project: OrchestrationProjectShell | undefined): { + readonly host: string | null; + readonly kind: SourceControlProviderKind | null; +} { + const identity = project?.repositoryIdentity; + const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; + if (!identity || kind === null) return { host: null, kind: null }; + return { + host: pullRequestHostOf(identity, kind), + kind, + }; +} + +/** + * Turns whichever shape the agent passed into one host-level identity. A URL + * wins outright; otherwise the repository and number are completed with the + * thread's project host, which is where an agent working in that checkout + * almost always opened the pull request. + */ +const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( + input: PullRequestTargetInput, + project: OrchestrationProjectShell | undefined, +) { + if (input.url !== undefined) { + const parsed = parseChangeRequestUrl(input.url); + if (parsed === null) { + return yield* new PullRequestUrlInvalidError({}); + } + return { ...parsed, url: input.url } satisfies ResolvedTarget; + } + if (input.repository === undefined || input.number === undefined) { + return yield* new PullRequestTargetIncompleteError({}); + } + const projectHost = projectHostAndProvider(project); + const host = (input.host ?? projectHost.host)?.toLowerCase(); + if (host === undefined) { + return yield* new PullRequestHostRequiredError({}); + } + const repository = input.repository.toLowerCase(); + const url = + changeRequestUrlFor( + // The project's kind only describes its own host; another host gets no URL guess. + host === projectHost.host ? projectHost.kind : null, + host, + repository, + input.number, + ) ?? `https://${host}/${repository}/pull/${input.number}`; + return { host, repository, number: input.number, url } satisfies ResolvedTarget; +}); + +function entryOf( + link: ThreadPullRequestLink, + chains: ReturnType, +): ThreadPullRequestEntry { + const key = threadPullRequestKeyOf(link); + let stack: ThreadPullRequestEntry["stack"] = null; + for (const chain of chains) { + if (chain.layers.length < 2) continue; + const index = chain.layers.findIndex((layer) => threadPullRequestKeyOf(layer) === key); + if (index !== -1) { + stack = { kind: chain.kind, position: index + 1, size: chain.layers.length }; + break; + } + } + return { + host: link.host, + repository: link.repository, + number: link.number, + url: link.url, + source: link.source, + state: link.snapshot?.state ?? null, + title: link.snapshot?.title ?? null, + headBranch: link.snapshot?.headBranch ?? null, + baseBranch: link.snapshot?.baseBranch ?? null, + isDraft: link.snapshot?.isDraft ?? null, + stack, + }; +} + +/** What the tools report from a thread shell; exported so the shape is testable without a layer. */ +export function listThreadPullRequests( + thread: Pick, +): ListThreadPullRequestsResult { + const chains = resolveThreadPullRequestChains(thread.pullRequests); + return { + pullRequests: visibleThreadPullRequests(thread.pullRequests).map((link) => + entryOf(link, chains), + ), + chains: chains.map((chain) => ({ + kind: chain.kind, + numbers: chain.layers.map((layer) => layer.number), + })), + }; +} + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const crypto = yield* Crypto.Crypto; + + const commandId = (tag: string, threadId: ThreadId) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)), + ); + + const requireThread = Effect.fn("PullRequestsToolkit.requireThread")(function* ( + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestListFailedError, + ) { + const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); + const thread = yield* snapshots + .getThreadShellById(scope.threadId) + .pipe(Effect.mapError((cause) => new Failure({ cause }))); + if (Option.isNone(thread) || thread.value.projectId === null) { + return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); + } + return { ...thread.value, projectId: thread.value.projectId }; + }); + + const projectOf = ( + thread: OrchestrationThreadShell & { + readonly projectId: NonNullable; + }, + Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError, + ) => + snapshots.getProjectShellById(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((cause) => new Failure({ cause })), + ); + + const dispatchFailure = + (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) => + ( + cause: Cause.Cause, + ): Effect.Effect => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail(new Failure({ cause })); + + return PullRequestsToolkit.of({ + link_pull_request: (input) => + Effect.gen(function* () { + const thread = yield* requireThread(PullRequestLinkFailedError); + const project = yield* projectOf(thread, PullRequestLinkFailedError); + const target = yield* resolveTarget(input, project); + const alreadyLinked = yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId: yield* commandId("mcp-pr-link", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + url: target.url, + source: "agent", + }) + .pipe( + Effect.as(false), + // The decider rejects a second link of the same PR; for the agent that is + // the outcome it asked for, not an error. + Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(true) }), + Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)), + ); + return { ...target, alreadyLinked }; + }), + unlink_pull_request: (input) => + Effect.gen(function* () { + const thread = yield* requireThread(PullRequestUnlinkFailedError); + const project = yield* projectOf(thread, PullRequestUnlinkFailedError); + const target = yield* resolveTarget(input, project); + const wasLinked = yield* engine + .dispatch({ + type: "thread.pull-request.unlink", + commandId: yield* commandId("mcp-pr-unlink", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + }) + .pipe( + Effect.as(true), + Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(false) }), + Effect.catchCause(dispatchFailure(PullRequestUnlinkFailedError)), + ); + return { + host: target.host, + repository: target.repository, + number: target.number, + wasLinked, + }; + }), + list_thread_pull_requests: () => + requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), + }); +}); + +export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts new file mode 100644 index 000000000..04c497e8b --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -0,0 +1,231 @@ +import { + McpCapabilityUnavailableError, + PositiveInt, + PullRequestState, + ThreadPullRequestLinkSource, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + OrchestrationEngine.OrchestrationEngineService, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, +]; + +const REGISTER_EVERY_PR = + "Register every pull request you open for this thread, including each layer of a stack, right after creating it."; + +/** + * Either the pull request's URL or its repository and number. Both forms + * resolve to the same host-level identity, so the agent can pass whichever + * the host CLI handed back. + */ +export const PullRequestTargetInput = Schema.Struct({ + url: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.", + }), + ), + repository: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Repository path below the host, for example owner/repo. Required with number when url is omitted.", + }), + ), + number: Schema.optional( + PositiveInt.annotate({ + description: "Pull request number. Required with repository when url is omitted.", + }), + ), + host: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.", + }), + ), +}); +export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; + +export class PullRequestUrlInvalidError extends Schema.TaggedError()( + "PullRequestUrlInvalidError", + {}, +) { + override get message(): string { + return "This is not a recognised pull request URL. Pass repository and number instead."; + } +} + +export class PullRequestTargetIncompleteError extends Schema.TaggedError()( + "PullRequestTargetIncompleteError", + {}, +) { + override get message(): string { + return "Pass either url, or both repository and number."; + } +} + +export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestHostRequiredError", + {}, +) { + override get message(): string { + return "This thread's project has no recognised remote. Pass host or url."; + } +} + +export class PullRequestThreadNotFoundError extends Schema.TaggedError()( + "PullRequestThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +export class PullRequestLinkFailedError extends Schema.TaggedError()( + "PullRequestLinkFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not link the pull request."; + } +} + +export class PullRequestUnlinkFailedError extends Schema.TaggedError()( + "PullRequestUnlinkFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not unlink the pull request."; + } +} + +export class PullRequestListFailedError extends Schema.TaggedError()( + "PullRequestListFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not list the pull request."; + } +} + +export const PullRequestToolError = Schema.Union([ + McpCapabilityUnavailableError, + PullRequestUrlInvalidError, + PullRequestTargetIncompleteError, + PullRequestHostRequiredError, + PullRequestThreadNotFoundError, + PullRequestLinkFailedError, + PullRequestUnlinkFailedError, + PullRequestListFailedError, +]); +export type PullRequestToolError = typeof PullRequestToolError.Type; + +const PullRequestIdentity = { + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + url: Schema.String, +}; + +export const LinkPullRequestResult = Schema.Struct({ + ...PullRequestIdentity, + alreadyLinked: Schema.Boolean.annotate({ + description: "True when the pull request was linked to this thread before the call.", + }), +}); +export type LinkPullRequestResult = typeof LinkPullRequestResult.Type; + +export const UnlinkPullRequestResult = Schema.Struct({ + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + wasLinked: Schema.Boolean.annotate({ + description: "False when the pull request was not linked to this thread to begin with.", + }), +}); +export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; + +export const ThreadPullRequestEntry = Schema.Struct({ + ...PullRequestIdentity, + source: ThreadPullRequestLinkSource, + state: Schema.NullOr(PullRequestState), + title: Schema.NullOr(Schema.String), + headBranch: Schema.NullOr(Schema.String), + baseBranch: Schema.NullOr(Schema.String), + isDraft: Schema.NullOr(Schema.Boolean), + stack: Schema.NullOr( + Schema.Struct({ + kind: Schema.Literals(["native", "derived"]), + /** 1-based, bottom of the stack first. */ + position: Schema.Int, + size: Schema.Int, + }), + ), +}); +export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type; + +export const ListThreadPullRequestsResult = Schema.Struct({ + pullRequests: Schema.Array(ThreadPullRequestEntry), + chains: Schema.Array( + Schema.Struct({ + kind: Schema.Literals(["native", "derived"]), + /** Bottom to top. */ + numbers: Schema.Array(Schema.Int), + }), + ), +}); +export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; + +const LinkPullRequestTool = Tool.make("link_pull_request", { + description: `${REGISTER_EVERY_PR} Links a pull request to this thread so Scient tracks it, shows its status beside the thread, and settles the thread when it merges. Pass the URL, or repository plus number. Linking an already-linked pull request succeeds with alreadyLinked=true.`, + parameters: PullRequestTargetInput, + success: LinkPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Link pull request to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { + description: + "Remove a pull request link from this thread, for example after closing a pull request you opened by mistake. Pass the URL, or repository plus number. Unlinking a pull request that is not linked succeeds with wasLinked=false.", + parameters: PullRequestTargetInput, + success: UnlinkPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Unlink pull request from thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { + description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + success: ListThreadPullRequestsResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "List thread pull requests") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const PullRequestsToolkit = Toolkit.make( + LinkPullRequestTool, + UnlinkPullRequestTool, + ListThreadPullRequestsTool, +); diff --git a/apps/server/src/mcp/toolkits/sources/handlers.test.ts b/apps/server/src/mcp/toolkits/sources/handlers.test.ts index 1f45f198a..99b17387e 100644 --- a/apps/server/src/mcp/toolkits/sources/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/sources/handlers.test.ts @@ -151,6 +151,7 @@ const makeThread = (input: { hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, + pullRequests: [], }); const makeQuery = (input: { diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 8fef0edab..2a12a96e0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -30,7 +30,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -63,7 +63,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -function makeOrchestrationLayer(databasePath?: string) { +function makeOrchestrationLayer( + databasePath?: string, + repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], +) { const persistence = databasePath ? makeSqlitePersistenceLive(databasePath) : SqlitePersistenceMemory; @@ -81,15 +84,27 @@ function makeOrchestrationLayer(databasePath?: string) { Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide( + repositoryIdentityResolver + ? Layer.succeed( + RepositoryIdentityResolver.RepositoryIdentityResolver, + repositoryIdentityResolver, + ) + : RepositoryIdentityResolver.layer, + ), Layer.provideMerge(persistence), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); } -async function createOrchestrationSystem(databasePath?: string) { - const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath)); +async function createOrchestrationSystem( + databasePath?: string, + repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], +) { + const runtime = ManagedRuntime.make( + makeOrchestrationLayer(databasePath, repositoryIdentityResolver), + ); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { @@ -376,6 +391,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", @@ -1012,7 +1028,22 @@ describe("OrchestrationEngine", () => { it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)( "rejects PR discovery completed after a newer %s command", async (change) => { - const system = await createOrchestrationSystem(); + const system = await createOrchestrationSystem(undefined, { + resolve: (workspaceRoot) => + Effect.succeed({ + canonicalKey: "example.test/owner/repository", + provider: "github", + displayName: "owner/repository", + rootPath: workspaceRoot, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://example.test/owner/repository.git", + }, + }), + }); + // Same-tick links must replace the old PR, not rely on timestamp ordering. + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse(now())); try { const projectId = ProjectId.make("pr-race-project"); const threadId = ThreadId.make("pr-race-thread"); @@ -1061,6 +1092,7 @@ describe("OrchestrationEngine", () => { linkedPullRequest: previous, }), ); + expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous); const metadataChanges = { unlink: { linkedPullRequest: null }, relink: { @@ -1114,6 +1146,9 @@ describe("OrchestrationEngine", () => { if (change === "delete") return; const current = (await system.readModel()).threads[0]; expect(current?.branchPullRequest ?? null).toBeNull(); + expect(current?.pullRequests.map((link) => link.number)).toEqual( + change === "unlink" ? [] : change === "relink" ? [3] : [1], + ); expect(current?.linkedPullRequest ?? null).toEqual( change === "unlink" ? null @@ -1122,6 +1157,7 @@ describe("OrchestrationEngine", () => { : previous, ); } finally { + clock.mockRestore(); await system.dispose(); } }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index bc1194ff9..316e4b3ba 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -289,6 +289,29 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + // New and moved projects do not carry a resolved identity in the event-derived + // command model. Legacy PR edits need it to identify the link they replace. + if ( + envelope.command.type === "thread.meta.update" && + envelope.command.linkedPullRequest !== undefined + ) { + const threadId = envelope.command.threadId; + const thread = commandReadModel.threads.find((thread) => thread.id === threadId); + if (thread !== undefined && thread.projectId !== null) { + const project = yield* projectionSnapshotQuery.getProjectShellById(thread.projectId); + if (Option.isSome(project)) { + commandReadModel = { + ...commandReadModel, + projects: commandReadModel.projects.map((entry) => + entry.id === thread.projectId + ? { ...entry, repositoryIdentity: project.value.repositoryIdentity } + : entry, + ), + }; + } + } + } + // Command snapshots omit activities at startup and cap them while running. // Read this request's durable state before deciding how to send the answer. const userInputActivity = diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 48e908b1b..4922fe505 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -14,6 +14,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; @@ -111,6 +112,16 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { + start: () => { + started.push("pull-request-sync-reactor"); + return Effect.void; + }, + drain: Effect.void, + requestSync: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -137,6 +148,7 @@ describe("OrchestrationReactor", () => { "thread-deletion-reactor", "thread-pull-request-reactor", "thread-settlement-reactor", + "pull-request-sync-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index a250a6536..16bd225a0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -14,6 +14,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -26,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { // SCIENT-FORK:END const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; @@ -40,6 +42,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* threadDeletionReactor.start(); yield* threadPullRequestReactor.start(); yield* threadSettlementReactor.start(); + yield* pullRequestSyncReactor.start(); yield* agentAwarenessRelay.start(); yield* queueWorker.start; }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 26f86f1ae..69e769fa8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -8,6 +8,7 @@ import { MessageId, ProjectId, ThreadId, + type ThreadPullRequestSnapshot, ThreadLinkedPullRequest, TurnId, ProviderInstanceId, @@ -764,6 +765,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull-requests-")))( + "OrchestrationProjectionPipeline pull request links", + (it) => { + it.effect("projects link, sync, unlink, legacy replay and delete into the link table", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-pr"); + const projectId = ProjectId.make("project-pr"); + const t0 = "2026-01-01T00:00:00.000Z"; + let counter = 0; + const base = (occurredAt: string) => { + counter += 1; + return { + eventId: EventId.make(`evt-pr-${counter}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make(`cmd-pr-${counter}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-pr-${counter}`), + metadata: {}, + } as const; + }; + const readLinks = () => + sql<{ + readonly host: string; + readonly repository: string; + readonly number: number; + readonly source: string; + readonly linkedAt: string; + readonly snapshotJson: string | null; + readonly stackJson: string | null; + }>` + SELECT + host, + repository, + number, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshotJson", + stack_json AS "stackJson" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY number ASC + `; + const readThreadUpdatedAt = () => + sql<{ readonly updatedAt: string }>` + SELECT updated_at AS "updatedAt" FROM projection_threads WHERE thread_id = ${threadId} + `; + + yield* eventStore.append({ + ...base(t0), + type: "thread.created", + payload: { + threadId, + projectId, + title: "Thread PR", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: t0, + updatedAt: t0, + }, + }); + + // Legacy single-link event replays into a manual row with the URL host. + yield* eventStore.append({ + ...base("2026-01-01T00:00:01.000Z"), + type: "thread.meta-updated", + payload: { + threadId, + linkedPullRequest: { + projectId, + repository: "web", + number: 41, + url: "https://org-a.visualstudio.com/DefaultCollection/project/_git/web/pullrequest/41", + }, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:02.000Z"), + type: "thread.pull-request-linked", + payload: { + threadId, + link: { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "created", + linkedAt: "2026-01-01T00:00:02.000Z", + snapshot: null, + stack: null, + }, + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + assert.deepEqual(yield* readLinks(), [ + { + host: "dev.azure.com", + repository: "org-a/project/_git/web", + number: 41, + source: "manual", + linkedAt: "2026-01-01T00:00:01.000Z", + snapshotJson: null, + stackJson: null, + }, + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + source: "created", + linkedAt: "2026-01-01T00:00:02.000Z", + snapshotJson: null, + stackJson: null, + }, + ]); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:02.000Z" }]); + + // Sync fills snapshot/stack on the matching row; a sync for an unknown + // link is ignored. + const snapshot: ThreadPullRequestSnapshot = { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-01T00:00:02.500Z", + syncedAt: "2026-01-01T00:00:03.000Z", + }; + yield* eventStore.append({ + ...base("2026-01-01T00:00:03.000Z"), + type: "thread.pull-request-synced", + payload: { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:03.500Z"), + type: "thread.pull-request-synced", + payload: { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 99, + snapshot, + stack: null, + updatedAt: "2026-01-01T00:00:03.500Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const synced = yield* readLinks(); + assert.equal(synced.length, 2); + assert.equal(synced[0]?.snapshotJson, null); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(synced[1]?.snapshotJson ?? "null"), snapshot); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:03.000Z" }]); + + // A legacy null clears only the manual row; created/agent/stack rows stay. + yield* eventStore.append({ + ...base("2026-01-01T00:00:04.000Z"), + type: "thread.meta-updated", + payload: { + threadId, + linkedPullRequest: null, + updatedAt: "2026-01-01T00:00:04.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual( + (yield* readLinks()).map((row) => row.number), + [42], + ); + + yield* eventStore.append({ + ...base("2026-01-01T00:00:05.000Z"), + type: "thread.pull-request-unlinked", + payload: { + threadId, + host: "GitHub.COM", + repository: "PingDotGG/T3Code", + number: 42, + updatedAt: "2026-01-01T00:00:05.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readLinks(), []); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]); + + // Deleting the thread clears whatever links it still had. + yield* eventStore.append({ + ...base("2026-01-01T00:00:06.000Z"), + type: "thread.pull-request-linked", + payload: { + threadId, + link: { + host: "github.com", + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + source: "agent", + linkedAt: "2026-01-01T00:00:06.000Z", + snapshot: null, + stack: null, + }, + updatedAt: "2026-01-01T00:00:06.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:07.000Z"), + type: "thread.deleted", + payload: { + threadId, + deletedAt: "2026-01-01T00:00:07.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readLinks(), []); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ec9d60d86..60973f3a3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -16,6 +16,10 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; @@ -32,6 +36,7 @@ import { type ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import * as ProjectionThreadPullRequests from "../../persistence/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { type ProjectionTurn, @@ -490,6 +495,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; + const projectionThreadPullRequestRepository = + yield* ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -624,6 +631,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.created": + // A draft retry can re-create this id; links belong to the old incarnation. + yield* projectionThreadPullRequestRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); yield* projectionThreadRepository.upsert({ threadId: event.payload.threadId, projectId: event.payload.projectId, @@ -873,6 +884,94 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti : {}), updatedAt: event.payload.updatedAt, }); + // Legacy single-link events replay into the link table. The old + // field held one user-chosen link, so it only ever owns the manual + // rows; created/agent/stack links are left alone. + if (event.payload.linkedPullRequest !== undefined) { + yield* projectionThreadPullRequestRepository.deleteByThreadIdAndSource({ + threadId: event.payload.threadId, + source: "manual", + }); + if (event.payload.linkedPullRequest !== null) { + const linked = event.payload.linkedPullRequest; + yield* projectionThreadPullRequestRepository.upsert({ + threadId: event.payload.threadId, + ...legacyThreadPullRequestKey(linked), + url: linked.url, + source: "manual", + linkedAt: event.payload.updatedAt, + snapshot: null, + stack: null, + }); + } + } + return; + } + + case "thread.pull-request-linked": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadPullRequestRepository.upsert({ + threadId: event.payload.threadId, + ...event.payload.link, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pull-request-unlinked": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadPullRequestRepository.delete({ + threadId: event.payload.threadId, + host: event.payload.host.toLowerCase(), + repository: event.payload.repository.toLowerCase(), + number: event.payload.number, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pull-request-synced": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + // A sync for a link the user removed in the meantime is stale; drop it. + const links = yield* projectionThreadPullRequestRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const link = links.find((candidate) => + threadPullRequestKeysEqual(candidate, event.payload), + ); + if (link === undefined) { + return; + } + yield* projectionThreadPullRequestRepository.upsert({ + ...link, + snapshot: event.payload.snapshot, + stack: event.payload.stack, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); return; } @@ -919,6 +1018,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (!recreatedLater) { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); } + // A tombstoned thread must not show up as linked to a pull request. + yield* projectionThreadPullRequestRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -2165,6 +2268,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), + Layer.provideMerge(ProjectionThreadPullRequests.layer), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 2be3e022f..e6ab26c22 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -6,6 +6,7 @@ import { MessageId, ProjectId, ThreadId, + type ThreadPullRequestLink, ThreadLinkedPullRequest, TurnId, ProviderInstanceId, @@ -66,6 +67,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; yield* sql`DELETE FROM projection_thread_proposed_plans`; + yield* sql`DELETE FROM projection_thread_pull_requests`; yield* sql`DELETE FROM projection_turns`; yield* sql` @@ -91,6 +93,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + // A merged link plus a newer open one: the multi-link projection must + // resolve to the open pull request, not the stale JSON column below. + yield* sql` + INSERT INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES + ( + 'thread-1', + 'github.com', + 'pingdotgg/t3code', + 41, + 'https://github.com/pingdotgg/t3code/pull/41', + 'created', + '2026-02-24T00:00:02.500Z', + '{"state":"merged","title":"Groundwork","headBranch":"feat/groundwork","baseBranch":"main","isDraft":false,"updatedAt":"2026-02-24T00:00:02.600Z","syncedAt":"2026-02-24T00:00:02.700Z"}', + NULL + ), + ( + 'thread-1', + 'github.com', + 'pingdotgg/t3code', + 42, + 'https://github.com/pingdotgg/t3code/pull/42', + 'manual', + '2026-02-24T00:00:03.000Z', + NULL, + NULL + ) + `; + yield* sql` INSERT INTO projection_threads ( thread_id, @@ -124,7 +165,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'default', NULL, NULL, - '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', + '{"projectId":"project-1","repository":"pingdotgg/t3code","number":41,"url":"https://github.com/pingdotgg/t3code/pull/41"}', ${encodeThreadLinkedPullRequest(branchPullRequest)}, 'turn-1', '2026-02-24T00:00:04.000Z', @@ -286,6 +327,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { sequence += 1; } + const expectedPullRequests: ReadonlyArray = [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 41, + url: "https://github.com/pingdotgg/t3code/pull/41", + source: "created", + linkedAt: "2026-02-24T00:00:02.500Z", + snapshot: { + state: "merged", + title: "Groundwork", + headBranch: "feat/groundwork", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-02-24T00:00:02.600Z", + syncedAt: "2026-02-24T00:00:02.700Z", + }, + stack: null, + }, + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-02-24T00:00:03.000Z", + snapshot: null, + stack: null, + }, + ]; + const snapshot = yield* snapshotQuery.getSnapshot(); assert.equal(snapshot.snapshotSequence, 5); @@ -332,12 +404,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, - linkedPullRequest: { - projectId: asProjectId("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }, + pullRequests: expectedPullRequests, branchPullRequest, latestCompletedAnswer: { turnId: asTurnId("turn-1"), @@ -469,12 +536,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, - linkedPullRequest: { - projectId: asProjectId("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }, + pullRequests: expectedPullRequests, branchPullRequest, latestCompletedAnswer: { turnId: asTurnId("turn-1"), @@ -530,13 +592,27 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } - const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); - assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq"); - assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); assert.equal(threadShell._tag, "Some"); if (threadShell._tag === "Some") { - assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest); + assert.deepEqual(threadShell.value, shellSnapshot.threads[0]); + } + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.deepEqual(commandReadModel.threads[0]?.pullRequests, expectedPullRequests); + assert.deepEqual( + commandReadModel.threads[0]?.linkedPullRequest, + snapshot.threads[0]?.linkedPullRequest, + ); + + // Without link rows the legacy field is omitted, whatever the old JSON + // column still holds. + yield* sql`DELETE FROM projection_thread_pull_requests`; + const unlinkedShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(unlinkedShell._tag, "Some"); + if (unlinkedShell._tag === "Some") { + assert.deepEqual(unlinkedShell.value.pullRequests, []); + assert.equal("linkedPullRequest" in unlinkedShell.value, false); } yield* sql` @@ -3660,3 +3736,56 @@ projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { }), ); }); + +it.effect("omits foreign-host PRs from legacy snapshots while preserving native links", () => { + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/acme/web.git", + }, + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project-1', 'Project', '/repo', '[]', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at) + VALUES ('thread-1', 'project-1', 'Thread', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('thread-1', 'github.enterprise.test', 'acme/web', 42, 'https://github.enterprise.test/acme/web/pull/42', 'manual', '2026-09-09T00:00:00Z')`; + const readThreads = Effect.gen(function* () { + const full = yield* query.getSnapshot(); + const shell = yield* query.getShellSnapshot(); + const detail = yield* query.getThreadDetailById(ThreadId.make("thread-1")); + const individual = yield* query.getThreadShellById(ThreadId.make("thread-1")); + return [ + full.threads[0]!, + shell.threads[0]!, + Option.getOrThrow(detail), + Option.getOrThrow(individual), + ]; + }); + for (const thread of yield* readThreads) { + assert.equal(thread.linkedPullRequest ?? null, null); + assert.equal(thread.pullRequests[0]?.host, "github.enterprise.test"); + } + yield* sql`UPDATE projection_thread_pull_requests SET host = 'github.com', url = 'https://github.com/acme/web/pull/42'`; + for (const thread of yield* readThreads) { + assert.equal(thread.linkedPullRequest?.url, "https://github.com/acme/web/pull/42"); + } + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index ab24efec3..943596f12 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -31,7 +31,11 @@ import { ProjectId, ThreadLinkedPullRequest, ThreadId, + ThreadPullRequestSnapshot, + ThreadPullRequestStack, + type ThreadPullRequestLink, } from "@t3tools/contracts"; +import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -56,6 +60,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadPullRequest } from "../../persistence/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { @@ -123,6 +128,12 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema Struct.assign({ hasOtherUserMessages: Schema.Number }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; +const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFields( + Struct.assign({ + snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), + stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), + }), +); const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ latestCompletedAnswer: Schema.NullOr(Schema.fromJsonString(ScientCompletedAnswer)), @@ -449,6 +460,49 @@ function mapProposedPlanRow( }; } +function mapPullRequestRow( + row: Schema.Schema.Type, +): ThreadPullRequestLink { + return { + host: row.host, + repository: row.repository, + number: row.number, + url: row.url, + source: row.source, + linkedAt: row.linkedAt, + snapshot: row.snapshot, + stack: row.stack, + }; +} + +function groupPullRequestRowsByThread( + rows: ReadonlyArray>, +): Map> { + const byThread = new Map>(); + for (const row of rows) { + const links = byThread.get(row.threadId) ?? []; + links.push(mapPullRequestRow(row)); + byThread.set(row.threadId, links); + } + return byThread; +} + +/** + * The link array plus the legacy single-link field derived from it, so clients + * from before `pullRequests` keep seeing the thread's current pull request. + */ +function mapThreadPullRequests( + pullRequests: ReadonlyArray, + projectId: ProjectId | null, + identity?: OrchestrationProject["repositoryIdentity"], +): Pick { + const linkedPullRequest = legacyLinkedPullRequestOf(pullRequests, projectId, identity); + return { + pullRequests, + ...(linkedPullRequest === null ? {} : { linkedPullRequest }), + }; +} + function mapThreadActivityRow( row: Schema.Schema.Type, ): OrchestrationThreadActivity { @@ -702,6 +756,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + ORDER BY thread_id ASC, linked_at ASC, number ASC + `, + }); + + const listActiveThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC + `, + }); + + const listArchivedThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC + `, + }); + const listThreadActivityRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadActivityDbRowSchema, @@ -1311,6 +1433,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadPullRequestRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY linked_at ASC, number ASC + `, + }); + const listThreadActivityRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -2034,6 +2177,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listThreadActivityRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2091,6 +2242,7 @@ pending_approval_requests AS ( threadRows, messageRows, proposedPlanRows, + pullRequestRows, activityRows, sessionRows, checkpointRows, @@ -2101,6 +2253,7 @@ pending_approval_requests AS ( Effect.gen(function* () { const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const activitiesByThread = new Map>(); const checkpointsByThread = new Map>(); const forkLineageByThread = new Map(); @@ -2254,7 +2407,10 @@ pending_approval_requests AS ( id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, - repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, + repositoryIdentity: + row.projectId === null + ? undefined + : (repositoryIdentities.get(row.projectId) ?? null), defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, @@ -2276,10 +2432,12 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + row.projectId === null ? undefined : repositoryIdentities.get(row.projectId), + ), branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, latestCompletedAnswer: row.latestCompletedAnswer, createdAt: row.createdAt, @@ -2353,6 +2511,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:decodeRows", + ), + ), + ), listThreadSessionRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2381,8 +2547,25 @@ pending_approval_requests AS ( ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => - Effect.sync(() => { + ([ + projectRows, + threadRows, + proposedPlanRows, + pullRequestRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => + Effect.gen(function* () { + const linkedThreadIds = new Set(pullRequestRows.map((row) => row.threadId)); + const linkedProjectIds = new Set( + threadRows + .filter((row) => linkedThreadIds.has(row.threadId)) + .map((row) => row.projectId), + ); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => linkedProjectIds.has(row.projectId)), + ); let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; @@ -2397,6 +2580,10 @@ pending_approval_requests AS ( id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + repositoryIdentity: + row.projectId === null + ? undefined + : (repositoryIdentities.get(row.projectId) ?? null), defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, @@ -2459,6 +2646,7 @@ pending_approval_requests AS ( latestTurnByThread.set(row.threadId, mapLatestTurn(row)); } const proposedPlansByThread = new Map>(); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const sessionByThread = new Map(); for (let index = 0; index < sessionRows.length; index += 1) { @@ -2494,10 +2682,12 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + row.projectId === null ? undefined : repositoryIdentities.get(row.projectId), + ), branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, latestCompletedAnswer: row.latestCompletedAnswer, createdAt: row.createdAt, @@ -2565,6 +2755,14 @@ pending_approval_requests AS ( ), ), ), + listActiveThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listActiveLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2593,7 +2791,15 @@ pending_approval_requests AS ( ) .pipe( Effect.flatMap( - ([projectRows, threadRows, sessionRows, latestTurnRows, forkLineageRows, stateRows]) => + ([ + projectRows, + threadRows, + sessionRows, + pullRequestRows, + latestTurnRows, + forkLineageRows, + stateRows, + ]) => Effect.gen(function* () { let updatedAt: string | null = null; for (const row of projectRows) { @@ -2629,13 +2835,19 @@ pending_approval_requests AS ( const forkLineageByThread = new Map( forkLineageRows.map((row) => [row.threadId, row] as const), ); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + mapProjectShellRow( + row, + row.projectId === null + ? undefined + : (repositoryIdentities.get(row.projectId) ?? null), + ), ) : Result.failVoid, ), @@ -2652,9 +2864,13 @@ pending_approval_requests AS ( branch: row.branch, worktreePath: row.worktreePath, branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + row.projectId === null + ? undefined + : repositoryIdentities.get(row.projectId), + ), latestTurn: latestTurnByThread.get(row.threadId) ?? null, latestCompletedAnswer: row.latestCompletedAnswer, createdAt: row.createdAt, @@ -2730,6 +2946,14 @@ pending_approval_requests AS ( ), ), ), + listArchivedThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listArchivedLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2758,7 +2982,15 @@ pending_approval_requests AS ( ) .pipe( Effect.flatMap( - ([projectRows, threadRows, sessionRows, latestTurnRows, forkLineageRows, stateRows]) => + ([ + projectRows, + threadRows, + sessionRows, + pullRequestRows, + latestTurnRows, + forkLineageRows, + stateRows, + ]) => Effect.gen(function* () { let updatedAt: string | null = null; for (const row of projectRows) { @@ -2783,6 +3015,7 @@ pending_approval_requests AS ( updatedAt = maxIso(updatedAt, row.updatedAt); } + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( projectRows.filter((row) => activeProjectIds.has(row.projectId)), @@ -2802,7 +3035,12 @@ pending_approval_requests AS ( projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null && activeProjectIds.has(row.projectId) ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + mapProjectShellRow( + row, + row.projectId === null + ? undefined + : (repositoryIdentities.get(row.projectId) ?? null), + ), ) : Result.failVoid, ), @@ -2817,9 +3055,11 @@ pending_approval_requests AS ( branch: row.branch, worktreePath: row.worktreePath, branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + row.projectId === null ? undefined : repositoryIdentities.get(row.projectId), + ), latestTurn: latestTurnByThread.get(row.threadId) ?? null, latestCompletedAnswer: row.latestCompletedAnswer, createdAt: row.createdAt, @@ -3101,40 +3341,49 @@ pending_approval_requests AS ( const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { - const [threadRow, latestTurnRow, sessionRow, forkLineageRow] = yield* Effect.all([ - getActiveThreadRowById({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadShellById:getThread:query", - "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow", + const [threadRow, latestTurnRow, sessionRow, forkLineageRow, pullRequestRows] = + yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getThread:query", + "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow", + ), ), ), - ), - getLatestTurnRowByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:query", - "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:decodeRow", + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:decodeRow", + ), ), ), - ), - getThreadSessionRowByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadShellById:getSession:query", - "ProjectionSnapshotQuery.getThreadShellById:getSession:decodeRow", + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getSession:query", + "ProjectionSnapshotQuery.getThreadShellById:getSession:decodeRow", + ), ), ), - ), - getForkLineageRowByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadShellById:getForkLineage:query", - "ProjectionSnapshotQuery.getThreadShellById:getForkLineage:decodeRow", + getForkLineageRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getForkLineage:query", + "ProjectionSnapshotQuery.getThreadShellById:getForkLineage:decodeRow", + ), ), ), - ), - ]); + listThreadPullRequestRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query", + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows", + ), + ), + ), + ]); if (Option.isNone(threadRow)) { return Option.none(); @@ -3150,10 +3399,15 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...mapThreadPullRequests( + pullRequestRows.map(mapPullRequestRow), + threadRow.value.projectId, + pullRequestRows.length === 0 || threadRow.value.projectId === null + ? null + : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) + ?.repositoryIdentity, + ), branchPullRequest: threadRow.value.branchPullRequest, - ...(threadRow.value.linkedPullRequest === null - ? {} - : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, latestCompletedAnswer: threadRow.value.latestCompletedAnswer, createdAt: threadRow.value.createdAt, @@ -3363,6 +3617,7 @@ pending_approval_requests AS ( threadRow, messageRows, proposedPlanRows, + pullRequestRows, activities, checkpointRows, forkLineageRow, @@ -3396,6 +3651,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:decodeRows", + ), + ), + ), activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( @@ -3465,10 +3728,15 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...mapThreadPullRequests( + pullRequestRows.map(mapPullRequestRow), + threadRow.value.projectId, + pullRequestRows.length === 0 || threadRow.value.projectId === null + ? null + : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) + ?.repositoryIdentity, + ), branchPullRequest: threadRow.value.branchPullRequest, - ...(threadRow.value.linkedPullRequest === null - ? {} - : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, latestCompletedAnswer: threadRow.value.latestCompletedAnswer, createdAt: threadRow.value.createdAt, diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts new file mode 100644 index 000000000..54aeca93b --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -0,0 +1,721 @@ +import { + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestRef, + type PullRequestStack, + type PullRequestSummary, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("sync-project"); + +type SyncCommand = Extract< + OrchestrationCommand, + { readonly type: "thread.pull-request-link.sync" } +>; +type LinkCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject(id: ProjectId = PROJECT_ID): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeLink( + number: number, + snapshot: Partial | null = null, + overrides: Partial = {}, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "owner/repository", + number, + url: `https://github.com/owner/repository/pull/${number}`, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + snapshot: + snapshot === null + ? null + : { + state: "open", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-08-27T00:00:00.000Z", + syncedAt: "2026-08-27T00:00:00.000Z", + ...snapshot, + }, + stack: null, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + snapshotSequence = 1, +): OrchestrationShellSnapshot { + return { + snapshotSequence, + projects: [makeProject()], + threads, + updatedAt: NOW, + }; +} + +function makeSummary( + input: PullRequestRef, + overrides: Partial = {}, +): PullRequestSummary { + return { + provider: "github", + projectId: input.projectId, + repository: input.repository, + number: input.number, + title: "Pull request", + url: `https://github.com/${input.repository}/pull/${input.number}`, + state: "open", + headBranch: "feature", + baseBranch: "main", + updatedAt: "2026-08-27T00:00:00.000Z", + ...overrides, + }; +} + +interface HarnessOptions { + readonly invalidate?: PullRequestService["Service"]["invalidate"]; + readonly snapshot: OrchestrationShellSnapshot; + readonly summary?: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly stack?: ( + input: PullRequestRef, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReads = yield* Queue.unbounded(); + const syncCommands = yield* Ref.make>([]); + const linkCommands = yield* Ref.make>([]); + const summaryCalls = yield* Ref.make>([]); + const stackCalls = yield* Ref.make>([]); + + const summary: PullRequestService["Service"]["summary"] = (input, readOptions) => + Effect.gen(function* () { + assert.strictEqual(readOptions?.recoverTransientFailure, false); + yield* Ref.update(summaryCalls, (calls) => [...calls, input]); + return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input)); + }); + + const stack: PullRequestService["Service"]["stack"] = (input, readOptions) => + Effect.gen(function* () { + assert.strictEqual(readOptions?.includeDetails, false); + yield* Ref.update(stackCalls, (calls) => [...calls, input]); + return yield* options.stack?.(input) ?? Effect.succeed(null); + }); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type === "thread.pull-request-link.sync") { + return Ref.update(syncCommands, (recorded) => [...recorded, command]).pipe( + Effect.as({ sequence: 1 }), + ); + } + if (command.type === "thread.pull-request.link") { + return Ref.update(linkCommands, (recorded) => [...recorded, command]).pipe( + Effect.as({ sequence: 1 }), + ); + } + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + }; + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))), + }), + Layer.mock(PullRequestService)({ + summary, + stack, + invalidate: options.invalidate ?? (() => Effect.void), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReads, + syncCommands, + linkCommands, + summaryCalls, + stackCalls, + layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +type Harness = Effect.Success>; + +const startAndSweep = Effect.fn("startPullRequestSyncHarness")(function* (fixture: Harness) { + const reactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + return reactor; +}); + +const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( + fixture: Harness, + reactor: PullRequestSyncReactor.PullRequestSyncReactor["Service"], +) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; +}); + +/** What the reactor would have persisted, so the next sweep sees its own writes. */ +function applySync( + snapshot: OrchestrationShellSnapshot, + commands: ReadonlyArray, +): OrchestrationShellSnapshot { + return { + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((thread) => ({ + ...thread, + pullRequests: thread.pullRequests.map((link) => { + const command = commands.findLast( + (candidate) => candidate.threadId === thread.id && candidate.number === link.number, + ); + return command === undefined + ? link + : { ...link, snapshot: command.snapshot, stack: command.stack }; + }), + })), + }; +} + +describe("PullRequestSyncReactor", () => { + it.effect("does not refresh links for historical null-project threads", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("retired", { projectId: null, pullRequests: [makeLink(42)] }), + ]), + }); + yield* startAndSweep(fixture).pipe(Effect.provide(fixture.layer)); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.stackCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []); + }), + ), + ); + + it.effect("retries a failed stack read after the summary becomes terminal", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + let attempts = 0; + const nativeStack: PullRequestStack = { + id: "stack", + number: 7, + url: "https://github.com/owner/repository/stacks/7", + base: "main", + layers: [{ number: 7, headBranch: "feature", state: "merged" }], + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7)] })]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), + stack: () => + ++attempts === 1 + ? Effect.fail( + new PullRequestOperationError({ + operation: "stack", + detail: "temporary failure", + }), + ) + : Effect.succeed(nativeStack), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const commands = yield* Ref.get(fixture.syncCommands); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); + yield* sweepAgain(fixture, reactor); + assert.strictEqual(attempts, 2); + assert.deepStrictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.stack, { + kind: "native", + ...nativeStack, + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("explicit refresh reads a changed stack even when its PR summary is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7, {})] })]), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 0); + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 7, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("snapshots an unsynced link once and writes it to the thread", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), + summary: (input) => + Effect.succeed(makeSummary(input, { title: "Ship it", isDraft: true })), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ + { + projectId: PROJECT_ID, + host: "github.com", + repository: "owner/repository", + number: 42, + }, + ]); + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map(({ commandId: _, ...rest }) => rest), + [ + { + type: "thread.pull-request-link.sync", + threadId: ThreadId.make("one"), + host: "github.com", + repository: "owner/repository", + number: 42, + snapshot: { + state: "open", + title: "Ship it", + headBranch: "feature", + baseBranch: "main", + isDraft: true, + updatedAt: "2026-08-27T00:00:00.000Z", + syncedAt: NOW, + closedAt: null, + mergedAt: null, + }, + stack: null, + }, + ], + ); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("asks the host once for a pull request shared by two threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { pullRequests: [makeLink(42)] }), + makeThread("two", { + pullRequests: [makeLink(42, null, { repository: "Owner/Repository" })], + }), + ]), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + const commands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual( + commands + .map((command) => [command.threadId, command.repository] as const) + .sort((left, right) => left[0].localeCompare(right[0])), + [ + [ThreadId.make("one"), "owner/repository"], + [ThreadId.make("two"), "Owner/Repository"], + ], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("dispatches nothing when the host snapshot is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const firstSweep = yield* Ref.get(fixture.syncCommands); + assert.strictEqual(firstSweep.length, 1); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep)); + + yield* sweepAgain(fixture, reactor); + + // Still open on an active thread, so the host was asked again, but nothing changed. + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("refreshes closed links through the reactor's project after reopening elsewhere", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const stale = yield* Ref.make(true); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("first", { pullRequests: [makeLink(42, { state: "closed" })] }), + makeThread("second", { + projectId: ProjectId.make("second-project"), + pullRequests: [makeLink(42, { state: "closed" })], + }), + ]), + invalidate: ({ reference }) => + reference?.projectId === makeProject().id && reference.host === "github.com" + ? Ref.set(stale, false) + : Effect.void, + summary: (input) => + Ref.get(stale).pipe( + Effect.map((cached) => makeSummary(input, { state: cached ? "closed" : "open" })), + ), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 42, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + const commands = yield* Ref.get(fixture.syncCommands); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); + const snapshot = yield* Ref.get(fixture.snapshots); + assert.deepStrictEqual( + snapshot.threads.map((thread) => thread.pullRequests[0]?.snapshot?.state), + ["open", "open"], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("stops asking the host once a pull request is merged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged", { pullRequests: [makeLink(1, { state: "merged" })] }), + ]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* sweepAgain(fixture, reactor); + + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []); + + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), + [1], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("discovers externally reopened pull requests after fifteen minutes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"closed" | "open">("closed"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }), + ]), + summary: (input) => + Ref.get(state).pipe(Effect.map((state) => makeSummary(input, { state }))), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + yield* Ref.set(state, "open"); + for (let index = 0; index < 14; index += 1) yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open"); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("preserves a refresh requested while an older host read is in flight", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const reading = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + summary: (input) => + Effect.gen(function* () { + calls += 1; + if (calls === 1) { + yield* Deferred.succeed(reading, undefined); + yield* Deferred.await(release); + } + return makeSummary(input, { state: calls === 1 ? "closed" : "open" }); + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set( + fixture.snapshots, + makeSnapshot([ + makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }), + ]), + ); + const key = { host: "github.com", repository: "owner/repository", number: 2 }; + yield* reactor.requestSync(key); + yield* Deferred.await(reading); + yield* reactor.requestSync(key); + yield* Deferred.succeed(release, undefined); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open"); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("polls open pull requests on settled threads every fifteen minutes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("settled", { + settledOverride: "settled", + settledAt: "2026-08-21T00:00:00.000Z", + pullRequests: [makeLink(5, { state: "open" })], + }), + ]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + for (let index = 0; index < 13; index += 1) yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("auto-links missing native stack layers and leaves dismissed ones alone", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const stack: PullRequestStack = { + id: "stack-1", + number: 42, + url: "https://github.com/owner/repository/stack/1", + base: "main", + layers: [ + { number: 41, headBranch: "layer-1", state: "merged" }, + { number: 42, headBranch: "layer-2", state: "open" }, + { number: 43, headBranch: "layer-3", state: "open" }, + ], + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { + pullRequests: [ + makeLink(42), + makeLink(41, { state: "merged" }, { source: "stack-dismissed" }), + ], + }), + ]), + stack: () => Effect.succeed(stack), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + const syncCommands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual( + syncCommands.map((command) => [command.number, command.stack] as const), + [[42, { kind: "native", ...stack }]], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.linkCommands)).map(({ commandId: _, ...rest }) => rest), + [ + { + type: "thread.pull-request.link", + threadId: ThreadId.make("one"), + host: "github.com", + repository: "owner/repository", + number: 43, + url: "https://github.com/owner/repository/pull/43", + source: "stack", + }, + ], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps existing snapshots and continues when the host fails", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("failing", { pullRequests: [makeLink(7, { state: "open" })] }), + makeThread("fine", { pullRequests: [makeLink(8)] }), + ]), + summary: (input) => + input.number === 7 + ? Effect.fail( + new PullRequestOperationError({ operation: "summary", detail: "host down" }), + ) + : Effect.succeed(makeSummary(input)), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), + [8], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts new file mode 100644 index 000000000..62b923d55 --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -0,0 +1,330 @@ +import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { + CommandId, + type OrchestrationThreadShell, + type PullRequestSummary, + type ThreadPullRequestKey, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, + type ThreadPullRequestStack, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; + +const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; + +type SnapshotFields = Omit; + +interface LinkEntry { + readonly thread: OrchestrationThreadShell & { + readonly projectId: NonNullable; + }; + readonly link: ThreadPullRequestLink; +} + +function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { + return { + state: summary.state, + title: summary.title, + headBranch: summary.headBranch, + baseBranch: summary.baseBranch, + isDraft: summary.isDraft ?? false, + updatedAt: summary.updatedAt, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + ...(summary.author === undefined ? {} : { author: summary.author }), + ...(summary.additions === undefined ? {} : { additions: summary.additions }), + ...(summary.deletions === undefined ? {} : { deletions: summary.deletions }), + ...(summary.changedFiles === undefined ? {} : { changedFiles: summary.changedFiles }), + ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }), + ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }), + ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }), + }; +} + +function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boolean { + return ( + left.state === right.state && + left.title === right.title && + left.headBranch === right.headBranch && + left.baseBranch === right.baseBranch && + left.isDraft === right.isDraft && + left.updatedAt === right.updatedAt && + (left.closedAt ?? null) === (right.closedAt ?? null) && + (left.mergedAt ?? null) === (right.mergedAt ?? null) && + (left.author?.login ?? null) === (right.author?.login ?? null) && + (left.author?.avatarUrl ?? null) === (right.author?.avatarUrl ?? null) && + left.additions === right.additions && + left.deletions === right.deletions && + left.changedFiles === right.changedFiles && + (left.reviewDecision ?? null) === (right.reviewDecision ?? null) && + (left.checksState ?? null) === (right.checksState ?? null) && + left.mergeability === right.mergeability + ); +} + +function stacksEqual( + left: ThreadPullRequestStack | null, + right: ThreadPullRequestStack | null, +): boolean { + if (left === null || right === null) return left === right; + return ( + left.kind === right.kind && + left.id === right.id && + left.number === right.number && + left.url === right.url && + left.base === right.base && + left.layers.length === right.layers.length && + left.layers.every((layer, index) => { + const other = right.layers[index]!; + return ( + layer.number === other.number && + layer.headBranch === other.headBranch && + layer.state === other.state + ); + }) + ); +} + +function isUnsettled(thread: OrchestrationThreadShell): boolean { + return thread.settledOverride !== "settled" && thread.settledAt === null; +} + +/** + * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads + * the shell snapshot, groups visible links by pull request so the host is asked once per PR + * no matter how many threads share it, and writes back only what changed. Native stacks the + * host reports are auto-linked to the thread as `source: "stack"`. + */ +export class PullRequestSyncReactor extends Context.Service< + PullRequestSyncReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + /** Force the next sweep to re-read this pull request, even when its snapshot is terminal. */ + readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect; + } +>()("t3/orchestration/PullRequestSyncReactor") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const lastSyncedAt = new Map(); + const requested = new Map(); + let requestGeneration = 0; + const retryStacks = new Set(); + + const isDue = (key: string, entries: ReadonlyArray, nowMs: number): boolean => { + if (requested.has(key) || retryStacks.has(key)) return true; + if (entries.some((entry) => entry.link.snapshot === null)) return true; + if (entries.every((entry) => entry.link.snapshot?.state === "merged")) return false; + if (entries.some((entry) => entry.link.snapshot?.state === "open" && isUnsettled(entry.thread))) + return true; + // Closed requests can reopen on the host, including after the thread settles. + const last = lastSyncedAt.get(key); + return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS; + }; + + const logSkipped = + (message: string, fields: Record) => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); + + const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const nowIso = DateTime.formatIso(now); + + const groups = new Map>(); + for (const thread of snapshot.threads) { + if (thread.archivedAt !== null || thread.projectId === null) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + const key = threadPullRequestKeyOf(link); + const entries = groups.get(key) ?? []; + entries.push({ thread: { ...thread, projectId: thread.projectId }, link }); + groups.set(key, entries); + } + } + + for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key); + for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key); + for (const key of requested.keys()) if (!groups.has(key)) requested.delete(key); + + // Layers auto-linked this sweep, so two links of one thread that share a + // stack do not both try to add the same sibling. + const linkedThisSweep = new Set(); + + const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + entry: LinkEntry, + fields: SnapshotFields, + fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, + ) { + const { thread, link } = entry; + const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack; + const changed = + link.snapshot === null || + !snapshotFieldsEqual(link.snapshot, fields) || + !stacksEqual(link.stack, nextStack); + if (changed) { + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), + threadId: thread.id, + host: link.host, + repository: link.repository, + number: link.number, + snapshot: { ...fields, syncedAt: nowIso }, + stack: nextStack, + }); + } + if (fetchedStack === null || fetchedStack.stack === null) return; + for (const layer of fetchedStack.stack.layers) { + const layerKey = { host: link.host, repository: link.repository, number: layer.number }; + const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`; + if (linkedThisSweep.has(dedupeKey)) continue; + // Tombstones count as present: a dismissed layer is never re-added. + if ( + thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, layerKey)) + ) { + continue; + } + const url = siblingPullRequestUrl(link.url, layer.number); + if (url === null) continue; + linkedThisSweep.add(dedupeKey); + const uuid = yield* crypto.randomUUIDv4; + yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`), + threadId: thread.id, + ...layerKey, + url, + source: "stack", + }) + .pipe( + Effect.catchCause( + logSkipped("pull request stack layer link skipped", { + threadId: thread.id, + number: layer.number, + }), + ), + ); + } + }); + + const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* ( + key: string, + entries: ReadonlyArray, + ) { + const first = entries[0]!; + const ref = { + projectId: first.thread.projectId, + host: first.link.host, + repository: first.link.repository, + number: first.link.number, + }; + const generation = requested.get(key); + if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref }); + const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); + const fields = snapshotFieldsOf(summary); + const needsStack = + generation !== undefined || + retryStacks.has(key) || + entries.some( + (entry) => + entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), + ); + const fetchedStack = needsStack + ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe( + Effect.map((stack) => ({ + stack: stack === null ? null : ({ kind: "native", ...stack } as const), + })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("pull request stack lookup failed", { + key, + }).pipe(Effect.as(null)), + ), + ) + : null; + if (needsStack) { + if (fetchedStack === null) retryStacks.add(key); + else retryStacks.delete(key); + } + // The host answered, so the cadence clock ticks even if a dispatch below is rejected. + lastSyncedAt.set(key, nowMs); + // A refresh requested while the host read was in flight belongs to the next sweep. + if (requested.get(key) === generation) requested.delete(key); + yield* Effect.forEach( + entries, + (entry) => + syncEntry(entry, fields, fetchedStack).pipe( + Effect.catchCause( + logSkipped("pull request sync skipped", { threadId: entry.thread.id, key }), + ), + ), + { discard: true }, + ); + }); + + yield* Effect.forEach( + groups, + ([key, entries]) => + isDue(key, entries, nowMs) + ? syncGroup(key, entries).pipe( + Effect.catchCause(logSkipped("pull request sync skipped", { key })), + ) + : Effect.void, + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), + ); + + const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( + "PullRequestSyncReactor.start", + )(function* () { + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + }); + + const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => + Effect.suspend(() => { + requested.set(threadPullRequestKeyOf(key), ++requestGeneration); + return worker.enqueue(undefined); + }); + + return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"]; +}); + +export const layer = Layer.effect(PullRequestSyncReactor, make); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89..29468dc3f 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,9 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema, + ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema, + ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; +export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; +export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index f9872e6dd..d6572083d 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -1,9 +1,11 @@ import { + CheckpointRef, EventId, GitManagerError, ProjectId, ProviderInstanceId, ThreadId, + TurnId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationProjectShell, @@ -87,6 +89,7 @@ function thread( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: "feature", worktreePath: null, latestTurn: null, @@ -310,54 +313,79 @@ describe("ThreadPullRequestReactor", () => { ), ); - it.effect("refreshes discovery after a turn ends without client demand", () => - Effect.scoped( - Effect.gen(function* () { - const current = thread("turn-thread"); - const detected = yield* Ref.make(null); - const fixture = yield* makeHarness({ - threads: [current], - branchPullRequest: (_input, options) => - options?.refresh - ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) - : Ref.get(detected), - }); - yield* Effect.gen(function* () { - const reactor = yield* fixture.start(); - expect(yield* Ref.get(fixture.commands)).toHaveLength(0); - yield* fixture.publish({ - type: "thread.session-set", - sequence: 2, - eventId: EventId.make("turn-finished"), - aggregateKind: "thread", - aggregateId: current.id, - occurredAt: NOW, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - payload: { - threadId: current.id, - session: { + it.effect.each(["session-first", "checkpoint-first"] as const)( + "refreshes discovery once after a turn ends with %s event ordering", + (order) => + Effect.scoped( + Effect.gen(function* () { + const current = thread("turn-thread"); + const detected = yield* Ref.make(null); + const fixture = yield* makeHarness({ + threads: [current], + branchPullRequest: (_input, options) => + options?.refresh + ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) + : Ref.get(detected), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + const sessionEvent: OrchestrationEvent = { + type: "thread.session-set", + sequence: 2, + eventId: EventId.make("turn-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: current.id, + session: { + threadId: current.id, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }, + }; + const checkpointEvent: OrchestrationEvent = { + ...sessionEvent, + type: "thread.turn-diff-completed", + sequence: 3, + eventId: EventId.make("checkpoint-finished"), + payload: { + threadId: current.id, + turnId: TurnId.make("turn"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint"), status: "ready", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, + files: [], + assistantMessageId: null, + completedAt: NOW, }, - }, - }); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); - expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( - 1, - ); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + }; + const events = + order === "session-first" + ? [sessionEvent, checkpointEvent] + : [checkpointEvent, sessionEvent]; + for (const event of events) { + yield* fixture.publish(event); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + expect( + (yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh), + ).toHaveLength(1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("uses live worktrees and falls back to the project for removed worktrees", () => @@ -454,54 +482,56 @@ describe("ThreadPullRequestReactor", () => { ), ); - it.effect("retries failed settled backfills and stops querying them after success", () => - Effect.scoped( - Effect.gen(function* () { - const online = yield* Ref.make(false); - const fixture = yield* makeHarness({ - threads: [ - thread("backfill", { settledOverride: "settled", settledAt: NOW }), - thread("known", { - branch: "known", - settledOverride: "settled", - settledAt: NOW, - branchPullRequest: reference(1), - }), - ], - branchPullRequest: ({ cwd }) => - Ref.get(online).pipe( - Effect.flatMap((connected) => - connected - ? Effect.succeed(branchPullRequest(42, "merged")) - : Effect.fail( - new GitManagerError({ - operation: "branchPullRequest", - cwd, - detail: "Offline", - }), - ), + it.effect.each(["settled", null] as const)( + "retries settled backfills and stops after success with settledOverride %s", + (settledOverride) => + Effect.scoped( + Effect.gen(function* () { + const online = yield* Ref.make(false); + const fixture = yield* makeHarness({ + threads: [ + thread("backfill", { settledOverride, settledAt: NOW }), + thread("known", { + branch: "known", + settledOverride, + settledAt: NOW, + branchPullRequest: reference(1), + }), + ], + branchPullRequest: ({ cwd }) => + Ref.get(online).pipe( + Effect.flatMap((connected) => + connected + ? Effect.succeed(branchPullRequest(42, "merged")) + : Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }), + ), + ), ), - ), - }); - yield* Effect.gen(function* () { - const reactor = yield* fixture.start(); - expect(yield* Ref.get(fixture.commands)).toHaveLength(0); - yield* Ref.set(online, true); - yield* TestClock.adjust("1 minute"); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); - yield* TestClock.adjust("1 minute"); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ - "feature", - "feature", - "feature", - ]); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + yield* Ref.set(online, true); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ + "feature", + "feature", + "feature", + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("stops retrying a settled backfill after repeated lookup failures", () => diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 6ff1f2e7e..23e3e2d2a 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -1,3 +1,7 @@ +import { + canonicalRepositoryKey, + sourceControlRepositorySelector, +} from "@t3tools/shared/sourceControl"; import { CommandId, type OrchestrationEvent, @@ -53,18 +57,6 @@ interface RefreshRequest { readonly backfill?: boolean; } -function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} - export function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: OrchestrationProjectShell, @@ -109,7 +101,10 @@ export const make = Effect.gen(function* () { const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { - if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { + if ( + (thread.settledOverride === "settled" || thread.settledAt !== null) && + thread.branchPullRequest == null + ) { pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); } } @@ -122,7 +117,7 @@ export const make = Effect.gen(function* () { thread.projectId !== null && thread.archivedAt === null && (request.threadId === null || thread.id === request.threadId) && - (thread.settledOverride !== "settled" || + ((thread.settledOverride !== "settled" && thread.settledAt === null) || request.threadId !== null || pendingBackfill.has(thread.id)) && (thread.branch !== null || thread.branchPullRequest != null) @@ -140,7 +135,7 @@ export const make = Effect.gen(function* () { const first = group[0]!; const project = projects.get(first.projectId); if (project === undefined) return finishBackfill(group); - const repository = PullRequestService.repositoryIdentityOf(project); + const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (first.branch !== null && repository === null) return finishBackfill(group); const worktreeExists = first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); @@ -191,6 +186,7 @@ export const make = Effect.gen(function* () { let replacement: ThreadLinkedPullRequest | undefined; if ( + thread.pullRequests.length === 0 && thread.linkedPullRequest != null && detected?.state === "open" && detectedReference !== null && @@ -336,7 +332,9 @@ export const make = Effect.gen(function* () { event.payload.session.status !== "running" && event.payload.session.status !== "starting" ) { - return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); + // Checkpoint completion forces the post-turn read. Session lifecycle + // events reuse it regardless of which event reaches this worker first. + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); } break; case "thread.turn-diff-completed": diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 61c512e6f..252b99439 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -5,6 +5,7 @@ import { ProjectId, TurnId, type OrchestrationThreadShell, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; @@ -18,6 +19,7 @@ const makeThread = ( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: "feature", worktreePath: "/repo", latestTurn: null, @@ -213,3 +215,75 @@ describe("resolveAutoSettlementAt", () => { ).toBe(true); }); }); + +function linkedRequest( + number: number, + snapshot: ThreadPullRequestLink["snapshot"], +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "org/repo", + number, + url: `https://github.com/org/repo/pull/${number}`, + source: "manual", + linkedAt: NOW, + stack: null, + snapshot, + }; +} + +const terminalSnapshot = ( + state: "closed" | "merged", + terminalAt: string, + updatedAt = terminalAt, +) => ({ + state, + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + closedAt: terminalAt, + mergedAt: state === "merged" ? terminalAt : null, + updatedAt, + syncedAt: NOW, +}); + +describe("linked request settlement", () => { + it.each(["closed", "merged"] as const)( + "uses the latest actual %s transition despite later comments on another PR", + (state) => { + const old = linkedRequest(1, terminalSnapshot(state, "2026-08-19T00:00:00.000Z", NOW)); + const recent = linkedRequest(2, terminalSnapshot(state, "2026-08-21T00:00:00.000Z")); + expect(decide(makeThread({ pullRequests: [old, recent] }), null, { days: null })).toBe(true); + expect(decide(makeThread({ pullRequests: [recent, old] }), null, { days: null })).toBe(true); + expect(decide(makeThread({ pullRequests: [old] }), null, { days: null })).toBe(false); + }, + ); + + it("keeps unknown and open links active even after the inactivity window", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + const unknown = linkedRequest(2, null); + const open = linkedRequest(3, { + ...terminalSnapshot("closed", NOW), + state: "open", + closedAt: null, + }); + expect(decide(makeThread({ pullRequests: [merged, unknown] }))).toBe(false); + expect(decide(makeThread({ pullRequests: [merged, open] }))).toBe(false); + expect( + decide(makeThread({ pullRequests: [merged, { ...unknown, source: "stack-dismissed" }] })), + ).toBe(true); + }); + + it("honors merge settings and ignores missing terminal timestamps", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null, merge: false })).toBe( + false, + ); + const missing = linkedRequest(2, { ...terminalSnapshot("merged", NOW), mergedAt: null }); + expect(decide(makeThread({ pullRequests: [missing] }), null, { days: null })).toBe(false); + expect(decide(makeThread({ pullRequests: [missing, merged] }), null, { days: null })).toBe( + true, + ); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 1df7855d1..92063745e 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -1,4 +1,5 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; @@ -72,7 +73,29 @@ export function resolveAutoSettlementAt(input: { readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; }): string | null { - const { thread, pullRequest } = input; + const { thread } = input; + let pullRequest = input.pullRequest; + const links = visibleThreadPullRequests(thread.pullRequests); + if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; + if (links.length > 0) { + const terminalTimestamp = (link: (typeof links)[number]) => { + const snapshot = link.snapshot; + const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; + const timestamp = Date.parse(value ?? ""); + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; + }; + const latest = links.reduce((current, candidate) => + terminalTimestamp(candidate) > terminalTimestamp(current) ? candidate : current, + ); + pullRequest = + latest.snapshot === null + ? null + : { + state: latest.snapshot.state, + mergedAt: latest.snapshot.mergedAt ?? null, + closedAt: latest.snapshot.closedAt ?? null, + }; + } if (!isAutoSettlementCandidate(thread, input.now)) return null; const activityAt = latestTimestamp([ thread.latestUserMessageAt, diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index a832b6985..cec8bfd15 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -80,6 +80,7 @@ function makeThread( }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, @@ -302,6 +303,57 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementReactor", () => { + it.effect( + "settles all-terminal links from snapshots and keeps open or unsynced links active", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const link = (number: number, state: "open" | "merged" | null) => ({ + host: "example.test", + repository: "owner/repository", + number, + url: `https://example.test/owner/repository/pull/${number}`, + source: "manual" as const, + linkedAt: NOW, + stack: null, + snapshot: + state === null + ? null + : { + state, + title: "Review", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: state === "merged" ? NOW : null, + }, + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged", { pullRequests: [link(1, "merged"), link(2, "merged")] }), + makeThread("open", { pullRequests: [link(1, "merged"), link(2, "open")] }), + makeThread("unsynced", { pullRequests: [link(1, "merged"), link(2, null)] }), + ]), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleOnMerge: true }, + branchPullRequest: () => Effect.die("linked threads must not query the branch"), + pullRequestSummary: () => Effect.die("linked threads must use their snapshots"), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), + [ThreadId.make("merged")], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 06deafcbb..9051b613a 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -110,7 +110,9 @@ export const make = Effect.gen(function* () { { concurrency: 8, }, - )).filter((thread) => thread !== null); + )) + .filter((thread) => thread !== null) + .filter((thread) => !thread.pullRequests.some((link) => link.source !== "stack-dismissed")); // Use the same cwd as PR discovery so both paths share GitManager's cache. const lookupCwdByThreadId = new Map(); diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index b71d049ca..5607381de 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -59,6 +59,7 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -84,6 +85,7 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/orchestration/decider.active-order.test.ts b/apps/server/src/orchestration/decider.active-order.test.ts index 58a7f5c05..39bccba11 100644 --- a/apps/server/src/orchestration/decider.active-order.test.ts +++ b/apps/server/src/orchestration/decider.active-order.test.ts @@ -33,6 +33,7 @@ function makeReadModel(overrides: Partial = {}): Orchestrat modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index 4ad00ba99..7d6f55d8f 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -36,6 +36,7 @@ function makeReadModel(input: { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.pullRequests.test.ts b/apps/server/src/orchestration/decider.pullRequests.test.ts new file mode 100644 index 000000000..69a3afdbb --- /dev/null +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -0,0 +1,558 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + OrchestrationEvent, + OrchestrationCommand, + type OrchestrationReadModel, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; +import { isThreadDetailEvent } from "../ws.ts"; + +const decodeCommand = Schema.decodeUnknownEffect(OrchestrationCommand); + +type PlannedEvent = Omit; + +function expectSingleEvent( + decided: PlannedEvent | ReadonlyArray, + type: Type, +): Omit, "sequence"> { + const event = Array.isArray(decided) ? decided[0] : (decided as PlannedEvent); + if (event === undefined || event.type !== type) { + throw new Error(`expected ${type}, got ${String(event?.type)}`); + } + return event as Omit, "sequence">; +} + +const NOW = "2026-01-01T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); + +function makeLink(overrides: Partial = {}): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: NOW, + snapshot: null, + stack: null, + ...overrides, + }; +} + +function makeReadModel(pullRequests: ReadonlyArray): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [ + { + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + repositoryIdentity: { + canonicalKey: "github.com/t3tools/t3code", + provider: "github", + displayName: "t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/t3tools/t3code.git", + }, + }, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const snapshot: ThreadPullRequestSnapshot = { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, +}; + +it.layer(NodeServices.layer)("pull request link decider", (it) => { + it.effect("does not attach new links to a historical null-project thread", () => + Effect.gen(function* () { + const model = makeReadModel([]); + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-link-retired"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "agent", + }, + readModel: { + ...model, + threads: model.threads.map((thread) => ({ ...thread, projectId: null })), + }, + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "OrchestrationCommandInvariantError", + detail: "Pull request links require a thread with an owning project.", + }); + }), + ); + + it.effect("legacy unlink cannot remove a newer cross-host link", () => + Effect.gen(function* () { + const own = makeLink(); + const foreign = makeLink({ + host: "github.enterprise.test", + url: "https://github.enterprise.test/t3tools/t3code/pull/42", + linkedAt: "2026-01-02T00:00:00Z", + }); + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "unlink", + threadId: THREAD_ID, + linkedPullRequest: null, + }); + const decided = yield* decideOrchestrationCommand({ + readModel: makeReadModel([own, foreign]), + command, + }); + const event = expectSingleEvent(decided, "thread.pull-request-unlinked"); + expect(event.payload.host).toBe("github.com"); + }), + ); + + it.effect("legacy replacement preserves unrelated manual links", () => + Effect.gen(function* () { + const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } }); + const current = makeLink({ linkedAt: "2026-01-02T00:00:00Z" }); + let model = makeReadModel([other, current]); + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "replace", + threadId: THREAD_ID, + linkedPullRequest: { + projectId: "project-1", + repository: "t3tools/t3code", + number: 99, + url: "https://github.com/t3tools/t3code/pull/99", + }, + }); + const decided = yield* decideOrchestrationCommand({ readModel: model, command }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual([ + "thread.pull-request-unlinked", + "thread.pull-request-linked", + ]); + for (const event of events) + model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); + expect(model.threads[0]!.pullRequests.map((link) => link.number)).toEqual([7, 99]); + }), + ); + it.effect("round-trips an Azure legacy link and unlinks only its organization", () => + Effect.gen(function* () { + const foreign = makeLink({ + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7", + }); + let model = makeReadModel([foreign]); + model = { + ...model, + projects: model.projects.map((project) => ({ + ...project, + repositoryIdentity: { + ...project.repositoryIdentity!, + provider: "azure-devops", + canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web", + displayName: "v3/org-a/project/web", + name: "web", + }, + })), + }; + const legacy = { + projectId: "project-1", + repository: "web", + number: 7, + url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", + }; + for (const linkedPullRequest of [legacy, null]) { + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: linkedPullRequest === null ? "unlink-azure" : "link-azure", + threadId: THREAD_ID, + linkedPullRequest, + }); + const decided = yield* decideOrchestrationCommand({ readModel: model, command }); + for (const event of Array.isArray(decided) ? decided : [decided]) { + model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); + } + if (linkedPullRequest !== null) { + expect(model.threads[0]!.linkedPullRequest).toEqual(legacy); + expect(model.threads[0]!.pullRequests.map((link) => link.repository)).toEqual([ + "org-b/project/_git/web", + "org-a/project/_git/web", + ]); + } + } + expect(model.threads[0]!.pullRequests).toEqual([foreign]); + expect(model.threads[0]!.linkedPullRequest).toBeNull(); + }), + ); + it.effect("legacy unlink alone does not emit an empty metadata event", () => + Effect.gen(function* () { + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "unlink", + threadId: THREAD_ID, + linkedPullRequest: null, + }); + const decided = yield* decideOrchestrationCommand({ + readModel: makeReadModel([makeLink()]), + command, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual(["thread.pull-request-unlinked"]); + }), + ); + + for (const source of ["manual", "agent", "created", "stack"] as const) { + it.effect(`legacy unlink removes the visible ${source} link and preserves other requests`, () => + Effect.gen(function* () { + const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } }); + const current = makeLink({ source, linkedAt: "2026-01-02T00:00:00.000Z" }); + let model = makeReadModel([other, current]); + // This is the pre-array command shape sent by older clients. + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "legacy-unlink", + threadId: THREAD_ID, + linkedPullRequest: null, + title: "Renamed by old client", + }); + const decided = yield* decideOrchestrationCommand({ readModel: model, command }); + const events = Array.isArray(decided) ? decided : [decided]; + for (const planned of events) { + const event = { ...planned, sequence: model.snapshotSequence + 1 }; + const encoded = yield* Schema.encodeEffect(OrchestrationEvent)(event); + const decoded = yield* Schema.decodeUnknownEffect(OrchestrationEvent)(encoded); + // Older detail-event unions must never receive the new PR discriminants. + expect(isThreadDetailEvent(decoded)).toBe(false); + model = yield* projectEvent(model, decoded); + } + const thread = model.threads[0]!; + expect(thread.title).toBe("Renamed by old client"); + expect(thread.pullRequests).toEqual( + source === "stack" ? [other, { ...current, source: "stack-dismissed" }] : [other], + ); + // The old single-link field continues to track the remaining visible request. + expect(thread.linkedPullRequest?.number).toBe(7); + }), + ); + } + + it.effect("links a pull request with a normalized key and empty host state", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-link"), + threadId: THREAD_ID, + host: " GitHub.com ", + repository: "T3Tools/T3Code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + }, + readModel: makeReadModel([]), + }); + expect(Array.isArray(decided)).toBe(false); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + expect(event.payload.link).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: event.payload.updatedAt, + snapshot: null, + stack: null, + }); + expect(event.payload.updatedAt).not.toBe(NOW); + }), + ); + + it.effect("rejects linking a pull request that is already linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-link-dup"), + threadId: THREAD_ID, + host: "GITHUB.COM", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "agent", + }, + readModel: makeReadModel([makeLink()]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("re-linking a dismissed stack member un-dismisses it", () => + Effect.gen(function* () { + const dismissed = makeLink({ + source: "stack-dismissed", + snapshot, + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [{ number: 42, headBranch: "feat/links", state: "open" }], + }, + }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-relink"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + }, + readModel: makeReadModel([dismissed]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + // Host state survives the flip; only the source changes. + expect(event.payload.link).toEqual({ ...dismissed, source: "manual" }); + }), + ); + + it.effect("rejects a stack sync re-adding a dismissed stack member", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-stack-readd"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "stack", + }, + readModel: makeReadModel([makeLink({ source: "stack-dismissed" })]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("unlinks a manual pull request", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink"), + threadId: THREAD_ID, + host: "GitHub.com", + repository: "t3tools/t3code", + number: 42, + }, + readModel: makeReadModel([makeLink()]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-unlinked"); + expect(event.payload).toMatchObject({ + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + }); + }), + ); + + it.effect("unlinking a stack member leaves a stack-dismissed tombstone", () => + Effect.gen(function* () { + const member = makeLink({ source: "stack", snapshot }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink-stack"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + }, + readModel: makeReadModel([member]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + expect(event.payload.link).toEqual({ ...member, source: "stack-dismissed" }); + }), + ); + + for (const source of ["manual", "agent", "created"] as const) { + it.effect(`unlinking a ${source} member prevents its sibling rediscovering it`, () => + Effect.gen(function* () { + const member = makeLink({ source }); + const sibling = makeLink({ + number: 43, + source: "stack", + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [ + { number: 42, headBranch: "first", state: "open" }, + { number: 43, headBranch: "second", state: "open" }, + ], + }, + }); + let model = makeReadModel([member, sibling]); + const decided = yield* decideOrchestrationCommand({ + readModel: model, + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("remove"), + threadId: THREAD_ID, + host: member.host, + repository: member.repository, + number: member.number, + }, + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + model = yield* projectEvent(model, { ...event, sequence: 1 }); + expect(model.threads[0]!.pullRequests).toEqual([ + { ...member, source: "stack-dismissed" }, + sibling, + ]); + const rediscovered = yield* decideOrchestrationCommand({ + readModel: model, + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("rediscovered"), + threadId: THREAD_ID, + host: member.host, + repository: member.repository, + number: member.number, + url: member.url, + source: "stack", + }, + }).pipe(Effect.result); + expect(rediscovered._tag).toBe("Failure"); + }), + ); + } + + it.effect("rejects unlinking a pull request that is not linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink-missing"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 7, + }, + readModel: makeReadModel([makeLink()]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects syncing a pull request that is not linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request-link.sync", + commandId: CommandId.make("cmd-sync-missing"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }, + readModel: makeReadModel([]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("sync emits the host snapshot for a linked pull request", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request-link.sync", + commandId: CommandId.make("cmd-sync"), + threadId: THREAD_ID, + host: "GitHub.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }, + readModel: makeReadModel([makeLink()]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-synced"); + expect(event.payload).toMatchObject({ + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.questionAttachments.test.ts b/apps/server/src/orchestration/decider.questionAttachments.test.ts index 6c3cdc62e..53e87d759 100644 --- a/apps/server/src/orchestration/decider.questionAttachments.test.ts +++ b/apps/server/src/orchestration/decider.questionAttachments.test.ts @@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index abc5cff37..bcb2408c2 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -47,6 +47,7 @@ function makeReadModel( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 1012240b1..505bb79df 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -41,6 +41,7 @@ function makeReadModel(input: { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index b29c8ffda..c032f33d0 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: UPDATED_AT, updatedAt: UPDATED_AT, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ca70c8c7c..90e4e9f72 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -10,8 +10,16 @@ import { type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, + type ThreadPullRequestKey, + type ThreadPullRequestLink, type OrchestrationThreadActivity, } from "@t3tools/contracts"; +import { + legacyLinkedPullRequestOf, + legacyThreadPullRequestKey, + normalizeThreadPullRequestKey, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -125,6 +133,13 @@ function hasQueuedTurnStartForThread( ); } +function findPullRequestLink( + thread: Pick, + key: ThreadPullRequestKey, +): ThreadPullRequestLink | undefined { + return thread.pullRequests.find((link) => threadPullRequestKeysEqual(link, key)); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -883,6 +898,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + // Old clients only see the derived single link. Unlink that request through + // the same command path as modern clients, including stack dismissal, while + // retaining other links they cannot see. Historical metadata events still replay unchanged. + const legacy = legacyLinkedPullRequestOf( + thread.pullRequests, + thread.projectId, + readModel.projects.find((project) => project.id === thread.projectId)?.repositoryIdentity, + ); + const currentPullRequest = + legacy === null + ? null + : (thread.pullRequests.find( + (link) => link.url === legacy.url && link.number === legacy.number, + ) ?? null); + if (command.linkedPullRequest != null) { + const { linkedPullRequest: linked, ...metadata } = command; + const project = readModel.projects.find((project) => project.id === thread.projectId); + let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"; + try { + host = new URL(linked.url).hostname; + } catch { + // Historical clients can send links without a parseable URL. + } + const hasMetadata = Object.entries(metadata).some( + ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, + ); + return yield* decideCommandSequence({ + readModel, + commands: [ + ...(hasMetadata ? [metadata] : []), + ...(currentPullRequest?.source === "manual" + ? [ + { + type: "thread.pull-request.unlink" as const, + commandId: command.commandId, + threadId: command.threadId, + host: currentPullRequest.host, + repository: currentPullRequest.repository, + number: currentPullRequest.number, + }, + ] + : []), + { + type: "thread.pull-request.link", + commandId: command.commandId, + threadId: command.threadId, + ...legacyThreadPullRequestKey(linked, host), + url: linked.url, + source: "manual", + }, + ], + }); + } + + if (command.linkedPullRequest === null && currentPullRequest !== null) { + const { linkedPullRequest: _linkedPullRequest, ...metadata } = command; + const hasMetadata = Object.entries(metadata).some( + ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, + ); + return yield* decideCommandSequence({ + readModel, + commands: [ + ...(hasMetadata ? [metadata] : []), + { + type: "thread.pull-request.unlink", + commandId: command.commandId, + threadId: command.threadId, + host: currentPullRequest.host, + repository: currentPullRequest.repository, + number: currentPullRequest.number, + }, + ], + }); + } const occurredAt = yield* nowIso; const branch = command.branch !== undefined && @@ -927,6 +1016,144 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pull-request.link": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.projectId === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Pull request links require a thread with an owning project.", + }); + } + const key = normalizeThreadPullRequestKey(command); + const existing = findPullRequestLink(thread, key); + // An explicit link on a dismissed stack member un-dismisses it; any + // other duplicate is a no-op the engine would reject as zero-event. + const undismisses = + existing?.source === "stack-dismissed" && + (command.source === "manual" || command.source === "agent" || command.source === "created"); + if (existing !== undefined && !undismisses) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pull-request-linked", + payload: { + threadId: command.threadId, + link: + existing !== undefined + ? { ...existing, url: command.url, source: command.source } + : { + ...key, + url: command.url, + source: command.source, + linkedAt: occurredAt, + snapshot: null, + stack: null, + }, + updatedAt: occurredAt, + }, + }; + } + + case "thread.pull-request.unlink": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizeThreadPullRequestKey(command); + const existing = findPullRequestLink(thread, key); + if (existing === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + const eventBase = yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }); + // Any known native-stack member needs a tombstone, regardless of who linked it. + // A sibling can rediscover it even before this link has its own stack snapshot. + const belongsToStack = + existing.source === "stack" || + existing.stack !== null || + thread.pullRequests.some( + (link) => + link.host.toLowerCase() === key.host && + link.repository.toLowerCase() === key.repository && + link.stack?.layers.some((layer) => layer.number === key.number), + ); + if (belongsToStack) { + return { + ...eventBase, + type: "thread.pull-request-linked", + payload: { + threadId: command.threadId, + link: { ...existing, source: "stack-dismissed" }, + updatedAt: occurredAt, + }, + }; + } + return { + ...eventBase, + type: "thread.pull-request-unlinked", + payload: { + threadId: command.threadId, + ...key, + updatedAt: occurredAt, + }, + }; + } + + case "thread.pull-request-link.sync": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizeThreadPullRequestKey(command); + if (findPullRequestLink(thread, key) === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pull-request-synced", + payload: { + threadId: command.threadId, + ...key, + snapshot: command.snapshot, + stack: command.stack, + updatedAt: occurredAt, + }, + }; + } + case "thread.pull-request.sync": { const thread = yield* requireThreadNotArchived({ readModel, diff --git a/apps/server/src/orchestration/decider.userInputDismiss.test.ts b/apps/server/src/orchestration/decider.userInputDismiss.test.ts index 5c7b495bc..a61da0ca7 100644 --- a/apps/server/src/orchestration/decider.userInputDismiss.test.ts +++ b/apps/server/src/orchestration/decider.userInputDismiss.test.ts @@ -49,6 +49,7 @@ function makeReadModel( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/orchestration/projector.pullRequests.test.ts b/apps/server/src/orchestration/projector.pullRequests.test.ts new file mode 100644 index 000000000..6876cdde4 --- /dev/null +++ b/apps/server/src/orchestration/projector.pullRequests.test.ts @@ -0,0 +1,417 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, + type OrchestrationReadModel, + type RepositoryIdentity, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const LATER = "2026-01-02T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); +const PROJECT_ID = ProjectId.make("project-1"); + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: NOW, + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +function makeLink(overrides: Partial = {}): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: NOW, + snapshot: null, + stack: null, + ...overrides, + }; +} + +const snapshot: ThreadPullRequestSnapshot = { + state: "merged", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: LATER, + syncedAt: LATER, +}; + +const createThread = (model: OrchestrationReadModel) => + projectEvent( + model, + makeEvent({ + sequence: model.snapshotSequence + 1, + type: "thread.created", + payload: { + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: NOW, + updatedAt: NOW, + }, + }), + ); + +const createProject = (model: OrchestrationReadModel, repositoryIdentity: RepositoryIdentity) => + projectEvent(model, { + ...makeEvent({ + sequence: model.snapshotSequence + 1, + type: "project.created", + payload: { + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + }, + }), + aggregateKind: "project", + aggregateId: PROJECT_ID, + }).pipe( + Effect.map((next) => ({ + ...next, + projects: next.projects.map((project) => + project.id === PROJECT_ID ? { ...project, repositoryIdentity } : project, + ), + })), + ); + +it.effect("seeds threads with no pull requests", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + expect(created.threads[0]?.pullRequests).toEqual([]); + expect(created.threads[0]?.linkedPullRequest ?? null).toBeNull(); + }), +); + +it.effect("projects link, sync, and unlink onto the thread", () => + Effect.gen(function* () { + const created = yield* createThread( + yield* createProject(createEmptyReadModel(NOW), { + canonicalKey: "github.com/t3tools/t3code", + provider: "github", + displayName: "t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/t3tools/t3code.git", + }, + }), + ); + const link = makeLink(); + + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link, updatedAt: LATER }, + }), + ); + expect(linked.threads[0]?.pullRequests).toEqual([link]); + expect(linked.threads[0]?.updatedAt).toBe(LATER); + // The legacy field is derived from the array so old clients keep working. + expect(linked.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }); + + // A second link for the same key replaces in place (used for un-dismiss + // and stack tombstones), never duplicates. + const relinked = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.pull-request-linked", + payload: { + threadId: THREAD_ID, + link: { ...link, host: "GITHUB.COM", source: "agent" }, + updatedAt: LATER, + }, + }), + ); + expect(relinked.threads[0]?.pullRequests).toHaveLength(1); + expect(relinked.threads[0]?.pullRequests[0]?.source).toBe("agent"); + + const synced = yield* projectEvent( + relinked, + makeEvent({ + sequence: 4, + type: "thread.pull-request-synced", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: LATER, + }, + }), + ); + expect(synced.threads[0]?.pullRequests[0]?.snapshot).toEqual(snapshot); + + const unlinked = yield* projectEvent( + synced, + makeEvent({ + sequence: 5, + type: "thread.pull-request-unlinked", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + updatedAt: LATER, + }, + }), + ); + expect(unlinked.threads[0]?.pullRequests).toEqual([]); + expect(unlinked.threads[0]?.linkedPullRequest).toBeNull(); + }), +); + +it.effect("ignores a sync for a pull request that is no longer linked", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const other = makeLink({ number: 7, url: "https://github.com/t3tools/t3code/pull/7" }); + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link: other, updatedAt: NOW }, + }), + ); + const synced = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.pull-request-synced", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: LATER, + }, + }), + ); + expect(synced.threads[0]?.pullRequests).toEqual([other]); + expect(synced.threads[0]?.updatedAt).toBe(NOW); + }), +); + +it.effect("mirrors legacy meta-updated links into pullRequests using the project host", () => + Effect.gen(function* () { + const withProject = yield* createProject(createEmptyReadModel(NOW), { + canonicalKey: "GitHub.com/t3tools/t3code", + provider: "github", + displayName: "t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + }); + const created = yield* createThread(withProject); + const agentLink = makeLink({ + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + source: "agent", + }); + const withAgentLink = yield* projectEvent( + created, + makeEvent({ + sequence: 3, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link: agentLink, updatedAt: NOW }, + }), + ); + + const legacyLinked = yield* projectEvent( + withAgentLink, + makeEvent({ + sequence: 4, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "T3Tools/T3Code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }, + updatedAt: LATER, + }, + }), + ); + expect(legacyLinked.threads[0]?.pullRequests).toEqual([ + agentLink, + { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: LATER, + snapshot: null, + stack: null, + }, + ]); + // Two open links read as a stack; the derived field points at the top. + expect(legacyLinked.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }); + + // Null clears only the manual link; the agent's stays. + const legacyCleared = yield* projectEvent( + legacyLinked, + makeEvent({ + sequence: 5, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, linkedPullRequest: null, updatedAt: LATER }, + }), + ); + expect(legacyCleared.threads[0]?.pullRequests).toEqual([agentLink]); + expect(legacyCleared.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + }); + }), +); + +it.effect("falls back to the link URL host when the project has no repository identity", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const legacyLinked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://GitLab.example.com/t3tools/t3code/-/merge_requests/42", + }, + updatedAt: LATER, + }, + }), + ); + expect(legacyLinked.threads[0]?.pullRequests[0]?.host).toBe("gitlab.example.com"); + }), +); + +it.effect("leaves pullRequests alone when meta-updated carries no legacy link", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const link = makeLink(); + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link, updatedAt: NOW }, + }), + ); + const retitled = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, title: "Renamed", updatedAt: LATER }, + }), + ); + expect(retitled.threads[0]?.title).toBe("Renamed"); + expect(retitled.threads[0]?.pullRequests).toEqual([link]); + }), +); + +it.effect("replays Azure legacy selectors as full repository keys", () => + Effect.gen(function* () { + const withProject = yield* createProject(createEmptyReadModel(NOW), { + canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web", + provider: "azure-devops", + displayName: "v3/org-a/project/web", + name: "web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@ssh.dev.azure.com:v3/org-a/project/web", + }, + }); + const created = yield* createThread(withProject); + const legacy = { + projectId: PROJECT_ID, + repository: "web", + number: 7, + url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", + }; + const model = yield* projectEvent( + created, + makeEvent({ + sequence: 3, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, linkedPullRequest: legacy, updatedAt: LATER }, + }), + ); + expect(model.threads[0]?.pullRequests).toEqual([ + { + host: "dev.azure.com", + repository: "org-a/project/_git/web", + number: 7, + url: legacy.url, + source: "manual", + linkedAt: LATER, + snapshot: null, + stack: null, + }, + ]); + expect(model.threads[0]?.linkedPullRequest).toEqual(legacy); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 7557318b8..b8ca631d8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -87,6 +87,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], branchPullRequest: null, latestTurn: null, createdAt: now, @@ -118,7 +119,31 @@ describe("orchestration projector", () => { commandId: null, }; let model = yield* projectEvent( - createEmptyReadModel(now), + { + ...createEmptyReadModel(now), + projects: [ + { + id: ProjectId.make("project-1"), + title: "T3 Code", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + repositoryIdentity: { + canonicalKey: "github.com/pingdotgg/t3code", + provider: "github", + displayName: "pingdotgg/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/pingdotgg/t3code.git", + }, + }, + }, + ], + }, makeEvent({ ...eventFields, sequence: 1, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 7a47b473f..f32b539c5 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,4 +1,12 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { + OrchestrationEvent, + OrchestrationProject, + OrchestrationReadModel, + ThreadId, + ThreadLinkedPullRequest, + ThreadPullRequestKey, + ThreadPullRequestLink, +} from "@t3tools/contracts"; import { isImportedAgentSessionMessageId, OrchestrationCheckpointSummary, @@ -7,6 +15,11 @@ import { OrchestrationThread, ThreadForkedPayload, } from "@t3tools/contracts"; +import { + legacyLinkedPullRequestOf, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -29,6 +42,9 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadPullRequestLinkedPayload, + ThreadPullRequestSyncedPayload, + ThreadPullRequestUnlinkedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -102,6 +118,77 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } +/** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */ +function pullRequestsPatch( + thread: Pick, + pullRequests: ReadonlyArray, + projects: OrchestrationReadModel["projects"], +): Pick { + return { + pullRequests, + linkedPullRequest: legacyLinkedPullRequestOf( + pullRequests, + thread.projectId, + projects.find((project) => project.id === thread.projectId)?.repositoryIdentity, + ), + }; +} + +function upsertPullRequestLink( + pullRequests: ReadonlyArray, + link: ThreadPullRequestLink, +): ReadonlyArray { + const index = pullRequests.findIndex((entry) => threadPullRequestKeysEqual(entry, link)); + return index === -1 + ? [...pullRequests, link] + : pullRequests.map((entry, entryIndex) => (entryIndex === index ? link : entry)); +} + +function removePullRequestLink( + pullRequests: ReadonlyArray, + key: ThreadPullRequestKey, +): ReadonlyArray { + return pullRequests.filter((entry) => !threadPullRequestKeysEqual(entry, key)); +} + +/** + * Host for a legacy `linkedPullRequest` being replayed into the link array. + * Legacy links never carried one; the project's canonical key + * (`//`) is the best witness, then the link URL. + */ +function legacyPullRequestHost( + project: OrchestrationProject | undefined, + linked: ThreadLinkedPullRequest, +): string { + const canonicalHost = project?.repositoryIdentity?.canonicalKey.split("/")[0]; + if (canonicalHost) return canonicalHost.toLowerCase(); + try { + return new URL(linked.url).hostname.toLowerCase(); + } catch { + return "unknown"; + } +} + +function legacyLinkToPullRequests( + thread: Pick, + project: OrchestrationProject | undefined, + linked: ThreadLinkedPullRequest | null, + linkedAt: string, +): ReadonlyArray { + // The legacy field held one user-chosen link, so null clears exactly the + // manual ones and leaves created/agent/stack links alone. + const withoutManual = thread.pullRequests.filter((entry) => entry.source !== "manual"); + if (linked === null) return withoutManual; + return upsertPullRequestLink(withoutManual, { + ...legacyThreadPullRequestKey(linked, legacyPullRequestHost(project, linked)), + url: linked.url, + source: "manual", + linkedAt, + snapshot: null, + stack: null, + }); +} + function decodeForEvent( schema: Schema.Decoder, value: unknown, @@ -338,6 +425,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + pullRequests: [], branchPullRequest: null, latestTurn: null, createdAt: payload.createdAt, @@ -500,37 +588,134 @@ export function projectEvent( case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.activeOrderKey !== undefined - ? { activeOrderKey: payload.activeOrderKey } - : {}), - ...(payload.titleRegeneration !== undefined - ? { titleRegeneration: payload.titleRegeneration } - : {}), - ...(payload.modelSelection !== undefined - ? { modelSelection: payload.modelSelection } - : {}), - ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), - // SCIENT-FORK:START — replay immutable historical project - // reassignment fields without changing the thread identity. - ...(payload.projectId !== undefined ? { projectId: payload.projectId } : {}), - ...(payload.workspaceRoot !== undefined - ? { workspaceRoot: payload.workspaceRoot } - : {}), - // SCIENT-FORK:END - ...(payload.linkedPullRequest !== undefined - ? { linkedPullRequest: payload.linkedPullRequest } - : {}), - ...(payload.branchPullRequest !== undefined - ? { branchPullRequest: payload.branchPullRequest } - : {}), - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + // Legacy single-link events replay into the link array so the + // derived linkedPullRequest and pullRequests never disagree. + const legacyLinkPatch = + thread !== undefined && payload.linkedPullRequest !== undefined + ? pullRequestsPatch( + thread, + legacyLinkToPullRequests( + thread, + nextBase.projects.find((project) => project.id === thread.projectId), + payload.linkedPullRequest, + payload.updatedAt, + ), + nextBase.projects, + ) + : {}; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.titleRegeneration !== undefined + ? { titleRegeneration: payload.titleRegeneration } + : {}), + ...(payload.modelSelection !== undefined + ? { modelSelection: payload.modelSelection } + : {}), + ...(payload.branch !== undefined ? { branch: payload.branch } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.activeOrderKey !== undefined + ? { activeOrderKey: payload.activeOrderKey } + : {}), + ...(payload.branchPullRequest !== undefined + ? { branchPullRequest: payload.branchPullRequest } + : {}), + // Decode-only compatibility for immutable historical project moves. + ...(payload.projectId !== undefined ? { projectId: payload.projectId } : {}), + ...(payload.workspaceRoot !== undefined + ? { workspaceRoot: payload.workspaceRoot } + : {}), + ...legacyLinkPatch, + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-linked": + return decodeForEvent( + ThreadPullRequestLinkedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch( + thread, + upsertPullRequestLink(thread.pullRequests, payload.link), + nextBase.projects, + ), + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-unlinked": + return decodeForEvent( + ThreadPullRequestUnlinkedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch( + thread, + removePullRequestLink(thread.pullRequests, payload), + nextBase.projects, + ), + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-synced": + return decodeForEvent( + ThreadPullRequestSyncedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + // A sync for a link the user removed in the meantime is stale; drop it. + if ( + !thread || + !thread.pullRequests.some((link) => threadPullRequestKeysEqual(link, payload)) + ) { + return nextBase; + } + const pullRequests = thread.pullRequests.map((link) => + threadPullRequestKeysEqual(link, payload) + ? { ...link, snapshot: payload.snapshot, stack: payload.stack } + : link, + ); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch(thread, pullRequests, nextBase.projects), + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.runtime-mode-set": diff --git a/apps/server/src/orchestration/scient-fork/ForkContextBootstrap.test.ts b/apps/server/src/orchestration/scient-fork/ForkContextBootstrap.test.ts index 67ac75913..39474c23b 100644 --- a/apps/server/src/orchestration/scient-fork/ForkContextBootstrap.test.ts +++ b/apps/server/src/orchestration/scient-fork/ForkContextBootstrap.test.ts @@ -75,6 +75,7 @@ function thread(messages: ReadonlyArray): OrchestrationThr deletedAt: null, messages: [...messages], proposedPlans: [], + pullRequests: [], activities: [], checkpoints: [], session: { diff --git a/apps/server/src/orchestration/scient-fork/forkDecider.test.ts b/apps/server/src/orchestration/scient-fork/forkDecider.test.ts index cb791e4ee..dc80fb860 100644 --- a/apps/server/src/orchestration/scient-fork/forkDecider.test.ts +++ b/apps/server/src/orchestration/scient-fork/forkDecider.test.ts @@ -167,6 +167,7 @@ function makeOriginThread(overrides: Partial = {}): Orchest }), ], proposedPlans: [], + pullRequests: [], activities: [], checkpoints: [checkpoint("turn-1", 1), checkpoint("turn-2", 2)], conversationForkBoundaries: boundaries, diff --git a/apps/server/src/orchestration/scient-fork/forkDecisionReadModel.test.ts b/apps/server/src/orchestration/scient-fork/forkDecisionReadModel.test.ts index b76e6825a..237a9cd1a 100644 --- a/apps/server/src/orchestration/scient-fork/forkDecisionReadModel.test.ts +++ b/apps/server/src/orchestration/scient-fork/forkDecisionReadModel.test.ts @@ -34,6 +34,7 @@ function thread(id: string, messages: OrchestrationThread["messages"]): Orchestr deletedAt: null, messages, proposedPlans: [], + pullRequests: [], activities: [], checkpoints: [], session: null, diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4d5b1597e..997bba77f 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -15,15 +15,21 @@ import * as Statement from "effect/unstable/sql/Statement"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; -import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; +import * as ProjectionThreadPullRequests from "../ProjectionThreadPullRequests.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import { + ProjectionThreadPullRequestRepository, + type ProjectionThreadPullRequest, +} from "../ProjectionThreadPullRequests.ts"; +import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts"; const projectionRepositoriesLayer = it.layer( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadPullRequests.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), @@ -532,4 +538,164 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest); }), ); + + it.effect("uses one Azure identity for repository writes, lookups, and deletion", () => + Effect.gen(function* () { + const pullRequests = yield* ProjectionThreadPullRequestRepository; + const threadId = ThreadId.make("azure-alias-link"); + const row: ProjectionThreadPullRequest = { + threadId, + host: "org.visualstudio.com", + repository: "project/_git/web", + number: 7, + url: "https://org.visualstudio.com/project/_git/web/pullrequest/7", + source: "manual", + linkedAt: "2026-09-09T00:00:00.000Z", + snapshot: null, + stack: null, + }; + yield* pullRequests.upsert(row); + yield* pullRequests.upsert({ + ...row, + host: "dev.azure.com", + repository: "org/project/_git/web", + }); + const found = yield* pullRequests.listByPullRequest({ + host: "ssh.dev.azure.com", + repository: "v3/org/project/web", + number: 7, + }); + assert.deepStrictEqual(found, [ + { ...row, host: "dev.azure.com", repository: "org/project/_git/web" }, + ]); + assert.deepStrictEqual( + yield* pullRequests.listByPullRequest({ + host: "dev.azure.com", + repository: "other/project/_git/web", + number: 7, + }), + [], + ); + yield* pullRequests.delete({ + threadId, + host: "vs-ssh.visualstudio.com", + repository: "v3/org/project/web", + number: 7, + }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []); + }), + ); + + it.effect("round-trips pull request links with JSON snapshot and stack columns", () => + Effect.gen(function* () { + const pullRequests = yield* ProjectionThreadPullRequestRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-pr-links"); + const otherThreadId = ThreadId.make("thread-pr-links-other"); + + const unsynced: ProjectionThreadPullRequest = { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-03-24T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const synced: ProjectionThreadPullRequest = { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "stack", + linkedAt: "2026-03-23T00:00:00.000Z", + snapshot: { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-03-23T01:00:00.000Z", + syncedAt: "2026-03-23T02:00:00.000Z", + }, + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/pingdotgg/t3code/stack/1", + base: "main", + layers: [ + { number: 7, headBranch: "feat/links", state: "open" }, + { number: 42, headBranch: "feat/links-ui", state: "open" }, + ], + }, + }; + const sharedOnOtherThread: ProjectionThreadPullRequest = { + ...unsynced, + threadId: otherThreadId, + source: "agent", + linkedAt: "2026-03-25T00:00:00.000Z", + }; + + yield* pullRequests.upsert(unsynced); + yield* pullRequests.upsert(synced); + yield* pullRequests.upsert(sharedOnOtherThread); + + const rawRows = yield* sql<{ + readonly number: number; + readonly snapshotJson: string | null; + readonly stackJson: string | null; + }>` + SELECT number, snapshot_json AS "snapshotJson", stack_json AS "stackJson" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY number ASC + `; + assert.strictEqual(rawRows[0]?.number, 7); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(rawRows[0]?.snapshotJson ?? "null"), synced.snapshot); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(rawRows[0]?.stackJson ?? "null"), synced.stack); + assert.strictEqual(rawRows[1]?.snapshotJson, null); + assert.strictEqual(rawRows[1]?.stackJson, null); + + // Ordered by linked_at, then number. + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, unsynced]); + + // One pull request across threads, ordered by linked_at. + assert.deepStrictEqual( + yield* pullRequests.listByPullRequest({ + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + }), + [unsynced, sharedOnOtherThread], + ); + + // Upsert on the composite key replaces snapshot and stack in place. + const resynced = { ...unsynced, snapshot: synced.snapshot, stack: null } as const; + yield* pullRequests.upsert(resynced); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, resynced]); + + yield* pullRequests.delete({ + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [resynced]); + + yield* pullRequests.deleteByThreadIdAndSource({ threadId, source: "manual" }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), [ + sharedOnOtherThread, + ]); + + yield* pullRequests.deleteByThreadId({ threadId: otherThreadId }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), []); + }), + ); }); diff --git a/apps/server/src/persistence/Migrations.compatibility.test.ts b/apps/server/src/persistence/Migrations.compatibility.test.ts index dd21d1fd6..637d75e22 100644 --- a/apps/server/src/persistence/Migrations.compatibility.test.ts +++ b/apps/server/src/persistence/Migrations.compatibility.test.ts @@ -1,24 +1,75 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import { runMigrations } from "./Migrations.ts"; +import { migrationManifest, runMigrations } from "./Migrations.ts"; -it.layer(NodeSqliteClient.layerMemory())("unreleased migration compatibility", (it) => { - it.effect("can reopen a development database with the retired error-reason column", () => +for (const previousId of [49, 50, 52] as const) { + it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))( + `migration compatibility after ${previousId}`, + (it) => { + it.effect("runs every pending step without rewriting recorded history or session data", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: previousId === 50 ? 49 : previousId }); + if (previousId === 50) { + yield* sql`ALTER TABLE projection_thread_sessions ADD COLUMN last_error_reason TEXT`; + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (50, 'ProjectionSessionErrorReason')`; + } + const historyBefore = + yield* sql`SELECT * FROM effect_sql_migrations ORDER BY migration_id`; + yield* sql`INSERT INTO projection_thread_sessions + (thread_id, status, provider_name, runtime_mode, last_error, updated_at) + VALUES ('legacy', 'error', 'pi', 'full-access', 'Old error', '2026-09-06T00:00:00.000Z')`; + + const executed = yield* runMigrations(); + assert.deepStrictEqual( + executed, + previousId === 52 + ? [[53, "ProjectionThreadPullRequests"]] + : [ + [51, "ProjectionThreadBranchPullRequest"], + [52, "ProjectionThreadsActiveOrderKey"], + [53, "ProjectionThreadPullRequests"], + ], + ); + assert.deepStrictEqual( + yield* sql`SELECT * FROM effect_sql_migrations + WHERE migration_id <= ${previousId} ORDER BY migration_id`, + historyBefore, + ); + const rows = yield* sql`SELECT last_error, updated_at + FROM projection_thread_sessions WHERE thread_id = 'legacy'`; + assert.deepStrictEqual(rows, [ + { last_error: "Old error", updated_at: "2026-09-06T00:00:00.000Z" }, + ]); + const columns = yield* sql<{ name: string }>`PRAGMA table_info(projection_threads)`; + assert.ok(columns.some((column) => column.name === "branch_pull_request_json")); + assert.ok(columns.some((column) => column.name === "active_order_key")); + assert.deepStrictEqual(yield* sql`SELECT * FROM projection_thread_pull_requests`, []); + assert.isEmpty(yield* runMigrations()); + }), + ); + }, + ); +} + +it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("fresh migration compatibility", (it) => { + it.effect("installs the complete manifest without reusing retired migration 50", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations(); - yield* sql`ALTER TABLE projection_thread_sessions ADD COLUMN last_error_reason TEXT`; - yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) - VALUES (50, 'ProjectionSessionErrorReason')`; - yield* sql`INSERT INTO projection_thread_sessions - (thread_id, status, provider_name, runtime_mode, last_error, updated_at) - VALUES ('legacy', 'error', 'pi', 'full-access', 'Old error', '2026-09-06T00:00:00.000Z')`; + assert.deepStrictEqual(yield* runMigrations(), migrationManifest); + const rows = yield* sql`SELECT migration_id, name FROM effect_sql_migrations + WHERE migration_id >= 50 ORDER BY migration_id`; + assert.deepStrictEqual(rows, [ + { migration_id: 51, name: "ProjectionThreadBranchPullRequest" }, + { migration_id: 52, name: "ProjectionThreadsActiveOrderKey" }, + { migration_id: 53, name: "ProjectionThreadPullRequests" }, + ]); + assert.deepStrictEqual(yield* sql`SELECT * FROM projection_thread_pull_requests`, []); assert.isEmpty(yield* runMigrations()); - const rows = yield* sql<{ last_error: string }>`SELECT last_error - FROM projection_thread_sessions WHERE thread_id = 'legacy'`; - assert.deepEqual(rows, [{ last_error: "Old error" }]); }), ); }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 932dce0ef..a745be71e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -63,6 +63,7 @@ import Migration0048 from "./Migrations/048_RepairAutomaticSettlementTimestamps. import Migration0049 from "./Migrations/049_ProjectionProjectIcon.ts"; import Migration0051 from "./Migrations/051_ProjectionThreadBranchPullRequest.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadsActiveOrderKey.ts"; +import Migration0053 from "./Migrations/053_ProjectionThreadPullRequests.ts"; /** * Migration loader with all migrations defined inline. @@ -124,8 +125,12 @@ const migrationEntries = [ [47, "ProjectionProjectsAutoPull", Migration0047], [48, "RepairAutomaticSettlementTimestamps", Migration0048], [49, "ProjectionProjectIcon", Migration0049], + // 50 was used by the retired development-only ProjectionSessionErrorReason. + // Never reuse recorded IDs: the runner advances past the highest recorded ID. [51, "ProjectionThreadBranchPullRequest", Migration0051], [52, "ProjectionThreadsActiveOrderKey", Migration0052], + // T3's migration 50 follows Scient's immutable history at the next free ID. + [53, "ProjectionThreadPullRequests", Migration0053], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/053_ProjectionThreadPullRequests.test.ts b/apps/server/src/persistence/Migrations/053_ProjectionThreadPullRequests.test.ts new file mode 100644 index 000000000..68f787713 --- /dev/null +++ b/apps/server/src/persistence/Migrations/053_ProjectionThreadPullRequests.test.ts @@ -0,0 +1,184 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +interface PullRequestRow { + readonly threadId: string; + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; + readonly source: string; + readonly linkedAt: string; + readonly snapshotJson: string | null; + readonly stackJson: string | null; +} + +layer("053_ProjectionThreadPullRequests", (it) => { + it.effect("creates the link table and backfills legacy single links", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 52 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '[]', + '2026-03-01T00:00:00.000Z', + '2026-03-01T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + linked_pull_request_json, + created_at, + updated_at + ) + VALUES + ( + 'thread-github', + 'project-1', + 'GitHub link', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"projectId":"project-1","repository":"PingDotGG/T3Code","number":42,"url":"https://GitHub.com/pingdotgg/t3code/pull/42"}', + '2026-03-01T00:00:01.000Z', + '2026-03-02T00:00:00.000Z' + ), + ( + 'thread-bad-url', + 'project-1', + 'Unparseable URL', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"projectId":"project-1","repository":"acme/widgets","number":7,"url":"not a url"}', + '2026-03-01T00:00:02.000Z', + '2026-03-03T00:00:00.000Z' + ), + ( + 'thread-malformed', + 'project-1', + 'Malformed JSON', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"repository":"acme/widgets"}', + '2026-03-01T00:00:03.000Z', + '2026-03-04T00:00:00.000Z' + ), + ( + 'thread-unlinked', + 'project-1', + 'No link', + '{"instanceId":"codex","model":"gpt-5.4"}', + NULL, + '2026-03-01T00:00:04.000Z', + '2026-03-05T00:00:00.000Z' + ) + `; + + assert.deepStrictEqual(yield* runMigrations({ toMigrationInclusive: 53 }), [ + [53, "ProjectionThreadPullRequests"], + ]); + + const rows = yield* sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshotJson", + stack_json AS "stackJson" + FROM projection_thread_pull_requests + ORDER BY thread_id ASC + `; + + assert.deepStrictEqual(rows, [ + { + threadId: "thread-bad-url", + host: "unknown", + repository: "acme/widgets", + number: 7, + url: "not a url", + source: "manual", + linkedAt: "2026-03-03T00:00:00.000Z", + snapshotJson: null, + stackJson: null, + }, + { + threadId: "thread-github", + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://GitHub.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-03-02T00:00:00.000Z", + snapshotJson: null, + stackJson: null, + }, + ]); + + // The legacy column stays so a rollback keeps its data. + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_thread_pull_requests) + `; + assert.ok(indexes.some((index) => index.name === "idx_projection_thread_pull_requests_pr")); + assert.isEmpty(yield* runMigrations()); + }), + ); +}); + +it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("053 Azure legacy links", (it) => { + it.effect("keeps legacy Azure repositories distinct across organizations", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 52 }); + for (const organization of ["org-a", "org-b"]) { + yield* sql` + INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at) + VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}', + ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })}, + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z') + `; + } + yield* runMigrations({ toMigrationInclusive: 53 }); + const rows = + yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`; + assert.deepStrictEqual(rows, [ + { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 }, + { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/053_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/053_ProjectionThreadPullRequests.ts new file mode 100644 index 000000000..2a53307c8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/053_ProjectionThreadPullRequests.ts @@ -0,0 +1,92 @@ +import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +interface LegacyLinkedThreadRow { + readonly threadId: string; + readonly updatedAt: string; + readonly linkedPullRequestJson: string; +} + +interface LegacyLinkedPullRequest { + readonly repository: string; + readonly number: number; + readonly url: string; +} + +function parseLegacyLinkedPullRequest(json: string): LegacyLinkedPullRequest | null { + try { + const value: unknown = JSON.parse(json); + if (typeof value !== "object" || value === null) return null; + const { repository, number, url } = value as Record; + if (typeof repository !== "string" || repository.trim().length === 0) return null; + if (typeof number !== "number" || !Number.isInteger(number) || number < 1) return null; + if (typeof url !== "string" || url.trim().length === 0) return null; + return { repository, number, url }; + } catch { + return null; + } +} + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_pull_requests ( + thread_id TEXT NOT NULL, + host TEXT NOT NULL, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + url TEXT NOT NULL, + source TEXT NOT NULL, + linked_at TEXT NOT NULL, + snapshot_json TEXT, + stack_json TEXT, + PRIMARY KEY (thread_id, host, repository, number) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_pull_requests_pr + ON projection_thread_pull_requests(host, repository, number) + `; + + const legacyRows = yield* sql` + SELECT + thread_id AS "threadId", + updated_at AS "updatedAt", + linked_pull_request_json AS "linkedPullRequestJson" + FROM projection_threads + WHERE linked_pull_request_json IS NOT NULL + `; + + for (const row of legacyRows) { + const linked = parseLegacyLinkedPullRequest(row.linkedPullRequestJson); + if (linked === null) continue; + const key = legacyThreadPullRequestKey(linked); + yield* sql` + INSERT OR IGNORE INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES ( + ${row.threadId}, + ${key.host}, + ${key.repository}, + ${linked.number}, + ${linked.url}, + 'manual', + ${row.updatedAt}, + NULL, + NULL + ) + `; + } +}); diff --git a/apps/server/src/persistence/ProjectionThreadPullRequests.ts b/apps/server/src/persistence/ProjectionThreadPullRequests.ts new file mode 100644 index 000000000..9f41993bd --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreadPullRequests.ts @@ -0,0 +1,266 @@ +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { + IsoDateTime, + PositiveInt, + ThreadId, + ThreadPullRequestKey, + ThreadPullRequestLinkSource, + ThreadPullRequestSnapshot, + ThreadPullRequestStack, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +import { toPersistenceSqlError, type ProjectionRepositoryError } from "./Errors.ts"; + +import * as Layer from "effect/Layer"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +export const ProjectionThreadPullRequest = Schema.Struct({ + threadId: ThreadId, + host: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, + source: ThreadPullRequestLinkSource, + linkedAt: IsoDateTime, + snapshot: Schema.NullOr(ThreadPullRequestSnapshot), + stack: Schema.NullOr(ThreadPullRequestStack), +}); +export type ProjectionThreadPullRequest = typeof ProjectionThreadPullRequest.Type; + +export const ListProjectionThreadPullRequestsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadPullRequestsInput = + typeof ListProjectionThreadPullRequestsInput.Type; + +export const ListProjectionThreadPullRequestsByPullRequestInput = ThreadPullRequestKey; +export type ListProjectionThreadPullRequestsByPullRequestInput = + typeof ListProjectionThreadPullRequestsByPullRequestInput.Type; + +export const DeleteProjectionThreadPullRequestInput = Schema.Struct({ + threadId: ThreadId, + ...ThreadPullRequestKey.fields, +}); +export type DeleteProjectionThreadPullRequestInput = + typeof DeleteProjectionThreadPullRequestInput.Type; + +export const DeleteProjectionThreadPullRequestsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadPullRequestsInput = + typeof DeleteProjectionThreadPullRequestsInput.Type; + +export const DeleteProjectionThreadPullRequestsBySourceInput = Schema.Struct({ + threadId: ThreadId, + source: ThreadPullRequestLinkSource, +}); +export type DeleteProjectionThreadPullRequestsBySourceInput = + typeof DeleteProjectionThreadPullRequestsBySourceInput.Type; + +const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields( + Struct.assign({ + snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), + stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), + }), +); + +export class ProjectionThreadPullRequestRepository extends Context.Service< + ProjectionThreadPullRequestRepository, + { + readonly upsert: ( + row: ProjectionThreadPullRequest, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadPullRequestsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByPullRequest: ( + input: ListProjectionThreadPullRequestsByPullRequestInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly delete: ( + input: DeleteProjectionThreadPullRequestInput, + ) => Effect.Effect; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadPullRequestsInput, + ) => Effect.Effect; + readonly deleteByThreadIdAndSource: ( + input: DeleteProjectionThreadPullRequestsBySourceInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionThreadPullRequests/ProjectionThreadPullRequestRepository") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadPullRequestRow = SqlSchema.void({ + Request: ProjectionThreadPullRequest, + execute: (row) => sql` + INSERT INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES ( + ${row.threadId}, + ${row.host}, + ${row.repository}, + ${row.number}, + ${row.url}, + ${row.source}, + ${row.linkedAt}, + ${row.snapshot === null ? null : JSON.stringify(row.snapshot)}, + ${row.stack === null ? null : JSON.stringify(row.stack)} + ) + ON CONFLICT (thread_id, host, repository, number) + DO UPDATE SET + url = excluded.url, + source = excluded.source, + linked_at = excluded.linked_at, + snapshot_json = excluded.snapshot_json, + stack_json = excluded.stack_json + `, + }); + + const listProjectionThreadPullRequestRows = SqlSchema.findAll({ + Request: ListProjectionThreadPullRequestsInput, + Result: ProjectionThreadPullRequestDbRow, + execute: ({ threadId }) => sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY linked_at ASC, number ASC + `, + }); + + const listProjectionThreadPullRequestRowsByPullRequest = SqlSchema.findAll({ + Request: ListProjectionThreadPullRequestsByPullRequestInput, + Result: ProjectionThreadPullRequestDbRow, + execute: ({ host, repository, number }) => sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE host = ${host} + AND repository = ${repository} + AND number = ${number} + ORDER BY linked_at ASC, thread_id ASC + `, + }); + + const deleteProjectionThreadPullRequestRow = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestInput, + execute: ({ threadId, host, repository, number }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + AND host = ${host} + AND repository = ${repository} + AND number = ${number} + `, + }); + + const deleteProjectionThreadPullRequestRows = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestsInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + `, + }); + + const deleteProjectionThreadPullRequestRowsBySource = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestsBySourceInput, + execute: ({ threadId, source }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + AND source = ${source} + `, + }); + + const upsert: ProjectionThreadPullRequestRepository["Service"]["upsert"] = (row) => + upsertProjectionThreadPullRequestRow({ ...row, ...normalizeThreadPullRequestKey(row) }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadPullRequestRepository["Service"]["listByThreadId"] = ( + input, + ) => + listProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"), + ), + ); + + const listByPullRequest: ProjectionThreadPullRequestRepository["Service"]["listByPullRequest"] = ( + input, + ) => + listProjectionThreadPullRequestRowsByPullRequest(normalizeThreadPullRequestKey(input)).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByPullRequest:query"), + ), + ); + + const deleteLink: ProjectionThreadPullRequestRepository["Service"]["delete"] = (input) => + deleteProjectionThreadPullRequestRow({ + ...input, + ...normalizeThreadPullRequestKey(input), + }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query")), + ); + + const deleteByThreadId: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.deleteByThreadId:query"), + ), + ); + + const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadIdAndSource"] = + (input) => + deleteProjectionThreadPullRequestRowsBySource(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadPullRequestRepository.deleteByThreadIdAndSource:query", + ), + ), + ); + + return { + upsert, + listByThreadId, + listByPullRequest, + delete: deleteLink, + deleteByThreadId, + deleteByThreadIdAndSource, + } satisfies ProjectionThreadPullRequestRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionThreadPullRequestRepository, make); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index d09ab41df..cbcf42432 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -125,6 +125,7 @@ const makeProjectedThread = (input: { modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index c5a7d07d2..ac2464f54 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -5128,12 +5128,14 @@ describe("agent browser access", () => { it.effect("keeps project capabilities available when browser access is off", () => Effect.gen(function* () { - const issued = yield* startSessionWith(false, asThreadId("thread-browser-off")); + const threadId = asThreadId("thread-browser-off"); + const issued = yield* startSessionWith(false, threadId); assert.deepEqual(issued, [ { threadId: asThreadId("thread-browser-off"), capabilities: new Set([ + "pull-requests", "documents:build", "sources:read", "sources:write", @@ -5156,6 +5158,7 @@ describe("agent browser access", () => { threadId, capabilities: new Set([ "preview", + "pull-requests", "documents:build", "sources:read", "sources:write", @@ -5183,7 +5186,12 @@ describe("agent browser access", () => { assert.deepEqual(issued, [ { threadId, - capabilities: new Set(["documents:build", "sources:read", "sources:write"]), + capabilities: new Set([ + "pull-requests", + "documents:build", + "sources:read", + "sources:write", + ]), }, ]); }).pipe(Effect.provide(NodeServices.layer)), @@ -5229,6 +5237,7 @@ describe("agent browser access", () => { { threadId, capabilities: new Set([ + "pull-requests", "documents:build", "sources:read", "sources:write", @@ -5259,7 +5268,13 @@ describe("agent browser access", () => { assert.equal(issued.length, 1); assert.deepEqual( issued[0]?.capabilities, - new Set(["documents:build", "sources:read", "sources:write", "skills:read"]), + new Set([ + "pull-requests", + "documents:build", + "sources:read", + "sources:write", + "skills:read", + ]), ); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -5279,7 +5294,14 @@ describe("agent browser access", () => { assert.equal(issued.length, 1); assert.deepEqual( issued[0]?.capabilities, - new Set(["preview", "documents:build", "sources:read", "sources:write", "skills:read"]), + new Set([ + "preview", + "pull-requests", + "documents:build", + "sources:read", + "sources:write", + "skills:read", + ]), ); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 3f5dabeb3..78edf4431 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -950,6 +950,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const supportsScientSkills = ScientSkillSession.scientSkillDeliveryForProvider(provider) === "mcp"; const capabilities = new Set([ + "pull-requests", "documents:build", "sources:read", "sources:write", diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3ab2b4618..2538e05e3 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -98,6 +98,7 @@ function makeReadModel( runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 1066432cc..389d8350f 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -12,6 +12,13 @@ describe("buildRuntimeInstructions", () => { expect(instructions).not.toContain("undefined"); }, ); + it("requires explicit registration of every PR and stack layer", () => { + const instructions = buildRuntimeInstructions({ harness: "Codex" }); + expect(instructions).toContain("When the t3-code MCP server exposes link_pull_request"); + expect(instructions).toContain("with the full PR URL immediately after creating a PR"); + expect(instructions).toContain("For a stack, call it for every layer"); + expect(instructions).toContain("call list_thread_pull_requests and link any PR"); + }); it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 5bbd8d838..13ca7bf2d 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -1,3 +1,7 @@ +const PULL_REQUEST_LINKING_INSTRUCTIONS = ` +When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. +`; + /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ export function buildRuntimeInstructions(runtime: { readonly harness: string; @@ -9,7 +13,7 @@ export function buildRuntimeInstructions(runtime: { const effort = toSingleLine(runtime.reasoningEffort ?? ""); const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in Scient through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.`; + return `In case you're asked: you are running in Scient through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; } function toSingleLine(value: string): string { diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index f61b7c323..6ef485627 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,6 +1,7 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const mockedExecute = vi.fn(); +const mockedStackMemberships = vi.fn(() => + Effect.succeed(output('{"data":{}}')), +); const mockedGetPullRequest = vi.fn(); const layer = it.layer( GitHubPullRequestCli.layer.pipe( Layer.provide( Layer.mock(GitHubCli.GitHubCli)({ - execute: mockedExecute, + execute: (input) => + input.args.some((arg) => arg.includes("query PullRequestStackMemberships")) + ? mockedStackMemberships(input) + : mockedExecute(input), getPullRequest: mockedGetPullRequest, }), ), @@ -179,24 +188,42 @@ function searchQueryOfCall(index: number): string | undefined { afterEach(() => { mockedExecute.mockReset(); + mockedStackMemberships.mockReset(); mockedGetPullRequest.mockReset(); }); layer("GitHubPullRequestCli.layer", (it) => { - it.effect("reads linked pull request status through one narrow request", () => + it.effect("reads linked pull request status with the overview fields in one request", () => Effect.gen(function* () { - mockedGetPullRequest.mockReturnValueOnce( - Effect.succeed({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - baseRefName: "main", - headRefName: "feat/summary", - state: "merged", - closedAt: "2026-08-23T10:00:00Z", - mergedAt: "2026-08-23T10:00:00Z", - updatedAt: "2026-08-24T12:34:56.000Z", - }), + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, + ], + body: "", + }), + ), + ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -207,23 +234,252 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, }); - assert.deepStrictEqual(summary, { + assert.deepStrictEqual( + { + number: summary.number, + state: summary.state, + headBranch: summary.headBranch, + isDraft: summary.isDraft, + author: summary.author?.login, + additions: summary.additions, + deletions: summary.deletions, + changedFiles: summary.changedFiles, + reviewDecision: summary.reviewDecision, + checksState: summary.checksState, + mergeability: summary.mergeability, + }, + { + number: 7, + state: "open", + headBranch: "feat/summary", + isDraft: false, + author: "octocat", + additions: 12, + deletions: 3, + changedFiles: 2, + reviewDecision: "approved", + checksState: "passing", + mergeability: "mergeable", + }, + ); + expect(mockedExecute).toHaveBeenCalledOnce(); + expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + "pr", + "view", + "7", + "--repo", + "github.com/acme/web", + "--json", + expect.stringContaining("statusCheckRollup"), + ]); + expect(mockedGetPullRequest).not.toHaveBeenCalled(); + }), + ); + + it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: 42, + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main" }, + pull_requests: [ + { + number: 6, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02", + }, + { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, + ], + }, + ]), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "ghe.example.com", + number: 7, + }); + + assert.deepStrictEqual(stack, { + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" }, + { number: 7, headBranch: "feat/two", state: "open" }, + ], + }); + assert.deepStrictEqual(callAt(0).args, [ + "api", + "--hostname", + "ghe.example.com", + "repos/acme/web/stacks?pull_request=7", + ]); + }), + ); + + it.effect("fetches layer titles only when the caller asks for stack details", () => + Effect.gen(function* () { + const minimal = { + url: "https://api.github.com/repos/acme/web/stacks/3", + number: 3, + base: { ref: "main" }, + pull_requests: [ + { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, + ], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal])))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + ...minimal, + pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + includeDetails: true, + }); + expect(stack?.layers[0]).toMatchObject({ + title: "Second layer", + headSha: "abc123", + isDraft: false, + }); + expect(callAt(1).args).toEqual([ + "api", + "--hostname", + "github.com", + "repos/acme/web/stacks/3", + ]); + }), + ); + + it.effect("reads an empty stacks listing as not stacked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.isNull(stack); + }), + ); + + it.effect("reads a host that refuses the stacks preview as not stacked", () => + Effect.gen(function* () { + // The CLI classifies a missing preview endpoint as not found. + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubCli.GitHubPullRequestNotFoundError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), + }), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - headBranch: "feat/summary", - baseBranch: "main", - state: "merged", - closedAt: "2026-08-23T10:00:00Z", - mergedAt: "2026-08-23T10:00:00Z", - updatedAt: "2026-08-24T12:34:56.000Z", }); - expect(mockedGetPullRequest).toHaveBeenCalledOnce(); - expect(mockedGetPullRequest).toHaveBeenCalledWith({ + + assert.isNull(stack); + }), + ); + + it.effect("does not read a signed-out gh as an unstacked pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/w", + cause: new Error("gh auth login"), + }), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubCliAuthenticationError"); + }), + ); + + it.effect("preserves transient stack failures instead of reporting no stack", () => + Effect.gen(function* () { + const failure = new GitHubCli.GitHubCliCommandError({ + command: "gh", cwd: "/w", - reference: "https://github.com/acme/web/pull/7", + cause: new Error("HTTP 503"), }); - expect(mockedExecute).not.toHaveBeenCalled(); + mockedExecute.mockReturnValueOnce(Effect.fail(failure)); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + assert.strictEqual(error, failure); + }), + ); + + it.effect("reports a stacks answer it cannot read against the stack read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + if (error._tag !== "GitHubPullRequestReadError") return; + assert.strictEqual(error.operation, "getPullRequestStack"); }), ); @@ -514,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("enriches only the visible fallback rows after filtering and widening", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), + ); + mockedStackMemberships.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: { pullRequest: { stack: null, stackEntry: null } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + filters: { draft: "hide" }, + }); + expect(batch.items.map((item) => item.number)).toEqual([4, 5]); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(false); + expect(mockedStackMemberships).toHaveBeenCalledTimes(1); + const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? ""; + expect(query).toContain("pullRequest(number: 4)"); + expect(query).toContain("pullRequest(number: 5)"); + expect(query).not.toContain("pullRequest(number: 1)"); + expect(query).not.toContain("pullRequest(number: 6)"); + }), + ); + + it.effect("batches membership reads and keeps successful rows when one chunk fails", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); + mockedStackMemberships.mockImplementation((input) => + input.args.at(-1)?.includes("pullRequest(number: 26)") + ? Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 502"), + }), + ) + : Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 26, + }); + expect(batch.items.map((item) => item.number)).toEqual( + Array.from({ length: 26 }, (_, i) => i + 1), + ); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[25]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(true); + expect(mockedStackMemberships).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("skips membership enrichment for empty pages and enterprise hosts", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { + cwd: "/w", + repository: "acme/web", + state: "open" as const, + involvement: "all" as const, + viewer: "bilal", + limit: 2, + }; + const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); + const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); + expect(empty.items).toEqual([]); + expect(enterprise.items.map((item) => item.number)).toEqual([7]); + expect(mockedStackMemberships).not.toHaveBeenCalled(); + }), + ); + it.effect("reads the line counts in chunks, and files them back by position", () => Effect.gen(function* () { const changeRequests = Array.from({ length: 26 }, (_, index) => ({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 6152df75f..5e1950a6e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,4 @@ +import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +7,7 @@ import * as Schema from "effect/Schema"; import { resolvePullRequestAuthorFilter, type PullRequestAction, + type PullRequestStackHead, type PullRequestActor, type PullRequestInvolvement, type PullRequestListFilters, @@ -39,6 +41,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodePullRequestStatsJson, decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, @@ -50,6 +53,8 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, pullRequestSearchGraphQlQuery, PULL_REQUEST_SEARCH_MAX_ROWS, @@ -84,6 +89,7 @@ import { type GitHubPullRequestHead, type GitHubPullRequestListItem, type GitHubPullRequestSearchItem, + type GitHubPullRequestStack, type GitHubReviewThreadComments, type GitHubRepositoryAccess, type GitHubWorkflowRunApproval, @@ -91,7 +97,7 @@ import { type GitHubReviewThreadPage, type GitHubViewerAccess, } from "./gitHubPullRequestJson.ts"; -import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; /** * Names the read that produced unusable output, so a failure reports the call it came from @@ -332,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError Effect.Effect< - { - readonly number: number; - readonly title: string; - readonly url: string; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: string; - }, - GitHubPullRequestCliError - >; + }) => Effect.Effect; readonly getPullRequestDetail: (input: { readonly cwd: string; @@ -494,6 +487,18 @@ export class GitHubPullRequestCli extends Context.Service< readonly isCrossRepository: true; }) => Effect.Effect, GitHubPullRequestCliError>; + /** + * The host-native stack this pull request is in, or null when it is in none — which is also + * the answer for a host that refuses the stacks preview altogether. + */ + readonly getPullRequestStack: (input: { + readonly cwd: string; + readonly includeDetails?: boolean; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + /** * How far the branch trails its base, and whether this viewer may update it. Its own read * because the comparison needs the head ref the detail answers with — a fork's branch is not @@ -628,6 +633,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; @@ -807,8 +814,8 @@ function matchesFilters( viewer: string, ): boolean { if (filters === undefined) return true; - const labels = item.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && (filters.review === undefined || @@ -1558,6 +1565,47 @@ export const make = Effect.gen(function* () { ? read(false) : Effect.succeed(batch), ), + Effect.flatMap((batch) => { + // Match the search query's host support, and enrich only rows that survived paging. + if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); + const chunks: Array> = []; + for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const query = buildPullRequestStackMembershipsGraphQlQuery( + input.repository, + chunk.map((item) => item.number), + ); + if (query === null) return Effect.succeed(chunk); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStackMemberships", + query, + decode: decodePullRequestStackMembershipsJson, + }).pipe( + Effect.map((memberships) => + chunk.map((item, index) => { + const stack = memberships.get(index); + return stack === undefined ? item : { ...item, stack }; + }), + ), + // Optional badges must not take down a listing that already read successfully. + Effect.catch(() => + Effect.logWarning("Pull request stack membership enrichment failed", { + operation: "listPullRequestStackMemberships", + host: input.host, + rows: chunk.length, + }).pipe(Effect.as(chunk)), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); + }), ); }, @@ -1582,7 +1630,7 @@ export const make = Effect.gen(function* () { operation: "searchPullRequests", // The reader's own words are in the query, so it travels over stdin rather than in argv. privateVariables: { q: query }, - query: pullRequestSearchGraphQlQuery(rows), + query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), decode: decodePullRequestSearchJson, }).pipe( Effect.map((batch) => ({ @@ -1630,41 +1678,122 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. getPullRequestSummary: (input) => github - .getPullRequest({ + .execute({ cwd: input.cwd, - reference: `https://${input.host}/${input.repository}/pull/${input.number}`, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], }) .pipe( - Effect.flatMap((summary) => - summary.updatedAt === undefined - ? Effect.fail( - new GitHubPullRequestUpdatedAtUnavailableError({ - command: "gh", - cwd: input.cwd, - repository: input.repository, - number: input.number, - }), - ) - : Effect.succeed({ - number: summary.number, - title: summary.title, - url: summary.url, - headBranch: summary.headRefName, - baseBranch: summary.baseRefName, - state: summary.state ?? "open", - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: summary.updatedAt, + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, }), - ), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), ), getPullRequestDetail, listWorkflowRunsRequiringApproval, + getPullRequestStack: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks/${stack.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + ); + }), + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. + Effect.catchTags({ + GitHubPullRequestNotFoundError: () => Effect.succeed(null), + }), + ); + }, + getPullRequestBaseComparison: (input) => { const { owner, name } = parseRepositorySelector(input.repository); return graphqlRead({ @@ -2057,6 +2186,10 @@ export const make = Effect.gen(function* () { }, runPullRequestAction: (input) => { + if (input.stackNumber !== undefined) + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( + Effect.provideService(GitHubCli.GitHubCli, github), + ); if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( Effect.flatMap((pullRequestId) => diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 8fd0f09dd..fe5032d70 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => baseBranch: "main", state: "open" as const, updatedAt: "2026-08-24T12:34:56.000Z", + author: { login: "octocat", name: null, avatarUrl: null }, }; }), }), @@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () => expect(summary.state).toBe("open"); expect(summaryReads).toBe(1); + // The author's avatar comes from the login-shaped URL, not a second request. + expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); + }), +); + +it.effect("declares host-native stacks and passes the one the CLI reads through", () => + Effect.gen(function* () { + const stack = { + id: "42", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" as const }, + { number: 7, headBranch: "feat/two", state: "open" as const }, + ], + }; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), + }), + ), + ); + + expect(provider.capabilities.stacks).toBe(true); + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const ref = { cwd: "/w", repository: "acme/web", host: "github.com" }; + expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack); + expect(yield* readStack({ ...ref, number: 8 })).toBeNull(); + }), +); + +it.effect("reports a failed stack read against its own operation", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestStack: () => + Effect.fail( + new GitHubPullRequestCli.GitHubPullRequestReadError({ + command: "gh", + cwd: "/w", + operation: "getPullRequestStack", + cause: new Error("unreadable"), + }), + ), + }), + ), + ); + + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const error = yield* Effect.flip( + readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), + ); + + expect(error.operation).toBe("getChangeRequestStack"); + expect(error.reason).toBe("failed"); }), ); @@ -69,6 +130,7 @@ describe("gitHubViewerPermissions", () => { ], comment: true, resolve: true, + stackRebase: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, labels: true, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index e75ef3547..66a247efb 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -47,6 +47,8 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + stacks: true, + stackActions: true, labels: true, }; @@ -69,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = { */ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { return { + ...(access.canWrite ? { stackRebase: true } : {}), actions: [ // Arming a merge and taking the arming back are the merge, deferred: whoever may not // merge here may not leave an instruction to merge later either. @@ -292,7 +295,20 @@ export const make = Effect.gen(function* () { .pipe(Effect.mapError(fail("listChangeRequestStats"))), getChangeRequestSummary: (input) => - cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), + cli.getPullRequestSummary(input).pipe( + // `gh pr view` names the author without an avatar; the login-shaped URL every user + // has stands in, without the second request the listing spends on it. + Effect.map((summary) => ({ + ...summary, + ...(summary.author === undefined + ? {} + : { author: withAvatar(summary.author, new Map(), input.host) }), + })), + Effect.mapError(fail("getChangeRequestSummary")), + ), + + getChangeRequestStack: (input) => + cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), getChangeRequest: (input) => Effect.all( @@ -544,6 +560,10 @@ export const make = Effect.gen(function* () { host: input.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index a021db910..2309268bb 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,7 +1,9 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { + PullRequestStackMembership, PullRequestAction, + PullRequestStackHead, PullRequestActor, PullRequestBaseComparison, PullRequestCapabilities, @@ -65,6 +67,7 @@ export interface PullRequestProviderFailure { /** A change request as the provider sees it, before the service attaches project context. */ export interface ProviderChangeRequest { + readonly stack?: PullRequestStackMembership; readonly number: number; readonly title: string; readonly url: string; @@ -103,6 +106,37 @@ export interface ProviderChangeRequestSummary { readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: string; + /** Overview fields, present where the host's single read returns them at no extra cost. */ + readonly author?: PullRequestActor | null | undefined; + readonly additions?: number | undefined; + readonly deletions?: number | undefined; + readonly changedFiles?: number | undefined; + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + readonly checksState?: PullRequestChecksState | null | undefined; + readonly mergeability?: PullRequestMergeability | undefined; +} + +/** One layer of a host-native stack, bottom to top order is the array's. */ +export interface ProviderChangeRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +/** + * A host-native stack: an ordered set of change requests the host itself merges and retargets as + * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay + * ignorant of which host said so. + */ +export interface ProviderChangeRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + readonly layers: ReadonlyArray; } export interface ProviderChangeRequestPage { @@ -331,6 +365,14 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * The host-native stack a change request belongs to, or null when it is not stacked. Optional + * because most hosts have no such object; the service derives chains from base branches there. + */ + readonly getChangeRequestStack?: ( + input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, + ) => Effect.Effect; + /** Comments, line threads, and commits, kept off the critical path for the core detail. */ readonly getChangeRequestActivity: ( input: ProviderRepositoryRef & { readonly number: number }, @@ -390,6 +432,8 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; /** Only meaningful for `update-branch`; absent takes the host's own default. */ diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts new file mode 100644 index 000000000..f94ff3cf5 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -0,0 +1,78 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PullRequestOperationError } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistence from "effect/unstable/persistence/Persistence"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; + +const cacheLayer = (directory: string) => + PullRequestReadCache.make.pipe( + Effect.provide( + Persistence.layerKvs.pipe(Layer.provideMerge(KeyValueStore.layerFileSystem(directory))), + ), + ); + +it.layer(NodeServices.layer)("PR filesystem cache", (it) => { + it.effect("reuses files after restart and respects the original expiry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + let reads = 0; + const lookup = Effect.sync(() => String(++reads)); + const first = yield* cacheLayer(directory); + const key = "long/repository/key".repeat(100); + assert.strictEqual(yield* first.get(key, lookup), "1"); + yield* TestClock.adjust("59 seconds"); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get(key, lookup), "1"); + yield* TestClock.adjust("1 second"); + assert.strictEqual(yield* restarted.get(key, lookup), "2"); + assert.strictEqual(reads, 2); + assert.strictEqual((yield* fs.readDirectory(directory)).length, 1); + }), + ); + + it.effect("clears in-flight reads before a new service can reuse them", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const cache = yield* cacheLayer(directory); + const read = yield* cache + .get( + "summary", + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as("old"), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + const invalidate = yield* cache.invalidate.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(read); + yield* Fiber.join(invalidate); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new")), "new"); + }), + ); + + it.effect("does not persist failed GitHub reads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const cache = yield* cacheLayer(directory); + const error = new PullRequestOperationError({ operation: "summary", detail: "unavailable" }); + yield* cache.get("summary", Effect.fail(error)).pipe(Effect.flip); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", Effect.succeed("recovered")), "recovered"); + }), + ); +}); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts new file mode 100644 index 000000000..62d1cffc3 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -0,0 +1,125 @@ +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; +import * as Equal from "effect/Equal"; +import * as Hash from "effect/Hash"; +import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistable from "effect/unstable/persistence/Persistable"; +import * as PersistedCache from "effect/unstable/persistence/PersistedCache"; +import * as Persistence from "effect/unstable/persistence/Persistence"; +import { ServerConfig } from "../config.ts"; + +const CONCURRENT_READS = 512; +type ReadError = PullRequestOperationError | PullRequestUnavailableError; + +class Read extends Persistable.Class<{ + payload: { key: string; lookup: Effect.Effect }; +}>()("PullRequestRead", { + primaryKey: ({ key }) => key, + success: Schema.Struct({ payload: Schema.String, expiresAt: Schema.Finite }), + error: Schema.Union([PullRequestOperationError, PullRequestUnavailableError]), +}) { + [Equal.symbol](that: unknown): boolean { + return that instanceof Read && that.key === this.key; + } + [Hash.symbol](): number { + return Hash.string(this.key); + } +} + +export class PullRequestReadCache extends Context.Service< + PullRequestReadCache, + { + readonly get: ( + key: string, + lookup: Effect.Effect, + ) => Effect.Effect; + readonly invalidate: Effect.Effect; + } +>()("t3/pullRequest/PullRequestReadCache") {} + +export const make = Effect.gen(function* () { + const backing = yield* KeyValueStore.KeyValueStore; + const crypto = yield* Crypto.Crypto; + const clock = yield* Clock.Clock; + let enabled = true; + const lock = yield* Semaphore.make(CONCURRENT_READS); + const timeToLive: Persistable.TimeToLiveFn = (exit) => + Exit.isSuccess(exit) + ? Duration.millis(Math.max(0, exit.value.expiresAt - clock.currentTimeMillisUnsafe())) + : Duration.zero; + const cache = yield* PersistedCache.make( + (request: Read) => + request.lookup.pipe( + Effect.map((payload) => ({ payload, expiresAt: clock.currentTimeMillisUnsafe() + 60_000 })), + ), + { + storeId: "pr-v2", + timeToLive, + inMemoryTTL: timeToLive, + inMemoryCapacity: CONCURRENT_READS, + }, + ); + return PullRequestReadCache.of({ + get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup) { + if (!enabled) return yield* lookup; + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(key)) + .pipe(Effect.option); + if (Option.isNone(digest)) return yield* lookup; + const read = yield* Effect.cached(lookup); + return yield* cache + .get(new Read({ key: Encoding.encodeHex(digest.value), lookup: read })) + .pipe( + Effect.map((result) => result.payload), + Effect.catchTags({ + PersistenceError: () => read, + SchemaError: () => read, + }), + Effect.uninterruptible, + lock.withPermits(1), + ); + }), + // Let existing reads finish before clearing, so they cannot repopulate stale entries. + invalidate: Cache.invalidateAll(cache.inMemory).pipe( + Effect.andThen(backing.clear), + Effect.catch(() => { + enabled = false; + return Effect.logWarning("PR cache disabled after clearing failed"); + }), + lock.withPermits(CONCURRENT_READS), + ), + }); +}); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + return Layer.effect(PullRequestReadCache, make).pipe( + Layer.provide(Persistence.layerKvs), + Layer.provide( + KeyValueStore.layerFileSystem( + path.join(config.providerStatusCacheDir, "pull-requests"), + ).pipe( + Layer.catch(() => + Layer.effectDiscard( + Effect.logWarning("PR cache directory unavailable; using memory cache"), + ).pipe(Layer.provideMerge(KeyValueStore.layerMemory)), + ), + ), + ), + ); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index eb2f913c9..3117c0072 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,3 +1,6 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistence from "effect/unstable/persistence/Persistence"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -24,6 +27,7 @@ import { } from "./PullRequestProvider.ts"; import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; function project(input: { readonly id: string; @@ -32,6 +36,7 @@ function project(input: { readonly repository?: string; readonly provider?: string; readonly host?: string; + readonly remoteUrl?: string; }): OrchestrationProjectShell { // The host defaults from the provider, so a fixture only names one when the point of the // test is two hosts of the same kind. @@ -47,7 +52,7 @@ function project(input: { locator: { source: "git-remote" as const, remoteName: "origin", - remoteUrl: `https://${host}/${input.repository}.git`, + remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, }, provider: input.provider ?? "github", displayName: input.repository, @@ -197,6 +202,11 @@ function makeService(input: { }), }), SourceControlRateLimit.layer, + Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( + Layer.provide(Persistence.layerKvs), + Layer.provide(KeyValueStore.layerMemory), + Layer.provide(NodeServices.layer), + ), ), ), ); @@ -470,7 +480,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" // Keyed by the selector Azure is actually asked with, which is the repository's own name. assert.deepStrictEqual(result.nextCursors, { - "dev.azure.com web": "2026-07-02T00:00:00Z|4|7", + "dev.azure.com dev.azure.com/acme/web": "2026-07-02T00:00:00Z|4|7", }); }), ); @@ -1563,6 +1573,289 @@ it.effect("refuses a repository that does not belong to the requested project", }), ); +it.effect("caches stack membership separately from action details", () => + Effect.gen(function* () { + const reads: Array = []; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestStack: (input) => + Effect.sync(() => { + reads.push(input.includeDetails === true); + return { + id: "9", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { + number: 7, + headBranch: "a", + state: "open" as const, + ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}), + }, + ], + }; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 }; + yield* service.stack(reference, { includeDetails: false }); + yield* service.stack(reference, { includeDetails: false }); + const detail = yield* service.stack(reference); + yield* service.stack(reference); + assert.deepStrictEqual(reads, [false, true]); + assert.strictEqual(detail?.layers[0]?.headSha, "abc"); + yield* service.invalidate({ reference }); + yield* service.stack(reference, { includeDetails: false }); + yield* service.stack(reference); + assert.deepStrictEqual(reads, [false, true, false, true]); + }), +); + +it.effect("reads a host-native stack through the provider and null where it has none", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestStack: () => + Effect.succeed({ + id: "9", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { number: 7, headBranch: "a", state: "open" as const }, + { number: 8, headBranch: "b", state: "open" as const }, + ], + }), + }), + ], + }); + + const stack = yield* service.stack({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + }); + assert.deepStrictEqual( + stack?.layers.map((layer) => layer.number), + [7, 8], + ); + + const withoutStacks = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [fakeProvider("github")], + }); + assert.isNull( + yield* withoutStacks.stack({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + }), + ); + }), +); + +it.effect("routes a hosted reference to another repository through a project on that host", () => + Effect.gen(function* () { + const seen: Array<{ cwd: string; repository: string; host: string }> = []; + const service = yield* makeService({ + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + + const summary = yield* service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, + { recoverTransientFailure: false }, + ); + + assert.strictEqual(summary.number, 7); + assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); + }), +); + +it.effect("routes Azure reads and writes through the requested organization's checkout", () => + Effect.gen(function* () { + const seen: string[] = []; + const service = yield* makeService({ + projects: ["org-a", "org-b"].map((organization) => + project({ + id: organization, + title: organization, + workspaceRoot: `/${organization}`, + repository: `${organization}/project/_git/web`, + provider: "azure-devops", + host: "dev.azure.com", + }), + ), + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), + }), + ], + }); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); + }), +); + +for (const checkout of [ + { + host: "ssh.dev.azure.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web", + }, + { + host: "vs-ssh.visualstudio.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web", + }, + { + host: "org-b.visualstudio.com", + repository: "DefaultCollection/project/_git/web", + remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", + }, +]) { + it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () => + Effect.gen(function* () { + const seen: string[] = []; + const target = project({ + id: "target", + title: "target", + workspaceRoot: "/target", + provider: "azure-devops", + ...checkout, + }); + const service = yield* makeService({ + projects: [ + ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => + project({ + id: repository, + title: repository, + workspaceRoot: `/${repository}`, + provider: "azure-devops", + host: "dev.azure.com", + repository, + }), + ), + target, + ], + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), + }), + ], + }); + const reference = { + projectId: "org-a/project/_git/web" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); + }), + ); +} + +it.effect("refuses Azure cross-organization reads and writes without its checkout", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "org-a", + title: "org-a", + workspaceRoot: "/org-a", + repository: "org-a/project/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), + runAction: () => Effect.die("must not modify the wrong organization"), + }), + ], + }); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + const readError = yield* Effect.flip( + service.summary(reference, { recoverTransientFailure: false }), + ); + const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); + assert.strictEqual(readError._tag, "PullRequestUnavailableError"); + assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); + }), +); + +it.effect("refuses a hosted reference when nothing is checked out from that host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], + providers: [fakeProvider("github")], + }); + + const error = yield* service + .summary( + { + projectId: "frontend" as ProjectId, + host: "gitlab.com", + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + }), +); + it.effect("refuses a diff on a host that cannot produce one", () => Effect.gen(function* () { const service = yield* makeService({ @@ -3377,6 +3670,48 @@ it.effect("does not ask the host again for a linked summary it already holds", ( }), ); +it.effect( + "opening detail preserves enriched linked summaries and updates draft and diff fields", + () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + isDraft: true, + reviewDecision: "approved", + checksState: "passing", + }), + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("body", 14), + deletions: 3, + changedFiles: 5, + mergeability: "conflicting", + }), + }), + ], + }); + yield* service.summary(reference); + const detail = yield* service.detail(reference); + const summary = yield* service.summary(reference); + assert.strictEqual(summary.isDraft, false); + assert.deepStrictEqual(summary.author, detail.author); + assert.strictEqual(summary.additions, 14); + assert.strictEqual(summary.deletions, 3); + assert.strictEqual(summary.changedFiles, 5); + assert.strictEqual(summary.mergeability, "conflicting"); + assert.strictEqual(summary.reviewDecision, "approved"); + assert.strictEqual(summary.checksState, "passing"); + }), +); + it.effect("reuses an observed merged state for strict settlement reads", () => Effect.gen(function* () { const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; @@ -3587,43 +3922,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil }), ); -it("names an Azure DevOps repository by its own name, not its project path", () => { - // `az repos pr list --repository` takes a name and detects the organisation and project from - // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then - // reads as unavailable on the page. - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - owner: "contoso", - name: "checkout", - }, - } as never); - assert.strictEqual(selector, "checkout"); -}); - -it("falls back to the path's last segment where an Azure identity has no name", () => { - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - }, - } as never); - assert.strictEqual(selector, "checkout"); -}); - -it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "gitlab", - displayName: "group/subgroup/service", - owner: "group", - name: "service", - }, - } as never); - assert.strictEqual(selector, "group/subgroup/service"); -}); - it.effect("narrows the rows of a host that ignored the filters it was handed", () => Effect.gen(function* () { const service = yield* makeService({ @@ -3750,6 +4048,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho }), ); +it.effect("authorizes stack rebases independently of whether the selected layer is behind", () => + Effect.gen(function* () { + let taken = 0; + let summaryReads = 0; + let mutationFails = false; + let stackRebase = true; + let stackActions = true; + const capabilities = { + diff: true, + comment: true, + actions: ["update-branch"] as const, + mergeMethods: ["merge"] as const, + updateMethods: ["rebase"] as const, + get stackActions() { + return stackActions; + }, + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + stackRebase, + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + }), + getChangeRequestSummary: () => + Effect.sync(() => { + summaryReads++; + return changeRequest(8, "2026-07-01T00:00:00Z"); + }), + runAction: () => + Effect.gen(function* () { + taken++; + if (mutationFails) return yield* requestFailed; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.strictEqual(taken, 1); + const unrelated = { ...input, number: 8 }; + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackRebase = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + stackRebase = true; + stackActions = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + assert.strictEqual(taken, 1); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackActions = true; + mutationFails = true; + yield* Effect.flip(service.runAction(input)); + assert.strictEqual(taken, 2); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 2); + }), +); + it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => Effect.gen(function* () { let taken: string | null = null; @@ -4137,3 +4520,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the assert.strictEqual(unnamed.viewer, undefined); }), ); + +it.effect("keeps Azure continuation cursors separate for repositories with the same name", () => + Effect.gen(function* () { + const seen: string[] = []; + const service = yield* makeService({ + projects: ["org-a", "org-b"].map((organization) => + project({ + id: organization, + title: organization, + workspaceRoot: `/${organization}`, + repository: `${organization}/project/_git/web`, + provider: "azure-devops", + host: "dev.azure.com", + }), + ), + providers: [ + fakeProvider("azure-devops", { + listChangeRequests: (input) => + Effect.sync(() => { + seen.push(input.cwd); + return { + items: [changeRequest(7, "2026-07-02T00:00:00Z")], + truncated: true, + continues: true, + }; + }), + }), + ], + }); + const first = yield* service.list({ state: "open" }); + assert.lengthOf(Object.keys(first.nextCursors), 2); + const key = Object.keys(first.nextCursors).find((key) => key.includes("org-b"))!; + seen.length = 0; + yield* service.list({ state: "open", cursors: { [key]: first.nextCursors[key]! } }); + assert.deepStrictEqual(seen, ["/org-b"]); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 2229a4f65..37716da44 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,3 +1,7 @@ +import { + canonicalRepositoryKey, + sourceControlRepositorySelector, +} from "@t3tools/shared/sourceControl"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -7,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -46,7 +51,8 @@ import { type PullRequestLabelCandidateList, type PullRequestLabelChangeInput, type PullRequestSubmitReviewInput, - type PullRequestSummary, + PullRequestStack, + PullRequestSummary, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, type PullRequestThreadCommentsInput, @@ -66,6 +72,7 @@ import { type PullRequestProviderApi, PullRequestProviderError, } from "./PullRequestProvider.ts"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; export interface PullRequestMergeEvent extends PullRequestRef { @@ -109,7 +116,6 @@ const REPOSITORY_SEARCH_CHUNK = 100; * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. */ const LIST_CACHE_TTL = Duration.seconds(30); -const SUMMARY_CACHE_TTL = Duration.seconds(60); const DETAIL_CACHE_TTL = Duration.seconds(15); const DIFF_CACHE_TTL = Duration.seconds(60); /** A commit is content-addressed, so its own diff cannot change under its key. */ @@ -144,6 +150,14 @@ export class PullRequestService extends Context.Service< input: PullRequestRef, options?: { readonly recoverTransientFailure?: boolean }, ) => Effect.Effect; + /** + * The host-native stack the pull request belongs to, or null when it is not in one or the + * host keeps no such object. Cached like a summary; a stack changes about as often. + */ + readonly stack: ( + input: PullRequestRef, + options?: { readonly includeDetails?: boolean }, + ) => Effect.Effect; readonly subscribeMerges: Effect.Effect< Stream.Stream, never, @@ -241,6 +255,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang /** A project this page can read: its remote is on a host with an implementation. */ interface SupportedProject { + readonly cursorKey: string; readonly project: OrchestrationProjectShell; readonly api: PullRequestProviderApi; readonly repository: string; @@ -453,7 +468,7 @@ function withRateLimitBackoff( call: (...args: Args) => Effect.Effect, ) => wrap(operation, call, true); - return { + const wrapped = { kind: api.kind, capabilities: api.capabilities, getViewer: wrap("getViewer", api.getViewer), @@ -474,6 +489,9 @@ function withRateLimitBackoff( : { getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), }), + ...(api.getChangeRequestStack === undefined + ? {} + : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }), getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), ...(api.getReviewThreadComments === undefined ? {} @@ -506,30 +524,9 @@ function withRateLimitBackoff( setReaction: interactive("setReaction", api.setReaction), setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), }; -} - -/** - * The provider-native repository selector. `displayName` is the full path below the host, which - * is what nested GitLab groups need; owner/name is the two-segment fallback for identities - * recorded before that field existed. - * - * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and - * takes the organisation and project from the checkout it detects — so the recorded - * `org/project/_git/repo` path is refused outright and the whole repository reads as - * unavailable. Its name is the last segment, which is what this hands over. - * - * One function because everything downstream is keyed by what it answers: the rows' own - * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. - */ -export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { - const identity = project.repositoryIdentity; - if (!identity) return null; - if (identity.provider === "azure-devops") { - const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); - return identity.name || segments.at(-1) || null; - } - if (identity.displayName) return identity.displayName; - return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; + // Optional provider methods must be forwarded too; returning the interface alone permits omissions. + return wrapped satisfies PullRequestProviderApi & + Record, never>; } export const make = Effect.gen(function* () { @@ -539,6 +536,7 @@ export const make = Effect.gen(function* () { const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; + const readCache = yield* PullRequestReadCache.PullRequestReadCache; const refineUnknownProjectKinds = ( projects: ReadonlyArray, @@ -554,7 +552,11 @@ export const make = Effect.gen(function* () { for (const project of projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; - if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue; + if ( + identity?.provider !== "unknown" || + sourceControlRepositorySelector(project.repositoryIdentity) === null + ) + continue; const host = pullRequestHostOf(identity, "unknown"); // A legacy identity has no canonical host until its provider is refined, so it must reach // the refinement before a host filter can decide whether it belongs in the result. @@ -628,7 +630,7 @@ export const make = Effect.gen(function* () { if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; let kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = repositoryIdentityOf(project); + const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (!identity || kind === undefined || repository === null) continue; // Worktrees of one repository are separate projects; reading the remote once keeps // the page from repeating every change request per local checkout. The host is part @@ -647,7 +649,10 @@ export const make = Effect.gen(function* () { if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); } - const key = listCursorKey(host, repository); + const key = listCursorKey( + host, + kind === "azure-devops" ? identity.canonicalKey : repository, + ); if (seen.has(key)) continue; seen.add(key); if (api === null) { @@ -657,6 +662,7 @@ export const make = Effect.gen(function* () { continue; } supported.push({ + cursorKey: key, project, api: withRateLimitBackoff(api, host, rateLimits), repository, @@ -667,16 +673,30 @@ export const make = Effect.gen(function* () { }), ); + /** + * The project whose checkout and credentials serve a reference. The project's own + * repository is the default; a reference that names a `host` may instead point at any + * repository on that host. Prefer its own checkout; providers with explicit repository + * targeting can fall back to another checkout on the host. Azure derives its organization + * from the checkout, so it requires a matching repository. + */ const requireProject = (ref: PullRequestRef): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( Effect.flatMap(({ supported }): Effect.Effect => { - const match = supported[0]; - if (!match) { - return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + const own = supported[0]; + const repository = ref.repository.trim(); + const host = ref.host?.trim().toLowerCase(); + if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) { + // Hostless references only ever meant the project's own repository, and a hosted one + // naming it still is; either way the project serves itself. + if (host === undefined || host === own.host) return Effect.succeed(own); } - // The repository travels through the client, so it is checked against the project's - // own remote rather than being handed to a provider verbatim. - if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + if (host === undefined) { + if (own === undefined) { + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being handed to a provider verbatim. return Effect.fail( new PullRequestOperationError({ operation: "resolveRepository", @@ -684,7 +704,39 @@ export const make = Effect.gen(function* () { }), ); } - return Effect.succeed(match); + const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); + // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare + // the complete repository identity before narrowing those checkouts by host. + return listWorkspaceProjects( + repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + ).pipe( + Effect.flatMap(({ supported }) => { + const onHost = supported.filter((candidate) => candidate.host === host); + const route = + supported.find( + (candidate) => + candidate.api.kind === "azure-devops" && + candidate.project.repositoryIdentity != null && + canonicalRepositoryKey( + candidate.project.repositoryIdentity.canonicalKey.toLowerCase(), + ) === repositoryKey, + ) ?? + onHost.find( + (candidate) => + candidate.api.kind !== "azure-devops" && + candidate.repository.toLowerCase() === repository.toLowerCase(), + ) ?? + onHost.find((candidate) => candidate.api.kind !== "azure-devops"); + if (route === undefined) { + return Effect.fail( + new PullRequestUnavailableError({ reason: "provider-unsupported" }), + ); + } + return Effect.succeed( + route.api.kind === "azure-devops" ? route : { ...route, repository }, + ); + }), + ); }), ); @@ -829,8 +881,8 @@ export const make = Effect.gen(function* () { viewer: string, ): boolean => { if (filters === undefined) return true; - const labels = item.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && // Judged on the provider row rather than the entry, because the two absences mean @@ -858,6 +910,7 @@ export const make = Effect.gen(function* () { }): PullRequestListEntry => { const viewer = input.viewer.toLowerCase(); return { + ...(input.item.stack === undefined ? {} : { stack: input.item.stack }), provider: input.project.api.kind, host: input.project.host, projectId: input.project.project.id, @@ -949,9 +1002,7 @@ export const make = Effect.gen(function* () { const selected = continuation === null ? projects - : projects.filter(({ host, repository }) => - continuation.has(listCursorKey(host, repository)), - ); + : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); const readable = selected.filter(({ host }) => viewers[host] !== undefined); // A host that could not be read still has projects, and they are absent from the list. // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. @@ -993,7 +1044,7 @@ export const make = Effect.gen(function* () { const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; const cursorOf = (project: SupportedProject): ListCursor | undefined => - continuation?.get(listCursorKey(project.host, project.repository)); + continuation?.get(project.cursorKey); /** * One repository asked on its own. What every host without a search across repositories @@ -1002,7 +1053,7 @@ export const make = Effect.gen(function* () { const readRepository = (project: SupportedProject): Effect.Effect => { { const viewer = viewers[project.host]!; - const key = listCursorKey(project.host, project.repository); + const key = project.cursorKey; const cursor = cursorOf(project); return project.api .listChangeRequests({ @@ -1161,7 +1212,7 @@ export const make = Effect.gen(function* () { !cursorHere.seenAt.includes(item.number), ); return Effect.succeed({ - key: listCursorKey(project.host, project.repository), + key: project.cursorKey, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) .map((item) => toEntry({ project, item, viewer })), @@ -1254,17 +1305,69 @@ export const make = Effect.gen(function* () { title: changeRequest.title, url: changeRequest.url, state: changeRequest.state, - ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, closedAt: changeRequest.closedAt ?? null, mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, + ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), + ...(changeRequest.additions === undefined + ? {} + : { additions: changeRequest.additions }), + ...(changeRequest.deletions === undefined + ? {} + : { deletions: changeRequest.deletions }), + ...(changeRequest.changedFiles === undefined + ? {} + : { changedFiles: changeRequest.changedFiles }), + ...(changeRequest.reviewDecision === undefined + ? {} + : { reviewDecision: changeRequest.reviewDecision }), + ...(changeRequest.checksState === undefined + ? {} + : { checksState: changeRequest.checksState }), + ...(changeRequest.mergeability === undefined + ? {} + : { mergeability: changeRequest.mergeability }), })), ); }), ); + const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getChangeRequestStack; + if (read === undefined) return Effect.succeed(null); + return read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + includeDetails: options?.includeDetails !== false, + }).pipe( + Effect.mapError(toPullRequestError("stack")), + Effect.map((stack): PullRequestStack | null => + stack === null + ? null + : { + id: stack.id, + number: stack.number, + url: stack.url, + base: stack.base, + layers: stack.layers.map((layer) => ({ + ...layer, + number: layer.number, + headBranch: layer.headBranch, + state: layer.state, + })), + }, + ), + ); + }), + ); + const detailUncached: PullRequestService["Service"]["detail"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -1434,6 +1537,20 @@ export const make = Effect.gen(function* () { const runAction = (input: PullRequestActionInput): Effect.Effect => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { + if ( + input.stackNumber !== undefined && + (project.api.capabilities.stackActions !== true || + !["merge", "update-branch"].includes(input.action) || + input.expectedStackHeads === undefined || + (input.action === "update-branch" && input.updateMethod !== "rebase")) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: "This stack action is not supported or has no expected head revision.", + }), + ); + } // The surface hides what a host cannot do, and this refuses it as well: a request that // reached here anyway must not be handed to a provider that never claimed the action. if (!project.api.capabilities.actions.includes(input.action)) { @@ -1476,7 +1593,10 @@ export const make = Effect.gen(function* () { // above do not. return viewerPermissionsOf(project, input, "runAction").pipe( Effect.flatMap((viewer): Effect.Effect => { - if (!viewer.actions.includes(input.action)) { + const stackRebase = input.stackNumber !== undefined && input.action === "update-branch"; + if ( + stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action) + ) { return Effect.fail( new PullRequestOperationError({ operation: "runAction", @@ -1485,6 +1605,7 @@ export const make = Effect.gen(function* () { ); } if ( + !stackRebase && input.updateMethod !== undefined && !(viewer.updateMethods ?? []).includes(input.updateMethod) ) { @@ -1502,12 +1623,23 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) .pipe( + // Once the authorized provider action starts, a failure may leave partial + // remote updates. Validation and permission failures above changed nothing. + Effect.ensuring(input.stackNumber === undefined ? Effect.void : refreshAfterTurn), Effect.mapError(toPullRequestError("runAction")), - Effect.as(project.repository), + Effect.as( + project.api.kind === "azure-devops" + ? input.repository.trim() + : project.repository, + ), ); }), ); @@ -2133,11 +2265,35 @@ export const make = Effect.gen(function* () { let turnRefreshEpoch = 0; const refEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; - const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; + const refScope = (ref: PullRequestRef) => + `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); + // Keys carry the reference back out of the cache loader, so the slot layout is shared with + // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: PullRequestRef) => - JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); + JSON.stringify([ + refEpoch(ref), + ref.projectId, + ref.host?.toLowerCase() ?? null, + ref.repository.toLowerCase(), + ref.number, + ]); + const refOfCacheKey = (key: string): PullRequestRef => { + const [, projectId, host, repository, number] = JSON.parse(key) as [ + number, + string, + string | null, + string, + number, + ]; + return { + projectId, + ...(host === null ? {} : { host }), + repository, + number, + } as PullRequestRef; + }; // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse // them; explicit refreshes, mutations, and turns strand old and in-flight results. const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]); @@ -2179,19 +2335,49 @@ export const make = Effect.gen(function* () { }; }; - const summaryCache = yield* Cache.makeWith( - (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return summaryUncached({ projectId, repository, number } as PullRequestRef); - }, - { - capacity: DETAIL_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), - }, - ); + const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* ( + input: PullRequestRef, + operation: string, + codec: Schema.Codec, + read: Effect.Effect, + ) { + const project = yield* requireProject(input); + const key = [ + operation, + project.api.kind, + project.host.toLowerCase(), + project.repository.toLowerCase(), + project.project.id, + project.project.workspaceRoot, + String(input.number), + ] + .map(encodeURIComponent) + .join(":"); + const lookup = yield* Effect.cached(read); + const encodedRead = lookup.pipe( + Effect.flatMap((value) => + Schema.encodeEffect(codec)(value).pipe( + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "cache", + detail: "Could not encode PR cache data.", + cause, + }), + ), + ), + ), + ); + const payload = yield* readCache.get(key, encodedRead); + const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option); + return Option.isSome(decoded) ? decoded.value : yield* lookup; + }); + const summaryCodec = Schema.fromJsonString(PullRequestSummary); + const stackCodec = Schema.fromJsonString(Schema.NullOr(PullRequestStack)); + const summary: PullRequestService["Service"]["summary"] = (input, options) => { const key = refCacheKey(input); - const cached = Cache.get(summaryCache, key); + const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input)); const held = lastGoodSummary.peek(key); return held !== undefined && (options?.recoverTransientFailure !== false || held.state === "merged") @@ -2203,6 +2389,14 @@ export const make = Effect.gen(function* () { ); }; + const stack: PullRequestService["Service"]["stack"] = (input, options) => + persistedRead( + input, + `stack:${options?.includeDetails !== false}`, + stackCodec, + stackUncached(input, options), + ); + // Keys serialize positionally and parse back in the lookup, so the cache is the only holder // of in-flight state: concurrent identical reads coalesce on the key into one host request. // The continuation cursors are part of the key, entries sorted so one continuation is one @@ -2283,8 +2477,7 @@ export const make = Effect.gen(function* () { const detailCache = yield* Cache.makeWith( (key: string) => { const statsKey = statsCacheKey(key); - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return detailUncached({ projectId, repository, number } as PullRequestRef).pipe( + return detailUncached(refOfCacheKey(key)).pipe( Effect.tap( Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { recordStats( @@ -2307,7 +2500,12 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({ + const summaryFromDetail = ( + detail: PullRequestDetail, + previous: PullRequestSummary | undefined, + ): PullRequestSummary => ({ + // Detail does not carry review/check summaries. Keep the last summary observation. + ...previous, provider: detail.provider, projectId: detail.projectId, repository: detail.repository, @@ -2315,7 +2513,12 @@ export const make = Effect.gen(function* () { title: detail.title, url: detail.url, state: detail.state, - ...(detail.isDraft === true ? { isDraft: true } : {}), + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + mergeability: detail.mergeability, headBranch: detail.headBranch, baseBranch: detail.baseBranch, closedAt: detail.closedAt, @@ -2338,7 +2541,7 @@ export const make = Effect.gen(function* () { key, Cache.get(detailCache, key).pipe( Effect.tap((value) => { - const summary = summaryFromDetail(value); + const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); return shouldReplaceHeldSummary(key, summary) ? lastGoodSummary.record(key, summary) : Effect.void; @@ -2350,8 +2553,7 @@ export const make = Effect.gen(function* () { const activityCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return activityUncached({ projectId, repository, number } as PullRequestRef); + return activityUncached(refOfCacheKey(key)); }, { capacity: DETAIL_CACHE_CAPACITY, @@ -2365,9 +2567,10 @@ export const make = Effect.gen(function* () { const diffCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [ + const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [ number, string, + string | null, string, number, string | null, @@ -2375,6 +2578,7 @@ export const make = Effect.gen(function* () { ]; return diffUncached({ projectId, + ...(host === null ? {} : { host }), repository, number, ...(cursor === null ? {} : { cursor }), @@ -2385,7 +2589,7 @@ export const make = Effect.gen(function* () { capacity: DIFF_CACHE_CAPACITY, timeToLive: (exit, key) => { if (!Exit.isSuccess(exit)) return Duration.zero; - const commit = (JSON.parse(key) as ReadonlyArray)[5]; + const commit = (JSON.parse(key) as ReadonlyArray)[6]; return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; }, }, @@ -2394,7 +2598,8 @@ export const make = Effect.gen(function* () { const key = JSON.stringify([ refEpoch(input), input.projectId, - input.repository, + input.host?.toLowerCase() ?? null, + input.repository.toLowerCase(), input.number, input.cursor ?? null, input.commit ?? null, @@ -2465,7 +2670,7 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; if (reference !== undefined) { - return Effect.sync(() => bumpRefEpoch(reference)); + return readCache.invalidate.pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(reference)))); } return Effect.sync(() => { listingsEpoch = ++epochCounter; @@ -2475,7 +2680,9 @@ export const make = Effect.gen(function* () { const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => { turnRefreshEpoch = listingsEpoch = ++epochCounter; - return SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch); + return readCache.invalidate.pipe( + Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch)), + ); }); // A mutation's own client re-reads right after it, and every other client's next read must @@ -2486,7 +2693,9 @@ export const make = Effect.gen(function* () { method: (input: I) => Effect.Effect, ): ((input: I) => Effect.Effect) => (input) => - method(input).pipe( + readCache.invalidate.pipe( + Effect.andThen(method(input)), + Effect.ensuring(readCache.invalidate), Effect.tap(() => Effect.sync(() => { bumpRefEpoch(input); @@ -2497,7 +2706,8 @@ export const make = Effect.gen(function* () { const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( "PullRequestService.runActionAndInvalidate", )(function* (input) { - const repository = yield* runAction(input); + yield* readCache.invalidate; + const repository = yield* runAction(input).pipe(Effect.ensuring(readCache.invalidate)); bumpRefEpoch({ ...input, repository }); listingsEpoch = ++epochCounter; if (input.action === "merge") { @@ -2523,6 +2733,7 @@ export const make = Effect.gen(function* () { list, listStats, summary, + stack, subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f6f5957f5..f9f6c4ce3 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { buildReviewSubmissionJson, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, buildReviewerRequestJson, decodeBaseComparisonJson, decodePullRequestActivityJson, @@ -11,6 +13,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodeLabelCandidatesJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, @@ -20,6 +23,7 @@ import { decodeWorkflowRunApprovalsJson, reviewThreadConversation, REVIEW_THREADS_GRAPHQL_QUERY, + pullRequestSearchGraphQlQuery, } from "./gitHubPullRequestJson.ts"; function listJson(entries: ReadonlyArray>): string { @@ -164,6 +168,17 @@ describe("pull request search decoding", () => { }); } + it("keeps stack membership beside search results without extra per-PR reads", () => { + const raw = JSON.parse(searchJson(["SUCCESS", null])); + raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; + raw.data.search.nodes[0].stackEntry = { position: 1 }; + const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); + expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); + }); + it("maps the rollup enum the search answers with onto the same three words", () => { // The search asks GitHub for the verdict rather than the checks behind it, so this path sees // one enum where the listing sees an array. @@ -1500,3 +1515,142 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +describe("host-native stack decoding", () => { + /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ + function stack(overrides: Record = {}) { + return { + id: 42, + number: 3, + node_id: "STK_kwDO", + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main", sha: "abc" }, + open: true, + created_at: "2026-09-01T00:00:00Z", + pull_requests: [ + { + number: 10, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02T00:00:00Z", + }, + { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, + { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, + ], + ...overrides, + }; + } + + /** The one stack a listing answered with, which these reads all expect to find. */ + function expectStack(overrides: Record = {}) { + const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); + if (decoded === null) throw new Error("expected a stack"); + return decoded; + } + + it("reads the first stack, bottom to top, with merged_at outranking state", () => { + expect(expectStack()).toEqual({ + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 10, headBranch: "feat/one", state: "merged" }, + { number: 11, headBranch: "feat/two", state: "open" }, + { number: 12, headBranch: "feat/three", state: "closed" }, + ], + }); + }); + + it("retains the detailed layer titles, draft state and expected revision", () => { + expect( + expectStack({ + pull_requests: [ + { + number: 11, + title: "Second layer", + draft: true, + head: { ref: "feat/two", sha: "abc123" }, + state: "open", + merged_at: null, + }, + ], + }).layers, + ).toEqual([ + { + number: 11, + title: "Second layer", + isDraft: true, + headSha: "abc123", + headBranch: "feat/two", + state: "open", + }, + ]); + }); + + it("accepts a base named as a bare branch, which is what the preview started out sending", () => { + expect(expectStack({ base: "develop" }).base).toBe("develop"); + }); + + it("prefers the page a person opens over the API URL, where the host reports one", () => { + expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( + "https://github.com/acme/web/stacks/3", + ); + }); + + it("falls back to the node id, then the number, for a stack without an id", () => { + expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); + expect(expectStack({ id: null, node_id: null }).id).toBe("3"); + }); + + it("reads an empty listing as not stacked", () => { + expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); + }); + + it("refuses a stack without a number or without its pull requests", () => { + expect( + Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), + ).toBe(false); + expect( + Result.isSuccess( + decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), + ), + ).toBe(false); + expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); + }); +}); + +describe("pull request stack membership batches", () => { + it("maps aliases while skipping missing pull requests and incomplete memberships", () => { + const memberships = expectSuccess( + decodePullRequestStackMembershipsJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: null, + s2: { pullRequest: null }, + s3: { pullRequest: { stack: null, stackEntry: null } }, + s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, + }, + }), + ), + ); + expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); + }); + + it("refuses malformed responses and unsafe query selectors", () => { + expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); + expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain( + "pullRequest(number: 8)", + ); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 7887a8161..42fa0ba0e 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -3,6 +3,7 @@ import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { + PullRequestStackMembership, PullRequestActor, PullRequestCheck, PullRequestCheckStatus, @@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({ statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), }); +const RawStackMembershipSchema = Schema.Struct({ + stack: Schema.optional( + Schema.NullOr( + Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), + ), + ), + stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), +}); + /** * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional * because a node that is not a pull request decodes as an empty object, which is skipped. */ const RawSearchItemSchema = Schema.Struct({ + ...RawStackMembershipSchema.fields, number: Schema.Int, title: Schema.String, url: Schema.String, @@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({ ), }); +const RawStackMembershipsSchema = Schema.Struct({ + data: Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), + ), +}); + /** How many of a reaction's people the hover names before it counts the rest. */ const REACTORS_PER_GROUP = 10; @@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; * than twenty labels shows twenty, and one that has asked more than twenty people for a review * is already past what a row can say. */ -export function pullRequestSearchGraphQlQuery(rows: number): string { +export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string { return `query($q: String!) { search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { pageInfo { hasNextPage } nodes { ... on PullRequest { + ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} number title url @@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS = "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; export interface GitHubPullRequestListItem { + readonly stack?: PullRequestStackMembership; /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ readonly authorId: string | null; readonly number: number; @@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson( const node = decodedNode.value; const repository = trimmed(node.repository?.nameWithOwner); if (repository === null) continue; + const stack = toStackMembership(node); items.push({ ...toListItem({ ...node, @@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson( return state === null ? [] : [{ state }]; }), }), + ...(stack === undefined ? {} : { stack }), repository, }); } @@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson( }); } +function toStackMembership( + raw: Schema.Schema.Type, +): PullRequestStackMembership | undefined { + return raw.stack && raw.stackEntry + ? { + number: raw.stack.number, + size: raw.stack.size, + base: raw.stack.baseRefName, + position: raw.stackEntry.position, + } + : undefined; +} + /** What a repository selector may hold before it is written into a GraphQL document unquoted. */ const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; @@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery( return `query {\n${selections.join("\n")}\n}`; } +/** Stack membership for the visible rows of a per-repository listing. */ +export function buildPullRequestStackMembershipsGraphQlQuery( + repository: string, + numbers: ReadonlyArray, +): string | null { + if (numbers.length === 0) return null; + const [owner, name, ...rest] = repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + const selections: string[] = []; + for (const [index, number] of numbers.entries()) { + if (!Number.isSafeInteger(number) || number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`, + ); + } + return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`; +} + +const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); + +export function decodePullRequestStackMembershipsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeStackMemberships(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const memberships = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data)) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const stack = toStackMembership(value.pullRequest); + if (stack !== undefined) memberships.set(Number(index), stack); + } + return Result.succeed(memberships); +} + /** * The counts by the position they were asked in. A repository or a pull request GitHub answered * nothing for is simply absent, which leaves the row with whatever it already had. @@ -2445,3 +2516,77 @@ export function decodePullRequestFilesJson( omittedFileStats, }); } + +/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ +const RawStackPullRequestSchema = Schema.Struct({ + title: Schema.optional(Schema.String), + draft: Schema.optional(Schema.Boolean), + number: Schema.Int, + head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), + state: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may + * still move. Only what a stack is made of is required — where it lives, what it stands on, and + * its pull requests — and `base` is accepted both as the ref object the preview sends today and + * as the bare branch name it started out as. + */ +const RawStackSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), + number: Schema.Int, + node_id: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), + pull_requests: Schema.Array(RawStackPullRequestSchema), +}); + +const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); + +export interface GitHubPullRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +export interface GitHubPullRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + /** Bottom to top, which is the order GitHub lists them in. */ + readonly layers: ReadonlyArray; +} + +/** + * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in + * at most one stack, so the array is GitHub's way of saying "none" rather than a page. + */ +export function decodePullRequestStacksJson( + raw: string, +): Result.Result { + const decoded = decodeStacks(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const stack = decoded.success[0]; + if (stack === undefined) return Result.succeed(null); + return Result.succeed({ + id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), + number: stack.number, + // The page a person opens where the preview reports one; the API URL is what it always has. + url: trimmed(stack.html_url) ?? stack.url, + base: typeof stack.base === "string" ? stack.base : stack.base.ref, + layers: stack.pull_requests.map((pullRequest) => ({ + ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), + ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), + ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), + number: pullRequest.number, + headBranch: pullRequest.head.ref, + state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), + })), + }); +} diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts new file mode 100644 index 000000000..4ed254514 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -0,0 +1,413 @@ +import { expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; + +const runGitHubStackAction = ( + execute: GitHubCli.GitHubCli["Service"]["execute"], + input: Parameters[0], +) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); + +const stack = [ + { + number: 50, + url: "https://api.github.com/repos/acme/web/stacks/50", + base: { ref: "main" }, + pull_requests: [ + { + number: 1, + title: "Base", + head: { ref: "base", sha: "aaa" }, + state: "closed", + merged_at: "2026-01-01T00:00:00Z", + }, + { + number: 2, + title: "Middle", + head: { ref: "middle", sha: "bbb" }, + state: "open", + draft: false, + }, + { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, + ], + }, +]; +const input = { + cwd: "/repo", + repository: "acme/web", + host: "github.com", + number: 3, + stackNumber: 50, + expectedStackHeads: [ + { number: 2, headSha: "bbb" }, + { number: 3, headSha: "ccc" }, + ], + action: "merge" as const, +}; +const access = { + data: { + repository: { + pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + }, + }, +}; + +const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ + data: { + processed: processed.map((headRefOid) => ({ headRefOid })), + repository: { + pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, + }, + }, +}); +const rebased = { + data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, +}; +const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; + +function fake(responses: readonly unknown[]) { + const calls: ReadonlyArray[] = []; + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => + Effect.sync(() => { + calls.push(request.args); + const value = responses[calls.length - 1]; + if (value === undefined) throw new Error("Unexpected GitHub request"); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdout: JSON.stringify(value), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + }; + }); + return { execute, calls }; +} + +it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => + Effect.gen(function* () { + const api = fake([stack, { status: "enqueued", details: {} }]); + yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=ccc"); + expect(api.calls[1]).toContain("merge_action=default"); + expect(api.calls[1]).toContain("merge_method=squash"); + }), +); + +it.effect("merges through the selected layer without including later draft layers", () => + Effect.gen(function* () { + const fiveLayers = [ + { + ...stack[0], + pull_requests: Array.from({ length: 5 }, (_, index) => ({ + number: index + 1, + head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, + state: "open", + draft: index >= 3, + })), + }, + ]; + const api = fake([fiveLayers, { status: "merged", details: {} }]); + yield* runGitHubStackAction(api.execute, { + ...input, + number: 3, + expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), + }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=sha-3"); + expect(api.calls[1]).toContain("merge_action=default"); + }), +); + +it.effect("rejects stale reviewed heads below a selected middle layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 2, + expectedStackHeads: [{ number: 2, headSha: "old-head" }], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("does not merge from an already merged layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 1, + expectedStackHeads: [], + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackUnsupportedError" }, + }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("polls an accepted merge and reports a later rule rejection", () => + Effect.gen(function* () { + const api = fake([ + stack, + { status: "pending", details: { uuid: "operation" } }, + { status: "failed", details: { message: "Required checks have not passed" } }, + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergeRejectedError" }, + }); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); + }), +); + +it.effect("retains stack identity and a rejection response without a message", () => + Effect.gen(function* () { + const rejection = { status: "failed", details: {} }; + const api = fake([stack, rejection]); + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "GitHubStackMergeRejectedError", + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + cause: rejection, + }, + }); + }), +); + +it.effect("refuses a changed stack before performing any mutation", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + expectedStackHeads: [ + { number: 2, headSha: "old" }, + { number: 3, headSha: "ccc" }, + ], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("rebases unmerged layers bottom to top without local git commands", () => + Effect.gen(function* () { + const api = fake([stack, access, ...rebaseResponses]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + const mutations = api.calls.filter((args) => + args.some((arg) => arg.startsWith("query=mutation")), + ); + expect(mutations).toHaveLength(2); + expect(mutations[0]).toContain("id=PR_2"); + expect(mutations[0]).toContain("sha=bbb"); + expect(mutations[1]).toContain("id=PR_3"); + expect(mutations[1]).toContain("sha=ccc"); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("does not update later layers after a rebase failure", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb")]); + const execute: typeof api.execute = (request) => + !request.args.some((arg) => arg.startsWith("query=mutation")) + ? api.execute(request) + : Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/repo", + cause: new Error("denied"), + }), + ); + const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( + Effect.result, + ); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, + }); + }), +); + +it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackPermissionError" }, + }); + expect(api.calls).toHaveLength(2); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("allows a fork that explicitly permits maintainer updates", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, + }, + }, + }, + ...rebaseResponses, + ]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.at(-1)).toContain("id=PR_3"); + }), +); + +it.effect("bounds polling and reports a still-running merge without claiming success", () => + Effect.gen(function* () { + const api = fake([ + stack, + ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("6 minutes"); + expect(yield* Fiber.join(fiber)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergePendingError" }, + }); + expect(api.calls.length).toBeLessThan(40); + }), +); + +it.effect("rejects a push after preflight without rebasing the new revision", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "new-head")]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, + }); + expect(api.calls).toHaveLength(3); + }), +); + +it.effect("skips current layers without submitting a rebase mutation", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( + false, + ); + }), +); + +it.effect("keeps earlier progress and stops after a later layer fails", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "ccc", 1, ["rebased-sha"]), + { data: { updatePullRequestBranch: null } }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, + }); + }), +); + +it.effect("reports partial progress when a later head changes during the rebase", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "concurrent-head", 1, ["rebased-sha"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, + }); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("Earlier updates remain on GitHub"); + } + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(1); + }), +); + +it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb", rebasedParent ? 1 : 0), + ...(rebasedParent ? [rebased] : []), + branch(3, "ccc", 1, ["concurrent-parent-head"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, + }); + expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( + true, + ); + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(rebasedParent ? 1 : 0); + }), +); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts new file mode 100644 index 000000000..6e72836e6 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -0,0 +1,418 @@ +import type { + PullRequestAction, + PullRequestMergeMethod, + PullRequestStackHead, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; + +const stackErrorIdentity = { + repository: Schema.String, + number: Schema.Int, + stackNumber: Schema.Int, +}; + +export class GitHubStackChangedError extends Schema.TaggedError()( + "GitHubStackChangedError", + { ...stackErrorIdentity, completed: Schema.Int }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return this.completed > 0 + ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` + : "The stack changed. Refresh it before trying again."; + } +} + +export class GitHubStackUnsupportedError extends Schema.TaggedError()( + "GitHubStackUnsupportedError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "This operation is not supported for this stack."; + } +} + +export class GitHubStackResponseInvalidError extends Schema.TaggedError()( + "GitHubStackResponseInvalidError", + { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub returned an unreadable stack operation response."; + } +} + +export class GitHubStackMergeRejectedError extends Schema.TaggedError()( + "GitHubStackMergeRejectedError", + { ...stackErrorIdentity, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; + } +} + +export class GitHubStackMergePendingError extends Schema.TaggedError()( + "GitHubStackMergePendingError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "The merge is still running on GitHub. Check its status there before submitting another request."; + } +} + +export class GitHubStackPermissionError extends Schema.TaggedError()( + "GitHubStackPermissionError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; + } +} + +export class GitHubStackRebaseFailedError extends Schema.TaggedError()( + "GitHubStackRebaseFailedError", + { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; + } +} + +export type GitHubStackActionError = + | GitHubStackChangedError + | GitHubStackUnsupportedError + | GitHubStackResponseInvalidError + | GitHubStackMergeRejectedError + | GitHubStackMergePendingError + | GitHubStackPermissionError + | GitHubStackRebaseFailedError; + +const MergeResponse = Schema.Struct({ + status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), + details: Schema.Struct({ + uuid: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +}); + +const decodeBranchAccess = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + headRepository: Schema.NullOr( + Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), + ), + maintainerCanModify: Schema.Boolean, + }), + ), + ), + ), + }), + }), + ), +); + +const decodeRebaseBranch = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + processed: Schema.optional( + Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))), + ), + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }), + }), + }), + }), + }), + ), +); +const decodeRebaseResponse = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + updatePullRequestBranch: Schema.Struct({ + pullRequest: Schema.Struct({ headRefOid: Schema.String }), + }), + }), + }), + ), +); + +const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); + +/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ +export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { + cwd: string; + repository: string; + host: string; + number: number; + stackNumber: number; + expectedStackHeads?: ReadonlyArray; + action: PullRequestAction; + mergeMethod?: PullRequestMergeMethod; +}) { + const github = yield* GitHubCli.GitHubCli; + const identity = { + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + }; + if (input.action !== "merge" && input.action !== "update-branch") + return yield* new GitHubStackUnsupportedError({ ...identity }); + const endpoint = `repos/${input.repository}`; + const read = yield* github.execute({ + cwd: input.cwd, + args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], + }); + const decoded = decodePullRequestStacksJson(read.stdout); + if (Result.isFailure(decoded)) + return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); + const stack = decoded.success; + const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; + const target = stack?.layers[targetIndex]; + if ( + stack?.number !== input.stackNumber || + target === undefined || + (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + const affectedLayers = + input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; + const open = affectedLayers.filter((layer) => layer.state !== "merged"); + if (input.action === "merge" && target.state !== "open") + return yield* new GitHubStackUnsupportedError({ ...identity }); + if ( + !input.expectedStackHeads || + input.expectedStackHeads.length !== open.length || + new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || + open.some( + (layer) => + !layer.headSha || + !input.expectedStackHeads?.some( + (expected) => expected.number === layer.number && expected.headSha === layer.headSha, + ), + ) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + if (open.length === 0 || open.some((layer) => layer.state !== "open")) + return yield* new GitHubStackUnsupportedError({ ...identity }); + if (input.action === "update-branch") { + const [owner, name] = input.repository.split("/"); + const permissions = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-f", + `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open + .map( + (layer) => + `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, + ) + .join(" ")}}}`, + ], + }); + const access = yield* decodeBranchAccess(permissions.stdout).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent + // will make it stale. Check branch write access separately before touching any layer. + if ( + open.some((layer) => { + const pr = access.data.repository?.[`pr${layer.number}`]; + return ( + !pr?.headRepository || + (!pr.maintainerCanModify && + !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) + ); + }) + ) + return yield* new GitHubStackPermissionError({ ...identity }); + const processed: Array<{ id: string; number: number; headSha: string }> = []; + for (const [index, layer] of open.entries()) { + yield* Effect.gen(function* () { + const read = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${layer.number}`, + "-f", + `sha=${layer.headSha}`, + "-f", + `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ + processed.length === 0 + ? "" + : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` + } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, + ], + }); + const { + data: { + processed: observed, + repository: { pullRequest: pr }, + }, + } = yield* decodeRebaseBranch(read.stdout); + // A push to an earlier layer must not silently become the next layer's new base. + const changed = processed.find( + (head, index) => observed?.[index]?.headRefOid !== head.headSha, + ); + if (changed !== undefined) + return yield* new GitHubStackChangedError({ + ...identity, + number: changed.number, + completed: index, + }); + if (pr.headRefOid !== layer.headSha) + return yield* new GitHubStackChangedError({ + ...identity, + number: layer.number, + completed: index, + }); + if (pr.baseRef.compare.behindBy === 0) { + processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); + return; + } + // Pass the reviewed revision to GitHub, including when a push races this read. + const updated = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `id=${pr.id}`, + "-f", + `sha=${layer.headSha}`, + "-f", + "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", + ], + }); + const response = yield* decodeRebaseResponse(updated.stdout); + processed.push({ + id: pr.id, + number: layer.number, + headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, + }); + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackChangedError" + ? cause + : new GitHubStackRebaseFailedError({ + ...identity, + number: layer.number, + completed: index, + cause, + }), + ), + ); + } + return; + } + if (open.some((layer) => layer.isDraft)) + return yield* new GitHubStackUnsupportedError({ ...identity }); + const decode = (raw: string) => + decodeMergeResponse(raw).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + const request = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--method", + "PUT", + `${endpoint}/pulls/${input.number}/merge-async`, + "-f", + `merge_method=${input.mergeMethod ?? "merge"}`, + "-f", + "merge_action=default", + "-f", + `sha=${target.headSha}`, + ], + }); + let result = yield* decode(request.stdout); + const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; + for ( + let attempt = 0; + result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; + attempt++ + ) { + const uuid = result.details.uuid; + if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); + yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); + const poll = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, + ], + }); + result = yield* decode(poll.stdout); + } + if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); + if (result.status === "failed") + return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); +}); diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts new file mode 100644 index 000000000..08237d0e3 --- /dev/null +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -0,0 +1,117 @@ +import { assert, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; + +it.effect( + "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-09-01T00:00:00.000Z"; + const archivedAt = "2026-09-03T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt}) + `; + const fixtures = [ + { + id: "azure", + host: "dev.azure.com", + repository: "org/project/_git/web", + number: 7, + source: "manual", + }, + { + id: "other-org", + host: "dev.azure.com", + repository: "other/project/_git/web", + number: 7, + source: "manual", + }, + { id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "archived", + host: "github.com", + repository: "acme/web", + number: 7, + source: "created", + }, + { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "dismissed", + host: "github.com", + repository: "acme/web", + number: 7, + source: "stack-dismissed", + }, + { + id: "other-host", + host: "github.example.com", + repository: "acme/web", + number: 7, + source: "manual", + }, + { + id: "other-repository", + host: "github.com", + repository: "acme/api", + number: 7, + source: "manual", + }, + { + id: "other-number", + host: "github.com", + repository: "acme/web", + number: 8, + source: "manual", + }, + ]; + for (const fixture of fixtures) { + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, created_at, updated_at, archived_at, deleted_at + ) VALUES ( + ${fixture.id}, 'project-1', ${fixture.id}, '{"instanceId":"codex","model":"gpt-5.4"}', + ${createdAt}, ${fixture.id === "archived" ? archivedAt : createdAt}, + ${fixture.id === "archived" ? archivedAt : null}, + ${fixture.id === "deleted" ? archivedAt : null} + ) + `; + yield* sql` + INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number}, + 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt}) + `; + } + + expect( + (yield* listLinkedPullRequestThreads({ + host: "org.visualstudio.com", + repository: "project/_git/web", + number: 7, + })).threads.map((thread) => thread.id), + ).toEqual(["azure"]); + const result = yield* listLinkedPullRequestThreads({ + host: "GitHub.Com", + repository: "ACME/WEB", + number: 7, + }); + expect(result).toEqual({ + threads: [ + { id: "archived", projectId: "project-1", title: "archived", archivedAt }, + { id: "active", projectId: "project-1", title: "active", archivedAt: null }, + ], + }); + assert.deepStrictEqual( + yield* listLinkedPullRequestThreads({ + host: "github.com", + repository: "acme/web", + number: 99, + }), + { threads: [] }, + ); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts new file mode 100644 index 000000000..26c751f0b --- /dev/null +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -0,0 +1,39 @@ +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { + PullRequestLinkedThreadsResult, + PullRequestOperationError, + type ThreadPullRequestKey, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult); + +export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")( + function* (input: ThreadPullRequestKey) { + const key = normalizeThreadPullRequestKey(input); + const sql = yield* SqlClient.SqlClient; + const threads = yield* sql` + SELECT t.thread_id AS id, t.project_id AS "projectId", t.title, + t.archived_at AS "archivedAt" + FROM projection_thread_pull_requests AS link + JOIN projection_threads AS t ON t.thread_id = link.thread_id + WHERE link.host = ${key.host.toLowerCase()} + AND link.repository = ${key.repository.toLowerCase()} + AND link.number = ${key.number} + AND link.source != 'stack-dismissed' + AND t.deleted_at IS NULL + ORDER BY t.updated_at DESC, t.thread_id ASC + `; + return yield* decodeLinkedThreads({ threads }); + }, + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "linkedThreads", + detail: "Could not load linked threads.", + cause, + }), + ), +); diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts new file mode 100644 index 000000000..2b21e20d1 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts @@ -0,0 +1,46 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; + +describe("pullRequestSyncKey", () => { + const reference = { projectId: ProjectId.make("project"), repository: "web", number: 7 }; + it.each([ + "dev.azure.com/org/project/_git/web", + "ssh.dev.azure.com/v3/org/project/web", + "vs-ssh.visualstudio.com/v3/org/project/web", + "org.visualstudio.com/defaultcollection/project/_git/web", + ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => { + const identity = { + canonicalKey, + provider: "azure-devops", + name: "web", + displayName: canonicalKey.split("/").slice(1).join("/"), + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${canonicalKey}`, + }, + }; + const expected = { host: "dev.azure.com", repository: "org/project/_git/web", number: 7 }; + expect(pullRequestSyncKey(reference, identity)).toEqual(expected); + expect( + pullRequestSyncKey({ ...reference, host: canonicalKey.split("/")[0]! }, identity), + ).toEqual(expected); + expect(pullRequestSyncKey({ ...reference, repository: "other" }, identity)).toBeNull(); + expect(pullRequestSyncKey({ ...reference, host: "unrelated.test" }, identity)).toBeNull(); + }); + it("normalizes complete hosted aliases without requiring a checkout", () => { + expect( + pullRequestSyncKey({ + ...reference, + host: "org.visualstudio.com", + repository: "project/_git/web", + }), + ).toEqual({ host: "dev.azure.com", repository: "org/project/_git/web", number: 7 }); + expect( + pullRequestSyncKey({ ...reference, host: "github.com", repository: "acme/web" }), + ).toEqual({ host: "github.com", repository: "acme/web", number: 7 }); + expect(pullRequestSyncKey(reference)).toBeNull(); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts new file mode 100644 index 000000000..334d48762 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -0,0 +1,44 @@ +import { + pullRequestHostOf, + type PullRequestRef, + type RepositoryIdentity, + type SourceControlProviderKind, + type ThreadPullRequestKey, +} from "@t3tools/contracts"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; + +/** Convert checkout-scoped references to the host-level identity used by linked threads. */ +export function pullRequestSyncKey( + reference: PullRequestRef, + identity?: RepositoryIdentity | null, +): ThreadPullRequestKey | null { + if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) { + if ( + reference.repository.toLowerCase() !== + sourceControlRepositorySelector(identity)?.toLowerCase() || + (reference.host !== undefined && + reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops")) + ) + return null; + const [host, ...repository] = identity.canonicalKey.split("/"); + if (!host || repository.length === 0) return null; + return normalizeThreadPullRequestKey({ + host, + repository: repository.join("/"), + number: reference.number, + }); + } + const host = + reference.host ?? + (identity + ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) + : undefined); + return host === undefined + ? null + : normalizeThreadPullRequestKey({ + host, + repository: reference.repository, + number: reference.number, + }); +} diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9e4d7a56a..fdb3395be 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -347,6 +347,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -490,6 +491,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: { turnId: "turn-1" as TurnId, state: "running", @@ -681,6 +683,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: { turnId: "turn-1" as TurnId, state: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a43e0e1d2..40157c096 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -137,6 +137,7 @@ import { import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ScientForkReactor from "./orchestration/Services/ScientForkReactor.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; @@ -356,6 +357,7 @@ const makeDefaultOrchestrationReadModel = () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -386,6 +388,7 @@ const makeDefaultOrchestrationThreadShell = ( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -409,7 +412,7 @@ const browserOtlpTracingLayer = Layer.mergeAll( const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), Layer.provide( Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ @@ -994,6 +997,11 @@ const buildAppUnderTest = (options?: { drainThrough: () => Effect.void, ...options?.layers?.threadDeletionReactor, }), + Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({ + start: () => Effect.void, + drain: Effect.void, + requestSync: () => Effect.void, + }), ), ), Layer.provide( @@ -8982,6 +8990,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6e7046b91..2d1efd5fe 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,6 +81,7 @@ import { ScientForkAttachmentCopierLive } from "./orchestration/scient-fork/Fork // SCIENT-FORK:END import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -102,6 +103,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; @@ -333,6 +335,7 @@ const ReactorLayerLive = Layer.empty.pipe( // SCIENT-FORK:END Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), + Layer.provideMerge(PullRequestSyncReactor.layer), Layer.provideMerge(ThreadPullRequestReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), @@ -374,6 +377,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(PullRequestReadCache.layer), Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(SourceControlRateLimit.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 509f69323..f4f883c7b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -89,6 +89,7 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + type PullRequestRef, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -168,6 +169,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; @@ -186,6 +188,10 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; +import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -615,7 +621,18 @@ const makeWsRpcLayer = ( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; + const sql = yield* SqlClient.SqlClient; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + /** A reference's host-level link key; the project's own host where the ref names none. */ + const resolvePullRequestSyncKey = (reference: PullRequestRef) => + reference.host !== undefined && reference.repository.includes("/") + ? Effect.succeed(pullRequestSyncKey(reference)) + : projectionSnapshotQuery.getProjectShellById(reference.projectId).pipe( + Effect.map((project) => + pullRequestSyncKey(reference, Option.getOrUndefined(project)?.repositoryIdentity), + ), + Effect.orElseSucceed(() => null), + ); const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; // SCIENT-FORK:START const scientForkReactor = yield* ScientForkReactor.ScientForkReactor; @@ -817,6 +834,7 @@ const makeWsRpcLayer = ( const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const pullRequests = yield* PullRequestService.PullRequestService; + const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -2555,6 +2573,24 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsStack]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsLinkedThreads]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsLinkedThreads, + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null + ? Effect.succeed({ threads: [] }) + : listLinkedPullRequestThreads(key).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", @@ -2578,9 +2614,21 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsRunAction]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsRunAction, + pullRequests + .runAction(input) + .pipe( + Effect.tap(() => + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), + ), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsUpdate]: (input) => observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), { "rpc.aggregate": "pull-requests", @@ -2618,9 +2666,23 @@ const makeWsRpcLayer = ( "rpc.aggregate": "pull-requests", }), [WS_METHODS.pullRequestsInvalidate]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsInvalidate, + pullRequests.invalidate(input).pipe( + // A reader asking for fresh host state also wants the thread badges it feeds to + // catch up, including a merged link the sweep would otherwise never revisit. + Effect.andThen( + input.reference === undefined + ? Effect.void + : resolvePullRequestSyncKey(input.reference).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), + ), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsSubscribeRefreshes]: () => observeRpcStream( WS_METHODS.pullRequestsSubscribeRefreshes, @@ -3242,8 +3304,25 @@ const makeWsRpcLayer = ( .pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( + onSuccess: (result) => + (input.threadId === undefined + ? Effect.void + : linkCreatedPullRequest({ + threadId: input.threadId, + result, + commandId: serverCommandId("pr-created-link"), + }).pipe( + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + ) + ).pipe( + Effect.andThen(refreshGitStatus(input.cwd)), Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), ), }), @@ -3678,6 +3757,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const pullRequests = yield* PullRequestService.PullRequestService; const analysis = yield* AnalysisService.AnalysisService; const compute = yield* ComputeSessionService.ComputeSessionService; + const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", "/ws", @@ -3734,6 +3814,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ), ), + Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // One server-lifetime service means clients share the same PR caches, and a WS diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a9531329..7bf122c46 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,18 @@ +import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts"; + +export interface CommandPaletteLinkedThreads { + readonly environmentId: EnvironmentId; + readonly threads: PullRequestLinkedThreadsResult["threads"]; +} + // Tiny event bus allowing components to programmatically open the command palette // without owning its React state. const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; + readonly query?: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index baac228b3..2f8a27211 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,3 +1,6 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { useRightPanelStore } from "../rightPanelStore"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -47,7 +50,12 @@ import { sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; -import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; +import { + ThreadPullRequestBadgeControl, + prStatusIndicator, + resolveThreadPullRequestBadge, + useLinkedThreadPullRequest, +} from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; @@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({ startFromOrigin, }); - // PR pill shown next to the branch selector when the active branch has one. + // Branch status is the fallback when this thread has no linked pull requests. const branchPrBranch = resolveBranchToolbarPrBranch({ activeThreadBranch, resolvedActiveBranch, @@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({ branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch ? (branchStatusQuery.data.pr ?? null) : null; - const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); - // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's - // state-description tooltip. - const branchPrTooltip = branchPr - ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` - : ""; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); + const linkedStatus = useLinkedThreadPullRequest( + environmentId, + serverThread?.linkedPullRequest, + true, + serverThread?.pullRequests, + serverThread?.branchPullRequest, + ); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? []) + : null; + const prBadge = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(serverThread?.pullRequests) + : null; + const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null); + const displayedPrStatus = prStatusIndicator( + displayedPr, + linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider, + ); + const prNumber = currentLinkedPr?.number ?? displayedPr?.number; + const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); function renderPickerItem(itemValue: string, index: number) { @@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({ className={cn("flex min-w-0 items-center gap-1", className)} data-composer-context-control > - {branchPr && branchPrStatus ? ( - - openPrLink(event, branchPrStatus.url)} - className={cn( - "inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60", - branchPrStatus.colorClass, - )} - /> - } - > - - - - #{branchPr.number} - - - - {branchPrTooltip} - - ) : null} + useRightPanelStore.getState().open(threadRef, "pull-requests")} + onOpenPullRequest={(event) => { + if (prUrl) openPrLink(event, prUrl); + }} + /> {/* Context menu lives on the wrapper: the disabled Button has pointer-events-none, so the trigger itself never sees right-clicks while refs are loading or a branch action is pending. */} diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 24fcdd474..600452823 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -43,6 +43,7 @@ vi.mock("../state/session", async (importOriginal) => ({ vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], + useServerConfigs: () => new Map(), })); vi.mock("../remoteOpen", () => ({ useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), @@ -52,8 +53,7 @@ vi.mock("../editorPreferences", () => ({ usePreferredEditor: () => [null, vi.fn()], })); vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectForChangeRequest: () => undefined, - matchesLinkedPullRequestUrl: () => false, + findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a3169cfe9..0be232754 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,5 +1,6 @@ import { extractFenceTitle } from "~/scient/presentation/CodeBlockTitle"; import { MarkdownCodeBlock } from "~/scient/presentation/MarkdownCodeBlock"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -28,7 +29,7 @@ import type { MessageId, ScopedThreadRef, ServerProviderSkill, - ThreadLinkedPullRequest, + ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { @@ -151,7 +152,6 @@ import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; -import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, @@ -160,7 +160,6 @@ import { } from "../workspaceBasenameLookup"; import { findProjectForChangeRequest, - matchesLinkedPullRequestUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, useOpenChangeRequestLink, @@ -2050,9 +2049,7 @@ function useChatMarkdownState({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); + const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canUseMarkdownFileShellActions( @@ -2104,9 +2101,6 @@ function useChatMarkdownState({ [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const threadServerConfig = useAtomValue( - serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), - ); const projects = useProjects(); const availableEditors = serverConfig?.availableEditors ?? []; const [preferredEditor] = usePreferredEditor(availableEditors); @@ -2207,52 +2201,33 @@ function useChatMarkdownState({ // makes a persisted "app" apply once settings hydrate after launch. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const resolveThreadPullRequest = useCallback( - (href: string): ThreadLinkedPullRequest | null => { + (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { if ( threadRef === undefined || readThreadShell(threadRef) === null || - threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true - ) { + !pullRequestLinking.canLink(href) + ) return null; - } const parsed = parseChangeRequestUrl(href); - if (parsed === null) return null; - const project = findProjectForChangeRequest( - projects.filter((candidate) => candidate.environmentId === threadRef.environmentId), - parsed, - ); - if (project === undefined) return null; - return { - projectId: project.id, - repository: project.repositoryIdentity?.displayName ?? parsed.repository, - number: parsed.number, - url: href, - }; + return parsed === null ? null : { ...parsed, url: href }; }, - [projects, threadRef, threadServerConfig], + [pullRequestLinking, threadRef], + ); + const linkedThreadPullRequestFor = useCallback( + (href: string) => { + if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) + return null; + const parsed = parseChangeRequestUrl(href); + return parsed === null ? null : { ...parsed, url: href }; + }, + [pullRequestLinking, threadRef], ); const updateThreadPullRequestLink = useCallback( async (href: string, linked: boolean) => { - if (threadRef === undefined) return; - const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; - if (linked && linkedPullRequest === null) { - throw new Error("The pull request is not available in this environment."); - } - if (!linked) { - const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; - if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { - return; - } - } - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, linkedPullRequest }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - throw squashAtomCommandFailure(result); - } + if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; + await pullRequestLinking.changeLink(threadRef, href, linked); }, - [resolveThreadPullRequest, threadRef, updateThreadMetadata], + [linkedThreadPullRequestFor, pullRequestLinking, threadRef], ); const openExternalLinkInPreview = useCallback( (url: string) => { @@ -2495,6 +2470,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, resolvedContentDirection, @@ -2524,6 +2500,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, resolvedContentDirection, @@ -2779,6 +2756,7 @@ const CHAT_MARKDOWN_COMPONENTS = { openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, serverConfig, threadRef, @@ -2919,13 +2897,10 @@ const CHAT_MARKDOWN_COMPONENTS = { event.stopPropagation(); const api = readLocalApi(); if (!api) return; - const pullRequest = resolveThreadPullRequest(href); - const currentPullRequest = - threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; const threadLinkAction = - currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) + linkedThreadPullRequestFor(href) !== null ? "unlink-from-thread" - : pullRequest === null + : resolveThreadPullRequest(href) === null ? undefined : "link-to-thread"; void showExternalLinkContextMenu({ diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 1228da6d6..6d3eacb3c 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({ vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], + useServerConfigs: () => new Map(), })); vi.mock("../remoteOpen", () => ({ useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), @@ -41,8 +42,7 @@ vi.mock("../editorPreferences", () => ({ usePreferredEditor: () => [null, vi.fn()], })); vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectForChangeRequest: () => undefined, - matchesLinkedPullRequestUrl: () => false, + findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7f0fec8cc..58854dd69 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -774,6 +774,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], activities: [], checkpoints: [], + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -956,6 +957,7 @@ describe("buildLoadingThreadFromShell", () => { snoozedUntil: null, snoozedAt: null, session: null, + pullRequests: [], latestUserMessageAt: now, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f8df43036..f86183a2b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -410,6 +410,7 @@ export function buildLocalDraftThread( branch: draftThread.branch, worktreePath: draftThread.worktreePath, checkpoints: [], + pullRequests: [], activities: [], proposedPlans: [], }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 640c3b14f..03d6cdafb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -236,6 +236,8 @@ import { type FilePathCopyFormat, } from "./files/filePathClipboard"; import { AgentsPanel } from "./AgentsPanel"; +import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; +import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { deriveAgentPanelModel, foldSubagentActivities, @@ -677,6 +679,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ + '[role="dialog"][aria-modal="true"]', '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', @@ -4595,6 +4598,14 @@ function ChatViewContent(props: ChatViewProps) { }, [openFileSourceSurfaceNow, runAfterPendingFileSave], ); + const supportsThreadPullRequests = + serverConfig?.environment.capabilities.threadPullRequests === true; + const addPullRequestsSurface = useCallback(() => { + if (!activeThreadRef || !supportsThreadPullRequests) return; + runAfterPendingFileSave("pull-requests", () => { + useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); + }); + }, [activeThreadRef, supportsThreadPullRequests, runAfterPendingFileSave]); const openFileSurface = useCallback( (relativePath: string) => { runAfterPendingFileSave(`file:${relativePath}`, () => { @@ -4779,10 +4790,22 @@ function ChatViewContent(props: ChatViewProps) { supportsPullRequests, threadDetailLoading, ]); + const closePreviewPanel = useCallback(() => { + if (!activeThreadRef) return; + runAfterPendingFileSave(null, () => { + if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { + usePreviewMiniPlayerStore + .getState() + .open(activeThreadRef, activeRightPanelSurface.resourceId); + } + setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().close(activeThreadRef); + }); + }, [activeRightPanelSurface, activeThreadRef, runAfterPendingFileSave]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { - runAfterPendingFileSave(null, () => useRightPanelStore.getState().close(activeThreadRef)); + closePreviewPanel(); return; } const activeTabId = activePreviewState.activeTabId; @@ -4796,17 +4819,11 @@ function ChatViewContent(props: ChatViewProps) { }, [ activePreviewState.activeTabId, activeThreadRef, + closePreviewPanel, createBrowserSurface, previewPanelOpen, runAfterPendingFileSave, ]); - const closePreviewPanel = useCallback(() => { - if (!activeThreadRef) return; - runAfterPendingFileSave(null, () => { - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); - }); - }, [activeThreadRef, runAfterPendingFileSave]); const addTerminalSurface = useCallback(() => { if (!activeThreadRef || !activeThreadId || activeTerminalTarget === null) { return; @@ -5882,9 +5899,16 @@ function ChatViewContent(props: ChatViewProps) { : resolveThreadReferenceCopyTarget({ threadId: activeThreadId, openPanelPullRequestUrl, + pullRequests: activeThreadMetadata?.pullRequests, linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, }), - [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl], + [ + activeThreadId, + isServerThread, + activeThreadMetadata?.pullRequests, + linkedThreadPullRequest?.url, + openPanelPullRequestUrl, + ], ); const copyActiveThreadReference = useCallback(() => { const target = activeThreadReferenceCopyTarget; @@ -8796,11 +8820,21 @@ function ChatViewContent(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. { + if (activeThreadRef) + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }); + }} threadRef={activeThreadRef} reference={{ projectId: renderedRightPanelSurface.projectId as ProjectId, + ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), repository: renderedRightPanelSurface.repository, number: renderedRightPanelSurface.number, }} @@ -8821,7 +8855,14 @@ function ChatViewContent(props: ChatViewProps) { : "page" } composerDraftTarget={composerDraftTarget} + onBack={ + activeThreadRef !== null && supportsThreadPullRequests + ? addPullRequestsSurface + : undefined + } /> + ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "agents" ? ( ) : null} + {expandedImage && ( { ).toContain("pe-24"); }); }); +describe("linked pull request thread navigation", () => { + it("keeps archived relations searchable and routes them through the PR environment", async () => { + const environmentId = EnvironmentId.make("remote"); + const id = ThreadId.make("archived-thread"); + const runThread = vi.fn(async () => {}); + const query = "https://github.com/acme/web/pull/42"; + const linkedThreads = { + environmentId, + threads: [ + { + id, + projectId: ProjectId.make("project"), + title: "Completed work", + archivedAt: "2026-09-01T00:00:00.000Z", + }, + ], + }; + const state = reduceCommandPaletteUiState( + { open: false, mode: "command", openIntent: null }, + { + _tag: "OpenSearch", + query, + linkedThreads, + }, + ); + expect(state.openIntent).toEqual({ kind: "search", query, linkedThreads }); + const items = buildLinkedThreadActionItems({ ...linkedThreads, query, icon: null, runThread }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items)).toEqual(items); + expect(items[0]?.description).toBe("Archived thread"); + await items[0]?.run(); + expect(runThread).toHaveBeenCalledWith({ environmentId, id }); + }); +}); describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; @@ -225,6 +267,32 @@ describe("reduceCommandPaletteUiState", () => { ); }); + it("opens PR search from another overlay and replaces an earlier search", () => { + const first = reduceCommandPaletteUiState( + { open: true, mode: "files", openIntent: null }, + { + _tag: "OpenSearch", + query: "https://github.com/acme/web/pull/7", + }, + ); + expect(first).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" }, + }); + const second = reduceCommandPaletteUiState(first, { + _tag: "OpenSearch", + query: "https://github.com/acme/web/pull/8", + }); + expect(second.openIntent).toEqual({ + kind: "search", + query: "https://github.com/acme/web/pull/8", + }); + expect( + reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent, + ).toBeNull(); + }); + it("routes open intents to command mode", () => { const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleMode", @@ -344,6 +412,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; @@ -714,3 +783,45 @@ describe("filterPinnedBrowseEntries", () => { }); }); }); + +it.each([ + "#10839", + "10839", + "pingdotgg/t3code#10839", + "https://github.com/pingdotgg/t3code/pull/10839", +])("finds linked threads from PR query %s", (query) => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + title: "Implementation", + pullRequests: [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 10839, + url: "https://github.com/pingdotgg/t3code/pull/10839", + source: "manual", + linkedAt: "2026-09-08T00:00:00Z", + snapshot: null, + stack: null, + }, + ], + }), + makeThread({ id: ThreadId.make("unrelated"), title: "Other work" }), + ], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items.map((item) => item.title))).toEqual([ + "Implementation", + ]); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 70903547b..20c32ccab 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,3 +1,5 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; +import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; import { type FilesystemBrowseEntry, type KeybindingCommand, @@ -17,6 +19,25 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +/** A PR's relations include archived threads that normal palette search omits. */ +export function buildLinkedThreadActionItems( + input: CommandPaletteLinkedThreads & { + query: string; + icon: ReactNode; + runThread: (thread: Pick) => Promise; + }, +): CommandPaletteActionItem[] { + return input.threads.map((thread) => ({ + kind: "action", + value: `thread:${input.environmentId}:${thread.id}`, + title: thread.title || "Untitled thread", + description: thread.archivedAt === null ? "Linked thread" : "Archived thread", + searchTerms: [input.query, thread.title], + icon: input.icon, + run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }), + })); +} + export function browseInputEndPaddingClass(input: { readonly willCreateProjectPath: boolean; readonly hasHighlightedBrowseItem: boolean; @@ -38,9 +59,13 @@ export function browseInputEndPaddingClass(input: { */ export type SearchOverlayMode = "command" | "files" | "content"; -export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +export type CommandPaletteOpenIntent = + | { readonly kind: "add-project" | "new-thread-in" } + | { + readonly kind: "search"; + readonly query: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; + }; export interface CommandPaletteUiState { readonly open: boolean; @@ -51,6 +76,11 @@ export interface CommandPaletteUiState { export type CommandPaletteUiAction = | { readonly _tag: "SetOpen"; readonly open: boolean } | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } + | { + readonly _tag: "OpenSearch"; + readonly query: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; + } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "ClearOpenIntent" }; @@ -68,6 +98,16 @@ export function reduceCommandPaletteUiState( return state.open && state.mode === action.mode ? { ...state, open: false, openIntent: null } : { open: true, mode: action.mode, openIntent: null }; + case "OpenSearch": + return { + open: true, + mode: "command", + openIntent: { + kind: "search", + query: action.query, + ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}), + }, + }; case "OpenAddProject": return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": @@ -246,6 +286,7 @@ export type BuildThreadActionItemsThread = Pick< | "title" | "worktreePath" > & { + pullRequests?: SidebarThreadSummary["pullRequests"]; updatedAt: string; latestUserMessageAt?: string | null; }; @@ -301,6 +342,7 @@ export function buildThreadActionItems"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); @@ -662,12 +682,16 @@ function OpenCommandPaletteDialog(props: { ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null; const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); + const activeThreadServerConfig = useServerConfigs().get( + activeThread?.environmentId ?? ("" as EnvironmentId), + ); const activeThreadReferenceCopyTarget = referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) ? null : resolveThreadReferenceCopyTarget({ threadId: referenceThreadRef.threadId, openPanelPullRequestUrl, + pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null, }); @@ -745,6 +769,7 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); const { @@ -1578,6 +1603,18 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "search") return; + browseNavigation.invalidate(); + cloneLookupGeneration.current += 1; + setIsRemoteProjectLookingUp(false); + setAddProjectCloneFlow(null); + setViewStack([]); + setLinkedThreadSearch(openIntent); + setQuery(openIntent.query); + clearOpenIntent(); + }, [browseNavigation, clearOpenIntent, openIntent]); + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; @@ -1683,6 +1720,35 @@ function OpenCommandPaletteDialog(props: { }); } + if ( + activeThread !== null && + threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported" + ) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + actionItems.push({ + kind: "action", + value: "action:link-pull-request", + searchTerms: ["link", "pull request", "pr", "attach", "stack"], + title: "Link pull request to thread", + icon: , + run: async () => { + openLinkPullRequestDialog(threadRef); + }, + }); + if (activeThreadServerConfig?.environment.capabilities.threadPullRequests === true) { + actionItems.push({ + kind: "action", + value: "action:open-thread-pull-requests", + searchTerms: ["pull requests", "linked", "stack", "prs"], + title: "Show linked pull requests", + icon: , + run: async () => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + }, + }); + } + } + actionItems.push({ kind: "action", value: "action:open-file-picker", @@ -1860,7 +1926,20 @@ function OpenCommandPaletteDialog(props: { isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, settingsSearchItems, - threadSearchItems: allThreadItems, + threadSearchItems: + linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query + ? buildLinkedThreadActionItems({ + ...linkedThreadSearch.linkedThreads, + query: linkedThreadSearch.query, + icon: , + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }) + : allThreadItems, }); const performProjectOpening = useCallback( @@ -2195,6 +2274,7 @@ function OpenCommandPaletteDialog(props: { } setIsRemoteProjectLookingUp(true); + const lookupGeneration = ++cloneLookupGeneration.current; const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, input: { @@ -2202,6 +2282,7 @@ function OpenCommandPaletteDialog(props: { repository: rawRepository, }, }); + if (lookupGeneration !== cloneLookupGeneration.current) return; setIsRemoteProjectLookingUp(false); if (lookupResult._tag === "Failure") { if (!isAtomCommandInterrupted(lookupResult)) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c575f270f..aa3767a31 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1351,6 +1351,9 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), + // A pull request the action opens is linked to the thread it ran beside. Drafts + // have no server thread yet, so there is nothing to link to. + ...(activeServerThread ? { threadId: activeServerThread.id } : {}), onProgress: applyProgressEvent, }); @@ -1875,9 +1878,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1888,11 +1891,11 @@ export default function GitActionsControl({
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 39093f9b2..940347c2b 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,4 +1,11 @@ import { completedAnswer } from "../scient/answerAttention/completion"; +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { GitPullRequestIcon } from "lucide-react"; +import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; +import { + resolveThreadCurrentPullRequestLink, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, @@ -470,10 +477,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }); const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, ); const pr = linkedPullRequestStatus?.pr ?? null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; @@ -595,17 +608,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); const handlePrClick = useCallback( (event: React.MouseEvent) => { - if (!prStatus) return; + const url = prStatus?.url ?? currentLinkedPr?.url; + if (!url) return; const openedInRightPanel = openPrLink( event, - prStatus.url, + url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { navigateToThread(threadRef); } }, - [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef], + [ + isActive, + navigateToThread, + openPrLink, + openPullRequestsInRightPanel, + prStatus, + currentLinkedPr, + threadRef, + ], ); const handleRenameInputRef = useCallback( (element: HTMLInputElement | null) => { @@ -745,6 +767,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} + {!pr && currentLinkedPr ? ( +
event.stopPropagation()} + onClick={handlePrClick} + className="text-muted-foreground" + aria-label={`PR #${currentLinkedPr.number}, status pending`} + > + + + ) : null} + {pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( + + ) : null} {threadStatus && } {renamingThreadKey === threadKey ? ( undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} + onAddPullRequests={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} onAddAgents={() => undefined} @@ -130,6 +133,7 @@ function renderTabs( diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} + pullRequestsAvailable={false} agentsAvailable={false} sourcesAvailable={false} computeAvailable={false} @@ -345,3 +349,47 @@ describe("RightPanelTabs context menu", () => { ]); }); }); + +describe("pull request tab snapshots", () => { + const environmentId = EnvironmentId.make("local"); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "acme/api", + number: 7, + url: "https://github.com/acme/api/pull/7", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: null, + }; + it("keeps unknown linked state authoritative and scopes matches to environment and host", () => { + const threads = [{ environmentId, pullRequests: [link] }]; + expect(resolvePullRequestTabLink(threads, environmentId, "github.com", link)).toBe(link); + expect( + resolvePullRequestTabLink(threads, EnvironmentId.make("remote"), "github.com", link), + ).toBeUndefined(); + expect( + resolvePullRequestTabLink(threads, environmentId, "github.enterprise.test", link), + ).toBeUndefined(); + }); + it("uses the newest snapshot when several threads link the same PR", () => { + const snapshot = { + state: "merged" as const, + title: "API", + headBranch: "api", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-02-01T00:00:00Z", + }; + const newer = { ...link, snapshot }; + expect( + resolvePullRequestTabLink( + [{ environmentId, pullRequests: [link, newer] }], + environmentId, + "github.com", + link, + ), + ).toBe(newer); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b5ef35bfe..41a05aa14 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,3 +1,10 @@ +import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import type { ContextMenuItem, EnvironmentId, @@ -14,6 +21,7 @@ import { FileDiff, Files, GitPullRequest, + GitPullRequestArrow, Globe2, Library, Plus, @@ -114,6 +122,7 @@ interface RightPanelTabsProps { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; onAddSources: () => void; onAddCompute: () => void; @@ -122,6 +131,7 @@ interface RightPanelTabsProps { diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; sourcesAvailable: boolean; computeAvailable: boolean; @@ -153,6 +163,7 @@ const SURFACE_DISABLED_REASONS = { files: "Files are only available when a workspace is open.", diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", + pullRequests: "Linked pull requests are only available for server threads.", agents: "Agents are only available from a thread.", sources: "Sources are only available inside a project workspace.", compute: "Compute is only available inside a project workspace.", @@ -177,6 +188,7 @@ const SURFACE_UNAVAILABLE_HINTS = { files: "Available when a project is open.", diff: "Available for Git repositories.", pullRequest: "No pull request on this branch yet.", + pullRequests: "Available for server threads.", agents: "Available from a thread.", } as const; @@ -352,6 +364,7 @@ function RightPanelEmptyState(props: { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; onAddSources: () => void; onAddCompute: () => void; @@ -360,6 +373,7 @@ function RightPanelEmptyState(props: { diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; sourcesAvailable: boolean; computeAvailable: boolean; @@ -439,6 +453,16 @@ function RightPanelEmptyState(props: { onClick: props.onAddPullRequest, badgeCount: 0, }, + { + label: "Linked pull requests", + description: "Every pull request this thread has linked, stacks included.", + icon: GitPullRequestArrow, + shortcut: "L", + available: props.pullRequestsAvailable, + disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, + onClick: props.onAddPullRequests, + badgeCount: 0, + }, { label: "Agents", description: "Follow subagents and workflows.", @@ -680,6 +704,8 @@ function surfaceTitle( ); case "pull-request": return `#${surface.number}`; + case "pull-requests": + return "Pull requests"; case "agents": return "Agents"; case "scient": @@ -763,6 +789,8 @@ function SurfaceIcon({ seed={pullRequestStatusSeeds?.[surface.id]} /> ); + case "pull-requests": + return ; case "agents": return ; case "scient": @@ -770,6 +798,35 @@ function SurfaceIcon({ } } +export function resolvePullRequestTabLink( + threads: readonly Pick[], + environmentId: EnvironmentId | null, + host: string | null, + reference: { repository: string; number: number }, +) { + if (environmentId === null || host === null) return undefined; + let newest: EnvironmentThreadShell["pullRequests"][number] | undefined; + for (const thread of threads) { + if (thread.environmentId !== environmentId) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + if ( + !threadPullRequestKeysEqual(link, { + host, + repository: reference.repository, + number: reference.number, + }) + ) + continue; + if ( + newest === undefined || + (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "") + ) + newest = link; + } + } + return newest; +} + function PullRequestSurfaceIcon({ surface, environmentId, @@ -781,13 +838,36 @@ function PullRequestSurfaceIcon({ }) { const resolvedEnvironmentId = (surface.environmentId as EnvironmentId | undefined) ?? environmentId; - const detail = useEnvironmentQuery( + const projects = useProjects(); + const threads = useThreadShells(); + const project = projects.find( + (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, + ); + const identity = project?.repositoryIdentity; + const host = + surface.host ?? + (identity?.provider + ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) + : null); + const configs = useServerConfigs(); + const capabilities = resolvedEnvironmentId === null + ? undefined + : configs.get(resolvedEnvironmentId)?.environment.capabilities; + const linkedSnapshot = + capabilities?.threadPullRequests === true + ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) + : null; + const detail = useEnvironmentQuery( + resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null ? null : pullRequestEnvironment.detail({ environmentId: resolvedEnvironmentId, input: { projectId: surface.projectId as ProjectId, + ...(capabilities?.threadPullRequests === true && surface.host !== undefined + ? { host: surface.host } + : {}), repository: surface.repository, number: surface.number, }, @@ -796,11 +876,15 @@ function PullRequestSurfaceIcon({ // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the // full detail would flip an open tab to the conflict glyph the moment its read lands. const status = - detail === null ? (seed ?? null) : { state: detail.state, isDraft: detail.isDraft }; + linkedSnapshot !== null + ? linkedSnapshot + : detail === null + ? (seed ?? null) + : { state: detail.state, isDraft: detail.isDraft }; if (status === null) { return ; } - const presentation = resolvePullRequestState(status); + const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft }); return ; } @@ -908,6 +992,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.pullRequest, onClick: props.onAddPullRequest, }, + { + label: "Linked pull requests", + icon: GitPullRequestArrow, + shortcut: "L", + available: props.pullRequestsAvailable, + disabledReason: SURFACE_DISABLED_REASONS.pullRequests, + onClick: props.onAddPullRequests, + }, { label: "Agents", icon: Bot, @@ -1347,6 +1439,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} + onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} onAddSources={props.onAddSources} onAddCompute={props.onAddCompute} @@ -1355,6 +1448,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} + pullRequestsAvailable={props.pullRequestsAvailable} agentsAvailable={props.agentsAvailable} sourcesAvailable={props.sourcesAvailable} computeAvailable={props.computeAvailable} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3bb16af31..4aa856f73 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -27,7 +27,7 @@ import { resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, - searchSidebarThreadsByTitle, + searchSidebarThreads, formatWorkingDurationLabel, shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, @@ -802,7 +802,7 @@ describe("resolveSidebarThreadStatus", () => { }); }); -describe("searchSidebarThreadsByTitle", () => { +describe("searchSidebarThreads", () => { const threads = [ { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, { id: "thread-2", title: "Review providers", project: "Workspace" }, @@ -810,15 +810,15 @@ describe("searchSidebarThreadsByTitle", () => { ]; it("matches thread titles case-insensitively and preserves their order", () => { - expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]); + expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); }); it("does not match project metadata", () => { - expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]); + expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]); }); it("returns no results for an empty query", () => { - expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]); + expect(searchSidebarThreads(threads, " ")).toEqual([]); }); }); @@ -2133,6 +2133,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 75561445d..8e9e4729f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import { hasUnreadAnswer } from "../scient/answerAttention/completion"; +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { @@ -870,17 +871,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** - * Search the already-ordered sidebar thread collection by title only. + * Search the already-ordered sidebar thread collection by title or linked PR. * Keeping the input order means lifecycle ordering (active, snoozed, settled) * remains stable while the user narrows the list. */ -export function searchSidebarThreadsByTitle( - threads: readonly T[], - query: string, -): T[] { +export function searchSidebarThreads< + T extends { readonly title: string } & Parameters[0], +>(threads: readonly T[], query: string): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery)); + return threads.filter((thread) => + [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + term.toLowerCase().includes(normalizedQuery), + ), + ); } export function filterSidebarProjectScopeItems(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 649e172dc..dac15c3ba 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,10 @@ import { completedAnswer } from "../scient/answerAttention/completion"; +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; +import { + resolveThreadCurrentPullRequestLink, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { @@ -70,6 +76,7 @@ import { } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; +import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, @@ -159,7 +166,7 @@ import { resolveSidebarDropVerb, type SidebarDropVerb, resolveSidebarThreadStatus, - searchSidebarThreadsByTitle, + searchSidebarThreads, shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, @@ -185,9 +192,11 @@ import { import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { + ThreadPullRequestBadgeControl, + ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, - settledPrHoverColorClass, + resolveThreadPullRequestBadge, terminalStatusFromRunningIds, type TerminalStatusIndicator, useLinkedThreadPullRequest, @@ -330,6 +339,7 @@ function SidebarThreadTooltip({ terminalProcessCount: number; }) { const driverKind = providerEntry?.driverKind ?? null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); return ( ) : null} + {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? ( +
+ +
+ ) : null}
); @@ -1047,8 +1062,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null @@ -1063,6 +1080,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus.data, ); const pr = linkedPullRequestStatus?.pr ?? null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. @@ -1157,7 +1178,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { currentGitBranch: visibleGitStatus?.refName ?? null, }); const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1340,17 +1360,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { - if (!pr?.url) return; + const url = pr?.url ?? currentLinkedPr?.url; + if (!url) return; const openedInRightPanel = openPrLink( event, - pr.url, + url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) { onThreadActivate(threadRef); } }, - [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef], + [ + onThreadActivate, + openPrLink, + openPullRequestsInRightPanel, + pr, + currentLinkedPr, + props.isActive, + threadRef, + ], ); // All sidebar rows share one surface model. Live threads used to look @@ -1359,6 +1388,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected @@ -1441,7 +1471,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : "text-foreground/90", ) : cn( - "truncate group-hover/sidebar-row:text-foreground", + "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", shouldRecede ? "text-secondary-label/70" : props.isActive || isWoke @@ -1457,28 +1487,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); - // A real link so cmd/ctrl+click and middle-click open the host in the - // browser. A plain click still opens T3's pull request view. + // Stacks show their layer count; multiple unrelated links show their total count. + // Plain clicks open T3; individual PR links also support opening the host in a new tab. + const prBadgeShape = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(thread.pullRequests) + : null; + const handlePrStackClick = useCallback(() => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + if (!props.isActive) onThreadActivate(threadRef); + }, [onThreadActivate, props.isActive, threadRef]); const prBadge = - prStatus && pr ? ( - event.stopPropagation()} - onClick={handlePrClick} - className={cn( - // Sidebar chrome follows the interface font; tabular digits keep the - // number from reflowing as PR states stream in. - "shrink-0 text-xs tabular-nums hover:underline", - variant === "slim" && variantAction === "unsettle" - ? cn("text-secondary-label transition-colors", settledPrHoverClass) - : prStatus.colorClass, - )} - aria-label={prStatus.tooltip} - > - #{pr.number} - + prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( + ) : null; const terminalStatusIcon = terminalStatus ? ( {props.project ? : null} @@ -1590,6 +1618,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} + {prBadge && + variantAction !== "unsettle" && + pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( + + ) : null} {sortable?.isDragging ? ( dragDestination ) : ( @@ -1890,10 +1926,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} {prBadge} + {prBadge && + pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( + + ) : null} {diff ? ( - +{diff.insertions}{" "} - −{diff.deletions} + +{diff.insertions}{" "} + −{diff.deletions} ) : null} 0; const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), + () => searchSidebarThreads(searchableThreads, threadSearchQuery), [searchableThreads, threadSearchQuery], ); const threadSearchResultOrderKey = threadSearchResults @@ -4311,7 +4354,7 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }} onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" + placeholder="Search threads or PRs" aria-label="Search threads" role="combobox" aria-autocomplete="list" diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 868bd2cd9..2ad3ef644 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -1,8 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators"; +import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators"; describe("ThreadWorktreeIndicator", () => { it("renders the worktree folder and branch in an accessible label", () => { @@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => { expect(markup).toBe(""); }); }); + +describe("linked pull request snapshots", () => { + const link: ThreadPullRequestLink = { + host: "gitlab.example.com", + repository: "acme/web", + number: 42, + url: "https://gitlab.example.com/acme/web/-/merge_requests/42", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: null, + }; + it("keeps unsynced links unknown", () => { + expect(linkedPullRequestSnapshotStatus(link)).toBeNull(); + }); + it("uses the snapshot state and branches with the linked identity", () => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + snapshot: { + state: "merged", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-02T00:00:00Z", + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result).toEqual({ + pr: { + number: 42, + url: link.url, + title: "Change", + state: "merged", + isDraft: false, + headRef: "feature", + baseRef: "main", + updatedAt: "2026-01-02T00:00:00Z", + }, + sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, + }); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index afe56608e..e948aa409 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,25 +1,37 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; import { - type EnvironmentId, resolveEnvironmentMachineKind, + type EnvironmentId, type ThreadLinkedPullRequest, + type ThreadPullRequestLink, type VcsStatusResult, } from "@t3tools/contracts"; -import { FolderGit2Icon, TerminalIcon } from "lucide-react"; -import { useMemo } from "react"; +import { + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + visibleThreadPullRequests, + type ThreadPullRequestBadge, +} from "@t3tools/shared/threadPullRequests"; +import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react"; +import { useMemo, type MouseEvent } from "react"; +import { buttonVariants, InlineButton } from "./ui/button"; +import { cn } from "../lib/utils"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; -import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { pullRequestListLines } from "./pullRequest/pullRequestListLines"; +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; export interface PrStatusIndicator { label: string; @@ -43,42 +55,233 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } -/** Keep cached summaries visible when an offscreen row stops live queries. */ +/** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, enabled = true, + pullRequests?: ReadonlyArray, + branchPullRequest?: ThreadLinkedPullRequest | null, ): LinkedThreadPullRequestStatus | null { + const supportsLinks = useSupportsMultiplePullRequests(environmentId); + const current = useMemo( + () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null), + [pullRequests, supportsLinks], + ); + const fallback = + current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null; + const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host; + const reference = + fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) }; const queried = useEnvironmentQuery( - !enabled || environmentId === null || linkedPullRequest == null + !enabled || environmentId === null || reference === null ? null - : linkedPullRequestDetailAtom({ - environmentId, - input: { - projectId: linkedPullRequest.projectId, - repository: linkedPullRequest.repository, - number: linkedPullRequest.number, - }, - }), + : linkedPullRequestDetailAtom({ environmentId, input: reference }), ).data; - const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); + const detail = useSharedPullRequestSummary(environmentId, reference, queried); + + return useMemo(() => { + if (current !== null) return linkedPullRequestSnapshotStatus(current); + return detail === null + ? null + : { + pr: pullRequestDetailToVcsStatus(detail), + sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" }, + }; + }, [current, detail]); +} + +export function linkedPullRequestSnapshotStatus( + link: ThreadPullRequestLink, +): LinkedThreadPullRequestStatus | null { + const snapshot = link.snapshot; + if (snapshot === null) return null; + const kind = link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : "github"; + return { + pr: { + number: link.number, + url: link.url, + title: snapshot.title, + state: snapshot.state, + isDraft: snapshot.isDraft, + headRef: snapshot.headBranch, + baseRef: snapshot.baseBranch, + ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), + }, + sourceControlProvider: { kind, name: kind, baseUrl: "" }, + }; +} - return useMemo( +export { + resolveThreadPullRequestBadge, + type ThreadPullRequestBadge, +} from "@t3tools/shared/threadPullRequests"; + +/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */ +function ThreadPullRequestBadgeIcon({ + icon, + className, +}: { + icon: "stack" | "pull-request"; + className?: string | undefined; +}) { + const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon; + return ; +} + +/** The complete linked-PR control shared by the sidebar and composer footer. */ +export function ThreadPullRequestBadgeControl({ + variant, + badge, + number, + url, + status, + onOpenStack, + onOpenPullRequest, +}: { + variant: "underline" | "ghost"; + badge: ThreadPullRequestBadge | null; + number?: number | undefined; + url?: string | undefined; + status: PrStatusIndicator | null; + onOpenStack: () => void; + onOpenPullRequest: (event: MouseEvent) => void; +}) { + const isStack = badge?.kind === "stack"; + const linkedCount = badge?.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null; + if (!isStack && (number === undefined || url === undefined)) return null; + const label = isStack + ? `Stack of ${badge.layers} pull requests, ${badge.state}` + : `${status?.tooltip ?? `PR #${number}, status pending`}${ + badge?.kind === "pull-request" && badge.others > 0 + ? `, and ${badge.others} more linked` + : "" + }`; + const className = cn( + variant === "ghost" + ? buttonVariants({ variant: "ghost", size: "xs" }) + : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring", + "text-xs tabular-nums", + variant === "ghost" && + "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", + linkedCount !== null + ? "text-secondary-label" + : isStack + ? PR_STATE_COLOR_CLASS[badge.state] + : (status?.colorClass ?? "text-muted-foreground"), + ); + const content = ( + <> + + {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} + + ); + return ( + + event.stopPropagation()} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onOpenStack(); + }} + /> + ) : ( + event.stopPropagation()} + onClick={onOpenPullRequest} + /> + ) + } + > + {content} + + {label} + + ); +} + +/** + * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, + * so the hover answers "what is in here" without opening the surface. + */ +export function ThreadPullRequestsMiniList({ + pullRequests, +}: { + pullRequests: ReadonlyArray; +}) { + const lines = useMemo( () => - detail === null - ? null - : { - pr: pullRequestDetailToVcsStatus(detail), - sourceControlProvider: { - kind: detail.provider, - name: detail.provider, - baseUrl: "", - }, - }, - [detail], + pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))), + [pullRequests], + ); + if (lines.length === 0) return null; + return ( +
    + {lines.map((line) => { + const snapshot = line.link.snapshot; + const presentation = + snapshot === null + ? null + : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft }); + return ( +
  • + {presentation ? ( + + ) : ( + + )} + #{line.link.number} + + {snapshot?.title ?? line.link.repository} + + {line.stack ? ( + + {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size} + + ) : null} +
  • + ); + })} +
); } +/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ +const PR_STATE_COLOR_CLASS: Record["state"], string> = { + open: "text-emerald-600 dark:text-emerald-300/90", + merged: "text-violet-600 dark:text-violet-300/90", + closed: "text-red-600 dark:text-red-300/90", +}; + export function settledPrHoverColorClass( state: NonNullable["state"], isDraft = false, @@ -282,7 +485,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ); const pullRequest = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, + true, + thread.pullRequests, + thread.branchPullRequest, ); const pr = pullRequest?.pr ?? null; const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); @@ -293,7 +499,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar }, }); - if (!prStatus && !threadStatus) { + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const pendingLink = + pr === null && supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; + if (!prStatus && !threadStatus && !pendingLink) { return null; } @@ -316,6 +527,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ) : null} + {pendingLink ? ( + + ) : null} {threadStatus ? : null}
); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b8150cb28..87ff5172a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4427,7 +4427,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} -
+
}> @@ -1792,7 +1793,7 @@ function AssistantMessageMeta({ "flex items-center gap-2 text-xs tabular-nums transition-opacity duration-200", alwaysVisible ? "opacity-100" - : "opacity-0 focus-within:opacity-100 group-hover/assistant:opacity-100", + : "opacity-0 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/assistant:opacity-100", className, )} > @@ -2381,6 +2382,11 @@ function toolGroupSummaryIconName( kind: Extract["summaryKind"], ): WorkEntryIconName { switch (kind) { + case "pull-request": + case "link-pr": + case "unlink-pr": + case "list-prs": + return "pull-request"; case "read": return "eye"; case "edit": @@ -2958,6 +2964,7 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "pull-request" | "t3-code" | "wrench" | "x" @@ -3162,6 +3169,8 @@ function ToolActivityImageIcon(props: { function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { + case "pull-request": + return ; case "bot": return ; case "brain": @@ -3466,13 +3475,12 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot, }) : null; - const commandMatchesVisibleLabel = workEntry.command?.trim() === previewText.trim(); const canExpand = (showFailedIndicator && previewText.trim().length > 0) || (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( - (!commandMatchesVisibleLabel && - (workEntryRawCommand(workEntry) || workEntry.command?.trim())) || + workEntryRawCommand(workEntry) || + workEntry.command?.trim() || workEntry.detail?.trim() || workEntry.changedFiles?.length || viewedImage, @@ -3553,9 +3561,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { [] = []; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); + +afterEach(async () => { + for (const root of roots.splice(0)) await act(() => root.unmount()); + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +async function mountImage() { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + roots.push(root); + const ref = createRef(); + const onError = vi.fn(); + await act(() => { + root.render(); + }); + const viewport = host.querySelector('[role="region"]')!; + const image = host.querySelector("img")!; + const zoom = () => host.querySelector('[aria-live="polite"]')!.textContent; + const key = async (value: string, options: KeyboardEventInit = {}) => { + const event = new KeyboardEvent("keydown", { + key: value, + bubbles: true, + cancelable: true, + ...options, + }); + await act(() => viewport.dispatchEvent(event)); + return event; + }; + return { viewport, image, ref, onError, zoom, key }; +} + +it("leaves gallery arrows available at fit and consumes them only when zoomed", async () => { + const { viewport, ref, zoom, key } = await mountImage(); + expect(ref.current!.pan("ArrowRight")).toBe(false); + expect(zoom()).toBe("100% zoom"); + + expect((await key("Enter")).defaultPrevented).toBe(true); + expect(zoom()).toBe("200% zoom"); + const left = viewport.scrollLeft; + expect(ref.current!.pan("ArrowRight")).toBe(true); + expect(viewport.scrollLeft).toBe(left + 40); + expect(ref.current!.pan("Escape")).toBe(false); + + await key("0"); + expect(zoom()).toBe("100% zoom"); + expect(ref.current!.pan("ArrowRight")).toBe(false); +}); + +it("does not hijack modified shortcuts or repeatedly toggle on a held key", async () => { + const { zoom, key } = await mountImage(); + expect((await key("+", { metaKey: true })).defaultPrevented).toBe(false); + expect((await key("+", { ctrlKey: true })).defaultPrevented).toBe(false); + expect(zoom()).toBe("100% zoom"); + await key(" "); + await key(" ", { repeat: true }); + expect(zoom()).toBe("200% zoom"); + await key(" "); + expect(zoom()).toBe("100% zoom"); +}); + +it("bounds wheel zoom and returns to fit when the window resizes", async () => { + const { viewport, zoom } = await mountImage(); + const wheel = async (deltaY: number) => { + const event = new WheelEvent("wheel", { deltaY, cancelable: true }); + await act(() => viewport.dispatchEvent(event)); + return event; + }; + expect((await wheel(-100_000)).defaultPrevented).toBe(true); + expect(zoom()).toBe("800% zoom"); + await wheel(100_000); + expect(zoom()).toBe("100% zoom"); + await wheel(-100_000); + await act(() => window.dispatchEvent(new Event("resize"))); + expect(zoom()).toBe("100% zoom"); +}); + +it("retains the image label and delegates load failure to the existing dialog", async () => { + const { viewport, image, onError } = await mountImage(); + expect(viewport.getAttribute("aria-label")).toBe("Figure, zoomable image"); + expect(viewport.tabIndex).toBe(0); + expect(image.alt).toBe("Figure"); + await act(() => image.dispatchEvent(new Event("error"))); + expect(onError).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx new file mode 100644 index 000000000..944c5204e --- /dev/null +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -0,0 +1,239 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type Ref, +} from "react"; + +const MAX_ZOOM = 8; + +export interface ZoomableImageHandle { + pan: (key: string) => boolean; +} + +/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */ +export function ZoomableImage({ + src, + name, + onError, + ref, +}: { + src: string; + name: string; + onError: () => void; + ref?: Ref; +}) { + const viewportRef = useRef(null); + const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }); + const [windowSize, setWindowSize] = useState(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const [zoom, setZoom] = useState(1); + const zoomRef = useRef(1); + const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + left: number; + top: number; + } | null>(null); + const suppressClickRef = useRef(false); + const [dragging, setDragging] = useState(false); + const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80)); + const fit = Math.min( + 1, + (windowSize.width * 0.92) / (naturalSize.width || 1), + maxHeight / (naturalSize.height || 1), + ); + const width = naturalSize.width * fit * zoom; + const height = naturalSize.height * fit * zoom; + + useImperativeHandle( + ref, + () => ({ + pan(key) { + const viewport = viewportRef.current; + if (!viewport || zoomRef.current <= 1) return false; + switch (key) { + case "ArrowLeft": + viewport.scrollLeft -= 40; + break; + case "ArrowRight": + viewport.scrollLeft += 40; + break; + case "ArrowUp": + viewport.scrollTop -= 40; + break; + case "ArrowDown": + viewport.scrollTop += 40; + break; + default: + return false; + } + return true; + }, + }), + [], + ); + + const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => { + const viewport = viewportRef.current; + const previous = zoomRef.current; + const clamped = Math.min(MAX_ZOOM, Math.max(1, next)); + if (!viewport || previous === clamped) return; + const bounds = viewport.getBoundingClientRect(); + const x = point ? point.x - bounds.left : viewport.clientWidth / 2; + const y = point ? point.y - bounds.top : viewport.clientHeight / 2; + anchorRef.current = { + x: (viewport.scrollLeft + x) / previous, + y: (viewport.scrollTop + y) / previous, + clientX: bounds.left + x, + clientY: bounds.top + y, + }; + zoomRef.current = clamped; + setZoom(clamped); + }, []); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + const anchor = anchorRef.current; + if (!viewport || !anchor) return; + const bounds = viewport.getBoundingClientRect(); + viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left); + viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top); + anchorRef.current = null; + }, [zoom]); + + useEffect(() => { + const resize = () => { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + changeZoom(1); + }; + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, [changeZoom]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const wheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = + event.deltaY * + (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1); + changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), { + x: event.clientX, + y: event.clientY, + }); + }; + viewport.addEventListener("wheel", wheel, { passive: false }); + return () => viewport.removeEventListener("wheel", wheel); + }, [changeZoom]); + + return ( +
+
1 ? (dragging ? "grabbing" : "grab") : "zoom-in", + }} + onClick={(event) => { + // Pointer capture also produces a click after dragging; leave the image zoomed. + if (suppressClickRef.current || event.detail > 1) return; + changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2); + } else if (event.key === "+" || event.key === "=") { + event.preventDefault(); + changeZoom(zoomRef.current * 1.5); + } else if (event.key === "-") { + event.preventDefault(); + changeZoom(zoomRef.current / 1.5); + } else if (event.key === "0") { + event.preventDefault(); + changeZoom(1); + } + }} + onPointerDown={(event) => { + if (dragRef.current) return; + suppressClickRef.current = false; + if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return; + const viewport = event.currentTarget; + const bounds = viewport.getBoundingClientRect(); + if ( + event.clientX - bounds.left >= viewport.clientWidth || + event.clientY - bounds.top >= viewport.clientHeight + ) + return; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + left: viewport.scrollLeft, + top: viewport.scrollTop, + }; + viewport.setPointerCapture(event.pointerId); + setDragging(true); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) { + suppressClickRef.current = true; + } + event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x); + event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setDragging(false); + }} + onLostPointerCapture={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); + }} + > + {name} { + setNaturalSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + }} + onError={onError} + /> +
+ + {Math.round(zoom * 100)}% zoom + +
+ ); +} diff --git a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx new file mode 100644 index 000000000..bbcb9e144 --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx @@ -0,0 +1,54 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Link2 } from "lucide-react"; +import { useState } from "react"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** Adopts a branch discovery as a durable link, even after the thread changes branches. */ +export function LinkBranchPullRequestButton({ + threadRef, + url, +}: { + threadRef: ScopedThreadRef; + url: string; +}) { + const linking = usePullRequestLinking(threadRef.environmentId); + const [pending, setPending] = useState(false); + if (!linking.canLink(url)) return null; + return ( + + event.stopPropagation()} + onClick={async (event) => { + event.preventDefault(); + event.stopPropagation(); + setPending(true); + try { + await linking.changeLink(threadRef, url, true); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not link pull request", + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setPending(false); + } + }} + > + + + } + /> + Link this PR to keep it with this thread + + ); +} diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts new file mode 100644 index 000000000..6d11ce7cd --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; + +const project = { + host: "github.com", + repository: "acme/web", + webUrl: (number: number) => changeRequestWebUrl("github", "github.com", "acme/web", number), +}; + +describe("resolveLinkPullRequestInput", () => { + it.each([ + ["https://bitbucket.org/acme/web/pull-requests/42", "bitbucket.org"], + ["https://github.acme.test/acme/web/pull/42", "github.acme.test"], + ["https://git.acme.test/acme/web/-/merge_requests/42", "git.acme.test"], + ])("links supported host URL %s without a thread project", (url, host) => { + expect( + resolveLinkPullRequestInput({ + reference: ` ${url} `, + project: null, + hasProject: (candidate) => candidate.host === host, + }), + ).toEqual({ link: { host, repository: "acme/web", number: 42, url } }); + }); + + it("validates the full Azure repository when resolving a browser URL", () => { + const hasProject = (reference: { host: string; repository: string }) => + reference.host === "dev.azure.com" && reference.repository === "org-a/project/_git/web"; + expect( + resolveLinkPullRequestInput({ + reference: "https://dev.azure.com/org-a/project/_git/web/pullrequest/42", + project: null, + hasProject, + }), + ).toMatchObject({ link: { repository: "org-a/project/_git/web", number: 42 } }); + expect( + resolveLinkPullRequestInput({ + reference: "https://dev.azure.com/org-b/project/_git/web/pullrequest/42", + project: null, + hasProject, + }), + ).toMatchObject({ error: expect.stringContaining("org-b/project/_git/web") }); + }); + + it("resolves bare Azure numbers into canonical browser URLs", () => { + expect( + resolveLinkPullRequestInput({ + reference: "#42", + project: { + host: "ssh.dev.azure.com", + repository: "v3/org/project/web", + webUrl: (number) => + changeRequestWebUrl("azure-devops", "ssh.dev.azure.com", "v3/org/project/web", number), + }, + hasProject: () => true, + }), + ).toMatchObject({ + link: { + host: "dev.azure.com", + repository: "org/project/_git/web", + number: 42, + url: "https://dev.azure.com/org/project/_git/web/pullrequest/42", + }, + }); + }); + + it("returns null for input that is not a reference", () => { + expect( + resolveLinkPullRequestInput({ reference: "hello", project, hasProject: () => true }), + ).toBeNull(); + }); + + it("resolves a bare number against the thread's own repository", () => { + expect( + resolveLinkPullRequestInput({ reference: "#42", project, hasProject: () => true }), + ).toEqual({ + link: { + host: "github.com", + repository: "acme/web", + number: 42, + url: "https://github.com/acme/web/pull/42", + }, + }); + }); + + it("links a URL from another repository on a host with a project", () => { + expect( + resolveLinkPullRequestInput({ + reference: "https://github.com/acme/api/pull/7", + project, + hasProject: (reference) => reference.host === "github.com", + }), + ).toEqual({ + link: { + host: "github.com", + repository: "acme/api", + number: 7, + url: "https://github.com/acme/api/pull/7", + }, + }); + }); + + it("refuses a URL on a host nothing is checked out from", () => { + const result = resolveLinkPullRequestInput({ + reference: "https://gitlab.com/acme/api/-/merge_requests/7", + project, + hasProject: () => false, + }); + expect(result).toMatchObject({ error: expect.stringContaining("gitlab.com") }); + }); + + it("asks for a URL when a bare number has no project to resolve against", () => { + expect( + resolveLinkPullRequestInput({ reference: "12", project: null, hasProject: () => true }), + ).toMatchObject({ error: expect.stringContaining("full URL") }); + }); + + it("accepts a checkout command as a reference", () => { + expect( + resolveLinkPullRequestInput({ + reference: "gh pr checkout https://github.com/acme/web/pull/3", + project, + hasProject: () => true, + }), + ).toMatchObject({ link: { number: 3, repository: "acme/web" } }); + }); +}); + +describe("changeRequestWebUrl", () => { + it("knows the four hosts and nothing else", () => { + expect(changeRequestWebUrl("gitlab", "gitlab.com", "g/sub/repo", 5)).toBe( + "https://gitlab.com/g/sub/repo/-/merge_requests/5", + ); + expect(changeRequestWebUrl("unknown", "x", "a/b", 1)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx new file mode 100644 index 000000000..aa5dfa789 --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -0,0 +1,254 @@ +import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; +export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; +import { + pullRequestHostOf, + type ScopedThreadRef, + type SourceControlProviderKind, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { parsePullRequestReference } from "~/pullRequestReference"; +import { useProjects, useThreadShell } from "~/state/entities"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { Atom } from "effect/unstable/reactivity"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; + +/** + * Which thread has the link dialog open, set by whichever entry point asked (command palette, + * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives + * a palette that closes the moment its command runs. + */ +const linkPullRequestDialogThreadAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("pull-requests:link-dialog-thread"), +); + +export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void { + appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef); +} + +interface LinkPullRequestDialogProps { + open: boolean; + threadRef: ScopedThreadRef; + /** The thread's own project: bare numbers resolve against its repository. */ + projectId: string | null; + onOpenChange: (open: boolean) => void; +} + +/** Mounted once per chat view; shows the dialog for whichever thread asked for it. */ +export function LinkPullRequestDialogHost() { + const threadRef = useAtomValue(linkPullRequestDialogThreadAtom); + const thread = useThreadShell(threadRef); + const linking = usePullRequestLinking(threadRef?.environmentId); + if (threadRef === null || linking.mode === "unsupported") return null; + return ( + { + if (!open) appAtomRegistry.set(linkPullRequestDialogThreadAtom, null); + }} + /> + ); +} + +interface ResolvedLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** + * Which pull request an input names, or why it cannot. A URL carries its own host and + * repository and may point at any repository on a host this environment has a project for; a + * bare `#123` can only mean the thread's own repository. + */ +export function resolveLinkPullRequestInput(input: { + readonly reference: string; + readonly project: { + readonly host: string; + readonly repository: string; + readonly webUrl: (number: number) => string | null; + } | null; + readonly hasProject: (reference: ResolvedLink) => boolean; +}): { link: ResolvedLink } | { error: string } | null { + const parsed = + parseChangeRequestUrl(input.reference.trim()) !== null + ? input.reference.trim() + : parsePullRequestReference(input.reference); + if (parsed === null) return null; + const url = parseChangeRequestUrl(parsed); + if (url !== null) { + if (!input.hasProject({ ...url, url: parsed })) { + return { error: `No project in this environment can read ${url.host}/${url.repository}.` }; + } + return { + link: { host: url.host, repository: url.repository, number: url.number, url: parsed }, + }; + } + const number = Number(parsed); + if (!Number.isSafeInteger(number) || number < 1) return null; + if (input.project === null) { + return { error: "Paste a full URL to link a pull request from another repository." }; + } + const webUrl = input.project.webUrl(number); + const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl); + if (webUrl === null || webReference === null) { + return { error: "Paste a full URL; this project's host has no known pull request URL." }; + } + return { + link: { ...webReference, url: webUrl }, + }; +} + +function LinkPullRequestDialog({ + open, + threadRef, + projectId, + onOpenChange, +}: LinkPullRequestDialogProps) { + const inputRef = useRef(null); + const [reference, setReference] = useState(""); + const [dirty, setDirty] = useState(false); + const [submitError, setSubmitError] = useState(null); + const projects = useProjects(); + const environmentProjects = useMemo( + () => projects.filter((project) => project.environmentId === threadRef.environmentId), + [projects, threadRef.environmentId], + ); + const ownProject = useMemo(() => { + const project = environmentProjects.find((candidate) => candidate.id === projectId); + const identity = project?.repositoryIdentity; + if (!project || !identity) return null; + const repository = + identity.displayName ?? + (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); + if (repository === null) return null; + const kind = identity.provider as SourceControlProviderKind; + const host = pullRequestHostOf(identity, kind); + return { + host, + repository, + webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number), + }; + }, [environmentProjects, projectId]); + const linking = usePullRequestLinking(threadRef.environmentId); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!open) return; + setReference(""); + setDirty(false); + setSubmitError(null); + const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [open]); + + const resolved = useMemo( + () => + resolveLinkPullRequestInput({ + reference, + project: ownProject, + hasProject: (reference) => linking.canLink(reference.url), + }), + [linking, ownProject, reference], + ); + + const submit = useCallback(async () => { + setDirty(true); + if (resolved === null || "error" in resolved) return; + setSubmitError(null); + setPending(true); + try { + await linking.changeLink(threadRef, resolved.link.url, true); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Could not link the pull request."); + return; + } finally { + setPending(false); + } + onOpenChange(false); + }, [linking, onOpenChange, resolved, threadRef]); + + const validation = !dirty + ? null + : reference.trim().length === 0 + ? "Paste a pull request URL or enter 123 / #123." + : resolved === null + ? "Use a pull request URL, 123, or #123." + : "error" in resolved + ? resolved.error + : null; + + return ( + (pending ? undefined : onOpenChange(next))}> + + + Link pull request + + Attach a pull request to this thread. A full URL can point at any repository on a host + this environment has a project for. + + + + { + setDirty(true); + setReference(event.target.value); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void submit(); + }} + /> + {resolved !== null && "link" in resolved ? ( +

+ {resolved.link.host}/{resolved.link.repository} #{resolved.link.number} +

+ ) : null} + {(validation ?? submitError) ? ( +

{validation ?? submitError}

+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index de6f32aaa..236d7cd86 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,5 @@ +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { usePullRequestStack } from "~/state/usePullRequestStack"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -66,16 +68,15 @@ import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; -import { useProjects } from "~/state/entities"; +import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { - pullRequestEnvironment, - usePullRequestTurnRefresh, - useSharedPullRequestSummary, -} from "~/state/pullRequests"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; +import { PullRequestStackMenu } from "./PullRequestStackMenu"; +import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { useUiStateStore } from "~/uiStateStore"; @@ -105,7 +106,7 @@ import { } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; @@ -137,6 +138,7 @@ import { readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, + allowsSinglePullRequestMerge, resolveBaseFreshness, resolvePullRequestMergeMethod, type PullRequestFinding, @@ -452,15 +454,18 @@ function PullRequestBaseFreshnessWarning({ export function PullRequestDetailPanel({ environmentId, threadRef = null, - reference, + reference: requestedReference, listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, context = "page", composerDraftTarget, + onBack, + onSelectPullRequest, }: { environmentId: EnvironmentId; + onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull * request itself (check details, host permalinks) can open in that thread's @@ -495,14 +500,35 @@ export function PullRequestDetailPanel({ * land here instead of opening a new thread — the branch is already under the reader's feet. */ composerDraftTarget?: ScopedThreadRef | DraftId; + /** + * Beside a thread, the way back to that thread's list of pull requests. The tab strip can + * close this surface, but closing is not going back: the reader came from the list and + * expects to land on it, with this one still open behind. + */ + onBack?: (() => void) | undefined; }) { - const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const environmentConfigs = useServerConfigs(); + const supportsThreadPullRequests = + environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; + const reference = useMemo( + () => + supportsThreadPullRequests + ? requestedReference + : { + projectId: requestedReference.projectId, + repository: requestedReference.repository, + number: requestedReference.number, + }, + [requestedReference, supportsThreadPullRequests], + ); + const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const matchingListEntry = listEntry?.projectId === reference.projectId && listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && listEntry.number === reference.number ? listEntry : null; + const [threadPickerOpen, setThreadPickerOpen] = useState(false); const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ @@ -631,6 +657,11 @@ export function PullRequestDetailPanel({ : { ...resolvedCoreDetail, ...sharedSummary, + author: sharedSummary.author ?? resolvedCoreDetail.author, + additions: sharedSummary.additions ?? resolvedCoreDetail.additions, + deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, + changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles, + mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability, closedAt: sharedSummary.closedAt === undefined ? resolvedCoreDetail.closedAt @@ -702,12 +733,36 @@ export function PullRequestDetailPanel({ const isStackedPullRequest = detail !== null && isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); + // The host's own stack, where it keeps one. Only asked for once the detail has landed so a + // pull request nobody can read costs one request rather than two. + const stackReference = useMemo( + () => + detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests + ? null + : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }, + [detail, reference, supportsThreadPullRequests], + ); + const nativeStackQuery = usePullRequestStack(environmentId, stackReference); + const nativeStack = nativeStackQuery.data; + const supportsStackActions = + supportsThreadPullRequests && + detail?.capabilities.stacks === true && + detail.capabilities.stackActions === true && + environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions === + true; + const canMergeSinglePullRequest = allowsSinglePullRequestMerge({ + supportsStackActions, + hasStack: nativeStack !== null, + stackPending: !nativeStackQuery.isSuccess || nativeStackQuery.isPending, + stackError: nativeStackQuery.error, + }); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); - }, [activityQuery.refresh, detailQuery.refresh]); + nativeStackQuery.refresh(); + }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); const [refreshToken, setRefreshToken] = useState(0); const codeRefreshToken = refreshToken + (turnRefresh ?? 0); const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( @@ -1318,9 +1373,9 @@ export function PullRequestDetailPanel({ checksState, autoMergeEnabled: detail.autoMergeEnabled, hasMergeMethod: allowedMergeMethods.length > 0, - canMerge: can("merge"), + canMerge: canMergeSinglePullRequest && can("merge"), canMarkReady: can("ready"), - canEnableAutoMerge: can("enable-auto-merge"), + canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), }) : null; // What the menu's action group holds. Named once so the separators around it are drawn from @@ -1330,6 +1385,7 @@ export function PullRequestDetailPanel({ can(detail.isDraft ? "ready" : "draft") && !(detail.isDraft && primaryAction === "ready"); const showsAutoMerge = + canMergeSinglePullRequest && detail?.state === "open" && ((autoMergeArmed && can("disable-auto-merge")) || (!autoMergeArmed && @@ -1339,6 +1395,7 @@ export function PullRequestDetailPanel({ can("enable-auto-merge") && allowedMergeMethods.length > 0)); const showsMergeNow = + canMergeSinglePullRequest && detail?.state === "open" && (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && can("merge") && @@ -1379,6 +1436,17 @@ export function PullRequestDetailPanel({ return (
+ {threadPickerOpen && detail ? ( + + ) : null}
{detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null} {detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null}
{detail ? ( - <> + + {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( + + ) : null} + {nativeStack ? ( + 0 + } + canRebase={ + nativeStackQuery.isFresh && + supportsStackActions && + detail.viewerPermissions.stackRebase === true + } + onActed={() => { + refreshDetail(); + onActed?.(); + }} + /> + ) : null} + {context === "page" ? ( + + ) : null} {/* Checking a pull request out is the reason to open one here at all, so it is a button of its own rather than a side effect of asking an agent for something. It asks where, because the two answers are not interchangeable: one leaves your @@ -1495,24 +1640,34 @@ export function PullRequestDetailPanel({ the page: beside a thread the branch is already checked out right there. */} {context === "page" ? ( - - - - {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} - - - - } - /> + + + + + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} + + + + } + /> + } + /> + Check out this pull request + startCheckout("worktree")}> @@ -1573,7 +1728,7 @@ export function PullRequestDetailPanel({ @@ -1621,6 +1774,7 @@ export function PullRequestDetailPanel({ + + } + /> + + Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "} + {mergeLayers.length === 1 ? "pull request" : "pull requests"}) + + + ) : null} + { + if (!value && !pending) setConfirmation(null); + }} + > + + + + {confirmation === "merge" + ? `Merge ${mergeLayers.length} pull requests?` + : `Rebase ${unmerged.length} pull requests?`} + + + {confirmation === "merge" + ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.` + : `Rebase the remote branches from bottom to top onto ${stack.base}. This rewrites branch history and may restart checks. If a layer fails, earlier updates remain.`} + + + +
    + {confirmationLayers.map((layer) => ( +
  • + +
  • + ))} +
+
+ + + + +
+
+ + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx b/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx new file mode 100644 index 000000000..451131cb1 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx @@ -0,0 +1,108 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts"; +import { LayersIcon } from "lucide-react"; +import { useState } from "react"; +import { usePullRequestStack } from "~/state/usePullRequestStack"; +import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu"; +import { PullRequestStackLayers } from "./PullRequestStackLayers"; +import { PullRequestStackHeader } from "./PullRequestStackHeader"; + +/** Mounted only while the menu is open, so list rows do not each fetch a stack. */ +function StackBody({ + environmentId, + reference, + onSelect, + stackNumber, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + onSelect: (reference: PullRequestRef) => void; + stackNumber: number; +}) { + const query = usePullRequestStack(environmentId, reference); + if (query.data !== null) { + return ( + <> + + {query.error ? Retry stack refresh : null} + + + ); + } + return ( + <> + + + {query.error ?? + (query.isPending ? "Loading stack…" : "This pull request is no longer in a stack.")} + + + ); +} + +export function PullRequestStackPopover({ + environmentId, + reference, + membership, + onSelect, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + membership: PullRequestStackMembership; + onSelect: (reference: PullRequestRef) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + + + } + aria-label={`Stack ${membership.number}, layer ${membership.position} of ${membership.size}`} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {membership.position}/{membership.size} + + } + /> + + View stack #{membership.number}, layer {membership.position} of {membership.size} + + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {open ? ( + { + setOpen(false); + onSelect(target); + }} + /> + ) : null} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index b06630f3b..fed932d45 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -314,12 +314,14 @@ function Section({ function CommentComposer({ environmentId, + reference, detail, actionPending, onCommentAction, onCommented, }: { environmentId: EnvironmentId; + reference: PullRequestRef; detail: PullRequestDetailView; actionPending: boolean; onCommentAction: ( @@ -355,9 +357,7 @@ function CommentComposer({ const result = await postComment({ environmentId, input: { - projectId: detail.projectId, - repository: detail.repository, - number: detail.number, + ...reference, body: trimmed, }, }); @@ -996,7 +996,14 @@ export function PullRequestSummaryTab({ {/* Posting is a core capability and remains usable even if the activity read failed. */} {detail.capabilities.comment && detail.viewerPermissions.comment ? ( void; +} + +/** Thread relations belong to the detail environment, including when another environment is active. */ +export function PullRequestThreadLinks(props: PullRequestThreadLinksProps) { + const configs = useServerConfigs(); + if ( + threadPullRequestLinkMode(configs.get(props.environmentId)?.environment.capabilities) === + "unsupported" + ) { + return null; + } + return ; +} + +function EnabledPullRequestThreadLinks({ + environmentId, + reference, + url, + threadRef, + display, + onPickerOpenChange, +}: PullRequestThreadLinksProps) { + const parsed = parseChangeRequestUrl(url); + const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null; + const thread = useThreadShell(currentThreadRef); + const linking = usePullRequestLinking(environmentId); + const linkedHere = linking.isLinked(thread, url); + const relations = useEnvironmentQuery( + linking.mode === "multiple" && display !== "menu-item" + ? pullRequestEnvironment.linkedThreads({ + environmentId, + input: parsed === null ? reference : { ...reference, ...parsed }, + }) + : null, + ); + // Refreshes can briefly clear the query value. Keep the last response so polling + // does not hide the linked-thread count between responses. + const [lastRelations, setLastRelations] = useState(relations.data); + if (relations.data !== null && relations.data !== lastRelations) { + setLastRelations(relations.data); + } + const [pending, setPending] = useState(false); + + const changeLink = async (threadId: ThreadId, remove: boolean) => { + if (parsed === null || pending) return; + setPending(true); + try { + await linking.changeLink(scopeThreadRef(environmentId, threadId), url, !remove); + } catch (error) { + toastManager.add({ + type: "error", + title: remove ? "Could not unlink the pull request" : "Could not link the pull request", + description: error instanceof Error ? error.message : String(error), + }); + return; + } finally { + setPending(false); + } + if (linking.mode === "multiple") { + appAtomRegistry.refresh( + pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }), + ); + } + onPickerOpenChange?.(false); + }; + + if (parsed === null || (!linkedHere && !linking.canLink(url))) return null; + const linkedThreads = + linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : []; + const linkedThreadsLabel = + linkedThreads.length > 0 + ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}` + : "Linked threads"; + return ( + <> + {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? ( + + + openCommandPalette({ + query: url, + ...((relations.data ?? lastRelations) === null + ? {} + : { linkedThreads: { environmentId, threads: linkedThreads } }), + }) + } + > + + {linkedThreads.length || "?"} + + } + /> + {linkedThreadsLabel}. Search in the command palette. + + ) : null} + {display === "menu-item" ? ( + { + if (currentThreadRef !== null) { + void changeLink(currentThreadRef.threadId, linkedHere); + } else { + onPickerOpenChange?.(true); + } + }} + > + {linkedHere ? ( + + ) : ( + + )} + {linkedHere + ? "Unlink from this thread" + : currentThreadRef + ? "Link to this thread" + : "Link to thread"} + + ) : null} + {display === "picker" ? ( + + + Link pull request to a thread + void changeLink(threadId, false)} + /> + + + ) : null} + + ); +} + +function ThreadPicker({ + environmentId, + url, + pending, + onSelect, +}: { + environmentId: EnvironmentId; + url: string; + pending: boolean; + onSelect: (threadId: ThreadId) => void; +}) { + const threads = useThreadShells(); + const linking = usePullRequestLinking(environmentId); + const projects = useProjects(); + const [query, setQuery] = useState(""); + const projectNames = new Map( + projects + .filter((project) => project.environmentId === environmentId) + .map((project) => [project.id, project.title]), + ); + const search = query.trim().toLocaleLowerCase(); + const candidates = threads + // Historical null-project shells are decode-only, not linkable workspaces. + .flatMap((thread) => + thread.projectId === null ? [] : [{ ...thread, projectId: thread.projectId }], + ) + .filter( + (thread) => + thread.environmentId === environmentId && + thread.archivedAt === null && + `${thread.title} ${projectNames.get(thread.projectId) ?? ""}` + .toLocaleLowerCase() + .includes(search), + ) + .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return ( + + + + {candidates.length === 0 ? ( +
+ No active threads found. +
+ ) : ( + candidates.map((thread) => { + const linked = linking.isLinked(thread, url); + return ( + onSelect(thread.id)} + > + + + {thread.title || "Untitled thread"} + + {projectNames.get(thread.projectId)} + + + {linked ? ( + <> + + Linked + + ) : null} + + ); + }) + )} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx new file mode 100644 index 000000000..c9ef8974e --- /dev/null +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -0,0 +1,296 @@ +import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import { + resolveThreadPullRequestChains, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { + GitPullRequestArrow, + LayersIcon, + LinkIcon, + MoreHorizontalIcon, + PlusIcon, +} from "lucide-react"; +import { useCallback, useMemo } from "react"; + +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { cn } from "~/lib/utils"; +import { useServerConfigs, useThreadShell } from "~/state/entities"; +import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { ScrollArea } from "../ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { openLinkPullRequestDialog } from "./LinkPullRequestDialog"; +import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines"; +import { + PullRequestActorAvatar, + PullRequestDiffStat, + PullRequestStateGlyph, + pullRequestChecksStatePresentation, +} from "./pullRequestPresentation"; + +const SOURCE_LABELS: Record = { + manual: "Linked by you", + created: "Created from this thread", + agent: "Linked by the agent", + stack: "Found in the stack", + "stack-dismissed": "Dismissed", +}; + +function ChecksGlyph({ + state, +}: { + state: NonNullable["checksState"] & string; +}) { + const presentation = pullRequestChecksStatePresentation(state); + return ( + + }> + + + {presentation.label} + + ); +} + +function LinkRow({ + line, + threadRef, + onUnlink, +}: { + line: PullRequestListLine; + threadRef: ScopedThreadRef; + onUnlink: (link: ThreadPullRequestLink) => void; +}) { + const openPrLink = useOpenPrLink(threadRef); + const { link, depth, stack } = line; + const snapshot = link.snapshot; + return ( +
+ {depth > 0 ? : null} + {snapshot === null ? ( + + ) : ( + + )} + openPrLink(event, link.url, threadRef)} + className="min-w-0 flex-1" + > + + + + } + > + #{link.number} + + + {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)} + + + + {snapshot?.title ?? link.repository} + + {/* Right-aligned signals, in the order a reviewer scans them: are checks green, + has someone ruled, how big is it. Each is absent rather than neutral when the + host said nothing, so a row without them reads as unknown, not as fine. */} + + {snapshot?.checksState ? : null} + {snapshot?.state === "open" && + (snapshot.reviewDecision === "approved" || + snapshot.reviewDecision === "changes-requested") ? ( + + {snapshot.reviewDecision === "approved" ? "Approved" : "Changes requested"} + + ) : null} + {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? ( + Conflicts + ) : null} + + + + + {stack ? ( + + + } + > + {stack.kind === "native" ? ( + + ) : ( + + )} + {stack.size} + + + {stack.kind === "native" + ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.` + : `${stack.size} pull requests chained by base branch.`} + + + ) : null} + {snapshot?.author ? ( + + + {snapshot.author.login} + + ) : null} + + {snapshot !== null + ? `${snapshot.headBranch} → ${snapshot.baseBranch}` + : `${link.host}/${link.repository}`} + + {snapshot?.updatedAt ? ( + · {formatRelativeTimeLabel(snapshot.updatedAt)} + ) : null} + + + + + + + } + /> + + void writeTextToClipboard(link.url, "link")}>Copy link + openPrLink(event, link.url, threadRef)}>Open + onUnlink(link)}> + {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} + + + +
+ ); +} + +export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { + const configs = useServerConfigs(); + if (configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true) { + return ( + + ); + } + return ; +} + +function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { + const thread = useThreadShell(threadRef); + const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); + const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); + const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); + const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); + const handleUnlink = useCallback( + (link: ThreadPullRequestLink) => { + void unlink({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + host: link.host, + repository: link.repository, + number: link.number, + }, + }); + }, + [threadRef, unlink], + ); + const openCount = useMemo( + () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length, + [links], + ); + const lastSynced = useMemo(() => { + let latest: string | null = null; + for (const link of links) { + const at = link.snapshot?.syncedAt; + if (at !== undefined && (latest === null || at > latest)) latest = at; + } + return latest; + }, [links]); + + if (links.length === 0) { + return ( +
+ +

No linked pull requests

+

+ Pull requests the agent opens from this thread land here. Link one yourself from a URL or + a number. +

+ +
+ ); + } + + return ( +
+ +
+ {lines.map((line) => ( + + ))} +
+
+
+ + {openCount} open · {links.length} linked + {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""} + + +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 000c594ec..60884bbb7 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -35,6 +35,7 @@ import { readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, + allowsSinglePullRequestMerge, shouldRefreshPullRequestActivity, resolveBaseFreshness, resolvePullRequestMergeMethod, @@ -1420,6 +1421,42 @@ describe("cached pull request detail", () => { expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull(); }); + it("isolates stored and displayed details between hosts with the same repository and number", () => { + const storage = makeStorage(); + const publicRef = { ...reference, host: "github.com" }; + const enterpriseRef = { ...reference, host: "github.example.com" }; + const publicDetail = detail(); + const enterpriseDetail = detail({ + title: "Enterprise change", + url: "https://github.example.com/acme/web/pull/7", + }); + writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail); + expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); + writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, enterpriseDetail); + expect(readPullRequestDetailSnapshot(storage, "env-1", publicRef)?.title).toBe( + publicDetail.title, + ); + expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)?.title).toBe( + enterpriseDetail.title, + ); + expect( + resolveDisplayedPullRequestDetail({ + live: null, + cached: publicDetail, + reference: enterpriseRef, + }), + ).toBeNull(); + expect( + resolveDisplayedPullRequestDetail({ + live: null, + cached: enterpriseDetail, + reference: enterpriseRef, + }), + ).toBe(enterpriseDetail); + writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, publicDetail); + expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); + }); + it("shrugs off corrupt storage and no storage at all", () => { const storage = makeStorage(); storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); @@ -1427,3 +1464,21 @@ describe("cached pull request detail", () => { expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); }); }); + +describe("single-PR merge compatibility during stack discovery", () => { + it.each([ + [false, true, false, null, true], + [false, false, true, null, true], + [true, false, true, null, false], + [true, false, false, "Lookup failed", false], + [true, true, false, null, false], + [true, false, false, null, true], + ] as const)( + "capability=%s stack=%s pending=%s error=%s permits=%s", + (supportsStackActions, hasStack, stackPending, stackError, allowed) => { + expect( + allowsSinglePullRequestMerge({ supportsStackActions, hasStack, stackPending, stackError }), + ).toBe(allowed); + }, + ); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 24b37acea..647921648 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { PullRequestDetail, @@ -28,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record, current: PullRequestMergeMethod | null, @@ -1028,6 +1042,7 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo type SnapshotStorage = Pick; export interface PullRequestDetailSnapshotRef { + readonly host?: string | undefined; readonly projectId: string; readonly repository: string; readonly number: number; @@ -1037,7 +1052,9 @@ const pullRequestDetailSnapshotKey = ( environmentId: string, reference: PullRequestDetailSnapshotRef, ) => - `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; + reference.host + ? `t3.pullRequests.detail:${JSON.stringify([environmentId, reference.projectId, reference.host.toLowerCase(), reference.repository.toLowerCase(), reference.number])}` + : `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail); @@ -1056,7 +1073,9 @@ export function readPullRequestDetailSnapshot( const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)); if (!raw) return null; const decoded = decodeDetailSnapshot(JSON.parse(raw)); - return decoded._tag === "Some" ? decoded.value : null; + return decoded._tag === "Some" + ? resolveDisplayedPullRequestDetail({ live: null, cached: decoded.value, reference }) + : null; } catch { return null; } @@ -1090,7 +1109,9 @@ export function resolveDisplayedPullRequestDetail(input: { input.cached !== null && input.cached.projectId === input.reference.projectId && input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() && - input.cached.number === input.reference.number + input.cached.number === input.reference.number && + (input.reference.host === undefined || + parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase()) ) { return input.cached; } diff --git a/apps/web/src/components/pullRequest/pullRequestListLines.test.ts b/apps/web/src/components/pullRequest/pullRequestListLines.test.ts new file mode 100644 index 000000000..fc86c0a69 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListLines.test.ts @@ -0,0 +1,76 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import { resolveThreadPullRequestChains } from "@t3tools/shared/threadPullRequests"; +import { describe, expect, it } from "vite-plus/test"; + +import { pullRequestListLines } from "./pullRequestListLines"; + +function link( + number: number, + head: string, + base: string, + updatedAt: string, + stack: ThreadPullRequestLink["stack"] = null, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "acme/web", + number, + url: `https://github.com/acme/web/pull/${number}`, + source: "manual", + linkedAt: "2026-01-01T00:00:00.000Z", + snapshot: { + state: "open", + title: `PR ${number}`, + headBranch: head, + baseBranch: base, + isDraft: false, + updatedAt, + syncedAt: updatedAt, + }, + stack, + }; +} + +describe("pullRequestListLines", () => { + it("orders newest first and keeps a stack together under its base layer", () => { + const lines = pullRequestListLines( + resolveThreadPullRequestChains([ + link(1, "a", "main", "2026-01-01T10:00:00Z"), + link(2, "b", "a", "2026-01-01T12:00:00Z"), + link(9, "solo", "main", "2026-01-01T11:00:00Z"), + link(5, "old", "main", "2026-01-01T09:00:00Z"), + ]), + ); + expect(lines.map((line) => [line.link.number, line.depth, line.stack?.size ?? null])).toEqual([ + // The stack's newest layer is #2 at 12:00, so the whole stack outranks #9 at 11:00. + [1, 0, 2], + [2, 1, null], + [9, 0, null], + [5, 0, null], + ]); + }); + + it("marks native stacks on their base layer", () => { + const stack = { + kind: "native" as const, + id: "1", + number: 1, + url: "https://github.com/acme/web/stacks/1", + base: "main", + layers: [ + { number: 3, headBranch: "x", state: "open" as const }, + { number: 4, headBranch: "y", state: "open" as const }, + ], + }; + const lines = pullRequestListLines( + resolveThreadPullRequestChains([ + link(4, "y", "x", "2026-01-01T10:00:00Z", stack), + link(3, "x", "main", "2026-01-01T10:00:00Z", stack), + ]), + ); + expect(lines.map((line) => [line.link.number, line.depth, line.stack?.kind ?? null])).toEqual([ + [3, 0, "native"], + [4, 1, null], + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestListLines.ts b/apps/web/src/components/pullRequest/pullRequestListLines.ts new file mode 100644 index 000000000..6602b2f39 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListLines.ts @@ -0,0 +1,49 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ThreadPullRequestChain } from "@t3tools/shared/threadPullRequests"; + +/** One line of a thread's pull-request list: a link plus how deep it sits in its stack. */ +export interface PullRequestListLine { + readonly link: ThreadPullRequestLink; + /** 0 for a pull request on the base branch; each layer above steps in by one. */ + readonly depth: number; + /** Which chain the line belongs to, so callers can tell one stack's lines from another's. */ + readonly chainKey: string; + /** Set on the bottom layer of a multi-layer stack, so that row can name the whole stack. */ + readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; +} + +function activityAt(link: ThreadPullRequestLink): number { + const ms = Date.parse(link.snapshot?.updatedAt ?? link.linkedAt); + return Number.isNaN(ms) ? 0 : ms; +} + +function chainKeyOf(chain: ThreadPullRequestChain): string { + const bottom = chain.layers[0]!; + return `${bottom.host}/${bottom.repository}#${bottom.number}`; +} + +/** + * Flattens chains into indented lines, newest first. A stack sorts by its most recent layer and + * then reads bottom to top beneath that slot, so the layer you would review first is at the + * bottom of the indent and a fresh push anywhere in the stack floats the whole stack up. + */ +export function pullRequestListLines( + chains: ReadonlyArray, +): ReadonlyArray { + const ordered = [...chains].sort( + (left, right) => + Math.max(...right.layers.map(activityAt)) - Math.max(...left.layers.map(activityAt)), + ); + return ordered.flatMap((chain) => { + const chainKey = chainKeyOf(chain); + return chain.layers.map((link, depth) => ({ + link, + depth, + chainKey, + stack: + depth === 0 && chain.layers.length > 1 + ? { kind: chain.kind, size: chain.layers.length } + : null, + })); + }); +} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 3c41e0956..9cd334083 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -422,10 +422,8 @@ export function PullRequestDiffStat({ } return ( - - +{additions.toLocaleString()} - - -{deletions.toLocaleString()} + +{additions.toLocaleString()} + -{deletions.toLocaleString()} ); } diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts index abbcab162..8466903eb 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { ProjectId } from "@t3tools/contracts"; -import { type PendingReviewComment, usePullRequestReviewStore } from "./pullRequestReviewStore"; +import { + type PendingReviewComment, + pullRequestReviewKey, + usePullRequestReviewStore, +} from "./pullRequestReviewStore"; function comment(id: string, body = id): PendingReviewComment { return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } }; @@ -36,6 +41,30 @@ describe("pull request review drafts", () => { }); }); + it("keeps drafts on different hosts separate when a thread reviews the same repository and number", () => { + const reference = { + projectId: ProjectId.make("project-a"), + repository: "owner/repo", + number: 7, + }; + const publicKey = pullRequestReviewKey({ ...reference, host: "github.com" }); + const enterpriseKey = pullRequestReviewKey({ ...reference, host: "github.example.com" }); + const store = usePullRequestReviewStore.getState(); + store.addComment(publicKey, comment("public")); + store.setSummary(publicKey, "Public review"); + + expect(usePullRequestReviewStore.getState().drafts[enterpriseKey]).toBeUndefined(); + expect(usePullRequestReviewStore.getState().summaries[enterpriseKey]).toBeUndefined(); + + store.addComment(enterpriseKey, comment("enterprise")); + store.setSummary(enterpriseKey, "Enterprise review"); + store.clear(enterpriseKey); + store.clearSummary(enterpriseKey, "Enterprise review"); + + expect(usePullRequestReviewStore.getState().drafts[publicKey]).toEqual([comment("public")]); + expect(usePullRequestReviewStore.getState().summaries[publicKey]).toBe("Public review"); + }); + it("does not clear a summary revised while submission is in flight", () => { const store = usePullRequestReviewStore.getState(); store.setSummary("review-a", "Submitted body"); diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index 41906a710..fba8d4c56 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -6,7 +6,7 @@ * hosts that have no pending review of their own. That also means a draft lives only as long * as the tab does, which is why this is deliberately not persisted. */ -import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; +import type { PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; import { create } from "zustand"; export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; @@ -24,9 +24,14 @@ export function nextPendingReviewCommentId(): string { return `pending-review-comment-${pendingCommentSequence}`; } -/** One pull request's draft, scoped by project as well as repository: a repository can be checked out twice. */ +/** A project's thread can review the same repository path and number on different hosts. */ export function pullRequestReviewKey(reference: PullRequestRef): string { - return `${reference.projectId}/${reference.repository}#${reference.number}`; + return JSON.stringify([ + reference.projectId, + reference.host?.toLowerCase() ?? null, + reference.repository.toLowerCase(), + reference.number, + ]); } interface PullRequestReviewStoreState { @@ -81,11 +86,9 @@ export const usePullRequestReviewStore = create()(( })); /** The comments a pull request's draft holds, stable across renders while it is empty. */ -export function usePendingReviewComments(reference: { - readonly projectId: ProjectId; - readonly repository: string; - readonly number: number; -}): ReadonlyArray { +export function usePendingReviewComments( + reference: PullRequestRef, +): ReadonlyArray { return usePullRequestReviewStore( (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY, ); diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts new file mode 100644 index 000000000..621ba3124 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts @@ -0,0 +1,93 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { savedPullRequestStack, pullRequestStackView } from "./pullRequestStackSnapshot"; + +const reference = { + projectId: ProjectId.make("project"), + host: "github.com", + repository: "acme/web", + number: 2, +}; +const link: ThreadPullRequestLink = { + host: "github.com", + repository: "acme/web", + number: 2, + url: "https://github.com/acme/web/pull/2", + source: "manual", + linkedAt: "2026-09-09T10:00:00Z", + snapshot: { + title: "Top layer", + headBranch: "top", + baseBranch: "bottom", + state: "open", + isDraft: false, + updatedAt: null, + syncedAt: "2026-09-09T10:00:00Z", + }, + stack: { + kind: "native", + id: "stack", + number: 3, + url: "https://github.com/acme/web/pull/3", + base: "main", + layers: [ + { number: 1, headBranch: "bottom", state: "open" }, + { number: 2, headBranch: "top", state: "open" }, + ], + }, +}; + +describe("saved stack navigation", () => { + it("preserves all native layers even when only one has a linked snapshot, without action SHAs", () => { + const saved = savedPullRequestStack([link], reference); + expect(saved?.layers).toEqual([ + { number: 1, headBranch: "bottom", state: "open" }, + { number: 2, headBranch: "top", state: "open", title: "Top layer", isDraft: false }, + ]); + expect(savedPullRequestStack([link], { ...reference, number: 1 })?.number).toBe(3); + }); + it("does not borrow stacks across hosts or repositories", () => { + expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull(); + expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull(); + expect(savedPullRequestStack([link], { ...reference, host: undefined })).toBeNull(); + }); + it("honors a newer saved removal across linked threads", () => { + expect( + savedPullRequestStack( + [ + link, + { + ...link, + stack: null, + snapshot: { ...link.snapshot!, syncedAt: "2026-09-09T11:00:00Z" }, + }, + ], + reference, + ), + ).toBeNull(); + }); + it("shows saved data during loading and marks a failed refresh stale", () => { + const saved = savedPullRequestStack([link], reference); + const query = { data: null, isSuccess: false, isPending: true, error: null }; + expect(pullRequestStackView(query, saved)).toMatchObject({ + data: saved, + isFresh: false, + notice: expect.stringContaining("Refreshing"), + }); + expect( + pullRequestStackView({ ...query, isPending: false, error: "Rate limited" }, saved), + ).toMatchObject({ data: saved, isFresh: false, notice: expect.stringContaining("stale") }); + }); + it("prefers refreshed data and honors a successful absence", () => { + const saved = savedPullRequestStack([link], reference); + const query = { data: saved, isSuccess: true, isPending: false, error: null }; + expect(pullRequestStackView(query, null)).toEqual({ data: saved, isFresh: true, notice: null }); + expect(pullRequestStackView({ ...query, data: null }, saved)).toEqual({ + data: null, + isFresh: true, + notice: null, + }); + expect(pullRequestStackView({ ...query, isPending: true }, saved).isFresh).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts new file mode 100644 index 000000000..4e48a2f37 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts @@ -0,0 +1,73 @@ +import type { PullRequestRef, PullRequestStack, ThreadPullRequestLink } from "@t3tools/contracts"; + +/** Saved native membership is enough for navigation, but never supplies action head SHAs. */ +export function savedPullRequestStack( + links: ReadonlyArray, + reference: PullRequestRef, +): PullRequestStack | null { + const host = reference.host?.toLowerCase(); + if (!host) return null; + const matching = links.filter( + (link) => + link.host.toLowerCase() === host && + link.repository.toLowerCase() === reference.repository.toLowerCase(), + ); + const exact = matching.filter((link) => link.number === reference.number); + const candidates = + exact.length > 0 + ? exact + : matching.filter((link) => + link.stack?.layers.some((layer) => layer.number === reference.number), + ); + const newest = candidates.toSorted( + (a, b) => + Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - + Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), + )[0]; + const stack = newest?.stack; + if (!stack || !stack.layers.some((layer) => layer.number === reference.number)) return null; + return { + id: stack.id, + number: stack.number, + url: stack.url, + base: stack.base, + layers: stack.layers.map((layer) => { + const snapshot = matching + .filter((link) => link.number === layer.number) + .toSorted( + (a, b) => + Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - + Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), + )[0]?.snapshot; + return { + ...layer, + ...(snapshot ? { title: snapshot.title, isDraft: snapshot.isDraft } : {}), + }; + }), + }; +} + +/** A fresh absence overrides saved membership; failed refreshes preserve available navigation. */ +export function pullRequestStackView( + query: { + data: PullRequestStack | null; + isSuccess: boolean; + isPending: boolean; + error: string | null; + }, + saved: PullRequestStack | null, +) { + const data = query.isSuccess ? query.data : (query.data ?? saved); + return { + data, + isFresh: query.isSuccess && !query.isPending, + notice: + data === null + ? null + : query.error + ? "Stack data may be stale. We couldn’t refresh it." + : !query.isSuccess || query.isPending + ? "Refreshing stack… Showing saved data." + : null, + }; +} diff --git a/apps/web/src/components/settings/ProviderModelsSection.test.ts b/apps/web/src/components/settings/ProviderModelsSection.test.ts index 57a12e6a9..cc39cfd43 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.test.ts +++ b/apps/web/src/components/settings/ProviderModelsSection.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; -import { groupModelsForDisplay } from "./ProviderModelsSection"; +import { groupModelsForDisplay, nextHiddenModelsForBulkToggle } from "./ProviderModelsSection"; function model(slug: string, isCustom = false): ServerProviderModel { return { slug, name: slug, isCustom, capabilities: null }; @@ -46,3 +46,20 @@ describe("groupModelsForDisplay", () => { expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]); }); }); + +describe("nextHiddenModelsForBulkToggle", () => { + it("hides every built-in model without hiding custom models", () => { + const models = [model("a"), model("b"), model("custom", true)]; + + expect(nextHiddenModelsForBulkToggle(models, ["a"])).toEqual(["a", "b"]); + }); + + it("shows every built-in model while preserving unrelated hidden entries", () => { + const models = [model("a"), model("b"), model("custom", true)]; + + expect(nextHiddenModelsForBulkToggle(models, ["a", "b", "legacy", "custom"])).toEqual([ + "legacy", + "custom", + ]); + }); +}); diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index db676de5a..dc87f0078 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -106,6 +106,21 @@ export function groupModelsForDisplay< ]; } +export function nextHiddenModelsForBulkToggle( + models: ReadonlyArray>, + hiddenModels: ReadonlyArray, +): string[] { + const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug); + const builtInSlugSet = new Set(builtInSlugs); + const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug)); + + if (allBuiltInModelsHidden) { + return hiddenModels.filter((slug) => !builtInSlugSet.has(slug)); + } + + return [...new Set([...hiddenModels, ...builtInSlugs])]; +} + interface ProviderModelsSectionProps { /** Identifier used to namespace input ids within the DOM. */ readonly instanceId: ProviderInstanceId; @@ -195,6 +210,8 @@ export function ProviderModelsSection({ (model) => !model.isCustom && hiddenModelSet.has(model.slug), ).length; const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]); + const allBuiltInModelsHidden = + builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug)); const showFilter = models.length > FILTER_THRESHOLD; const normalizedFilter = filter.trim().toLowerCase(); const isFiltering = showFilter && normalizedFilter.length > 0; @@ -519,11 +536,27 @@ export function ProviderModelsSection({ aria-label="Filter models" /> ) : null} - - {models.length} model{models.length === 1 ? "" : "s"} - {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""} - {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} - +
+ {builtInModels.length > 0 ? ( + + ) : null} + + {models.length} model{models.length === 1 ? "" : "s"} + {favoriteCount > 0 + ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` + : ""} + {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} + +
void) { ? ["Contrast"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), + ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme + ? ["Diff colors"] + : []), ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ["Panel animations"] : []), @@ -611,6 +614,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, + settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, @@ -716,6 +720,7 @@ export function useSettingsRestore(onRestored?: () => void) { } updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, + diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, @@ -1307,6 +1312,52 @@ export function AppearanceSettingsPanel() { } /> + + updateSettings({ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme }) + } + /> + ) : null + } + control={ +
+ +
+ } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0d4d99f55..cc7775586 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -132,6 +132,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["transparent transparency solid menus dialogs composer"], }, + { + id: "diff-color-scheme", + title: "Diff colors", + to: "/settings/appearance", + searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"], + }, { id: "panel-animations", title: "Panel animations", diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0e688db83..4657a0b9a 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -23,6 +23,7 @@ const buttonVariants = cva( "icon-lg": "size-10 sm:size-9", "icon-micro": "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", + "icon-tiny": "size-4 p-0 [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 sm:size-7", "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", @@ -85,3 +86,23 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) { } export { Button, buttonVariants }; + +/** An inline action that keeps the geometry of surrounding text or a graph node. */ +export function InlineButton({ + className, + underline = false, + ...props +}: React.ComponentProps<"button"> & { underline?: boolean }) { + return ( +