diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index b4e545fade71..1a4f11b8c7e0 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 7d32c70234d1..4da3a97c2bf6 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/GlassBackdrop.tsx b/apps/mobile/src/components/GlassBackdrop.tsx new file mode 100644 index 000000000000..f18ee7908db6 --- /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 577c43aa890a..014a760588ab 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 697d13e7c472..474bd4481ca8 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 c5a9f2c6bbcb..eb1722c73fde 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 e59531fe7ca9..0f7b14bf9365 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 d7a9d38ebe1e..895c73bbe266 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, @@ -311,6 +312,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 ad29e0f32a1a..585f55a1265c 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 bca54694bd7b..bc2e6fc3c64a 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 ? ( { @@ -839,17 +841,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleFeedTouchCancel = useCallback(() => { feedTouchStartRef.current = null; }, []); + const feedBlurTarget = useRef(null); return ( {showContent ? ( - + - + ) : ( )} @@ -1003,41 +1015,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 c4f8eea9275a..8874f77bdc28 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2866,13 +2866,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 5aefccb4baff..881f461f29c6 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 1e3094bd071f..0ec50c674451 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -56,6 +56,7 @@ function pullRequestTintColor( return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; + case null: case "closed": return dark ? "#a1a1aa" : "#71717a"; } @@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {pr !== null ? ( - - + + {pr.kind === "stack" ? ( + + ) : ( + + )} )} {pr ? ( - - #{pr.label} - + + {pr.kind === "stack" ? ( + + ) : null} + + {pr.kind === "stack" ? 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 000000000000..4e6f7d3e3e88 --- /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 38ce75f24b08..46a79b70acd2 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -258,6 +258,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 0ab9986b2f40..7f0241f949c3 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 100391cbfe10..5532d6e2c4fb 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 fd7a171810ee..dd21e2d70ec4 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -1,11 +1,22 @@ -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 isDraft: boolean; /** Provider-side last activity, bounding when a terminal state landed. */ readonly updatedAt: string | null; @@ -30,6 +41,7 @@ export function presentThreadPr( const presentation = resolveChangeRequestPresentation(provider); const isDraft = pr.state === "open" && pr.isDraft === true; return { + kind: "pull-request", number: pr.number, state: pr.state, isDraft, @@ -40,3 +52,53 @@ 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 label = + badge.kind === "stack" + ? String(badge.layers) + : `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`; + return { + kind: badge.kind, + 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: 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 f320e9da710d..e66f690428e8 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 f6fddfdc5578..330e31cb0171 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,142 @@ 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: "1 +1", + }); + }); + + 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 e824ce56217b..c903abbb07f2 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 85f7fb3c3c92..d922eb7f6d5c 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 ae217a3aabe7..4d8a384ee997 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -67,6 +67,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"; @@ -404,6 +405,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 a069322aa8bf..a63b07f0adef 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -68,6 +68,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 a674a25c1ec8..a12a8242b0d2 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).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 bf02cd90fbdf..2aab17b27a76 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -230,6 +230,8 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadTitleRegeneration: 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 000000000000..2c33d82792bd --- /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 000000000000..4eaeea1dc6f9 --- /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)) 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 133e71bc3bcb..885629930706 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 3f3e48ebe4b2..3556a57befdc 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -28,6 +28,8 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; +import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -437,6 +439,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe( + Layer.provide(PullRequestsToolkitHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -444,4 +450,7 @@ const McpTransportLive = McpServer.layerHttp({ protocols: [McpProtocol.v2025_06_18], }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); +export const layer = Layer.mergeAll( + PreviewToolkitRegistrationLive, + PullRequestsToolkitRegistrationLive, +).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 569917325bef..123944206e37 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 49273485a44d..0c0a0ab68ae9 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, @@ -7,7 +8,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview"; +export type McpCapability = "preview" | "pull-requests"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; @@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} -export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( +/** 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, -) { - 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; -}); +): 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/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts index d5dc582046c1..61c3ac1e0b20 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -7,6 +7,8 @@ export interface McpProviderSessionConfig { readonly providerInstanceId: ProviderInstanceId; readonly endpoint: string; readonly authorizationHeader: string; + /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */ + readonly preview: boolean; } const sessionsByThread = new Map(); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 1d8aead99d0d..2e9749a04062 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -39,6 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, }); expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -54,6 +55,29 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); +it.effect("always grants pull-requests and gates preview on the request", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const withPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, + }); + const withoutPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-no-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + preview: false, + }); + const capabilitiesOf = (issued: typeof withPreview) => + registry + .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, "")) + .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort())); + + expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]); + expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]); + }), +); + it.effect("builds MCP endpoints from the bound server host", () => Effect.gen(function* () { const cases = [ @@ -68,6 +92,7 @@ it.effect("builds MCP endpoints from the bound server host", () => const issued = yield* registry.issue({ threadId: ThreadId.make(`thread-${hostname}`), providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, }); expect(issued.config.endpoint).toBe(expectedEndpoint); } @@ -81,6 +106,7 @@ it.effect("expires credentials once their session stops showing signs of life", const issued = yield* registry.issue({ threadId: ThreadId.make("thread-2"), providerInstanceId: ProviderInstanceId.make("claude"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); timestamp += 101; @@ -96,6 +122,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("claude"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -117,6 +144,7 @@ it.effect("does not keep credentials of other threads alive", () => const issued = yield* registry.issue({ threadId: ThreadId.make("thread-4"), providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index f19a4f4e8c49..130f6dce582b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -14,6 +14,11 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + /** + * Whether the credential may drive the user's browser. The pull request + * toolkit is always granted: it only touches the thread's own links. + */ + readonly preview: boolean; } export interface McpIssuedCredential { @@ -68,7 +73,7 @@ export interface McpSessionRegistryOptions { * * The bound matters because `/mcp` is mounted outside the environment auth * stack and is reachable on whatever host the server binds to, so this token is - * the only thing guarding the preview toolkit on a remote-reachable server. + * the only thing guarding the `t3-code` toolkits on a remote-reachable server. */ const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000; @@ -128,7 +133,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), + capabilities: new Set( + request.preview ? ["pull-requests", "preview"] : ["pull-requests"], + ), issuedAt, }; yield* SynchronizedRef.update(state, ({ records }) => { @@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( providerInstanceId: scope.providerInstanceId, endpoint, authorizationHeader: `Bearer ${rawToken}`, + preview: request.preview, }, }; }, 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 000000000000..18062a216bb4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -0,0 +1,418 @@ +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("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 000000000000..1106bf435327 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -0,0 +1,243 @@ +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)) { + return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); + } + return thread.value; + }); + + const projectOf = ( + thread: OrchestrationThreadShell, + 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 000000000000..e0ab5561a3a1 --- /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 T3 Code 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/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2cb4ed7566ad..3929136f90fc 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -60,7 +60,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; @@ -78,15 +81,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.provide(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 { @@ -373,6 +388,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", @@ -1009,7 +1025,20 @@ 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", + }, + }), + }); try { const projectId = ProjectId.make("pr-race-project"); const threadId = ThreadId.make("pr-race-thread"); @@ -1058,6 +1087,7 @@ describe("OrchestrationEngine", () => { linkedPullRequest: previous, }), ); + expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous); const metadataChanges = { unlink: { linkedPullRequest: null }, relink: { diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index dd76721defa0..5fcc32a34fba 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -10,6 +10,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"; @@ -84,6 +85,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, @@ -107,6 +118,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 a86907b0d78b..ff632240d3e7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -10,6 +10,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"; @@ -19,6 +20,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; @@ -29,6 +31,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* threadDeletionReactor.start(); yield* threadPullRequestReactor.start(); yield* threadSettlementReactor.start(); + yield* pullRequestSyncReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 29ecd516441e..a038a5662169 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, @@ -686,6 +687,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 e4638a329b6c..e303e7323729 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, @@ -480,6 +485,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; @@ -599,6 +606,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, @@ -820,6 +831,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; } @@ -866,6 +965,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, }); @@ -2068,6 +2171,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 e262bce34aaf..5849123c55d6 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); @@ -331,12 +403,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, latestTurn: { turnId: asTurnId("turn-1"), @@ -461,12 +528,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, latestTurn: { turnId: asTurnId("turn-1"), @@ -516,13 +578,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` @@ -3259,3 +3335,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 5f82a26e2a36..066c60760ca5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -29,7 +29,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"; @@ -54,6 +58,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 { @@ -112,6 +117,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({ modelSelection: Schema.fromJsonString(ModelSelection), @@ -403,6 +414,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, + identity?: OrchestrationProject["repositoryIdentity"], +): Pick { + const linkedPullRequest = legacyLinkedPullRequestOf(pullRequests, projectId, identity); + return { + pullRequests, + ...(linkedPullRequest === null ? {} : { linkedPullRequest }), + }; +} + function mapThreadActivityRow( row: Schema.Schema.Type, ): OrchestrationThreadActivity { @@ -649,6 +703,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, @@ -1208,6 +1330,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, @@ -1852,6 +1995,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( @@ -1901,6 +2052,7 @@ pending_approval_requests AS ( threadRows, messageRows, proposedPlanRows, + pullRequestRows, activityRows, sessionRows, checkpointRows, @@ -1910,6 +2062,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 sessionsByThread = new Map(); @@ -2071,10 +2224,12 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2146,6 +2301,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( @@ -2174,8 +2337,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[] = []; @@ -2190,6 +2370,7 @@ pending_approval_requests AS ( id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, @@ -2252,6 +2433,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) { @@ -2286,10 +2468,12 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2356,6 +2540,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( @@ -2375,99 +2567,104 @@ pending_approval_requests AS ( ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); + const repositoryIdentities = + yield* resolveRepositoryIdentitiesForProjects(projectRows); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(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), - ) - : Result.failVoid, - ), - threads: Arr.filterMap(threadRows, (row) => - row.deletedAt === null - ? Result.succeed({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - activeOrderKey: row.activeOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - } satisfies OrchestrationThreadShell) - : Result.failVoid, - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, + ), + threads: Arr.filterMap(threadRows, (row) => + row.deletedAt === null + ? Result.succeed({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + } satisfies OrchestrationThreadShell) + : Result.failVoid, + ), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2505,6 +2702,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( @@ -2524,98 +2729,102 @@ pending_approval_requests AS ( ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( - projectRows.filter((row) => activeProjectIds.has(row.projectId)), - ); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null && activeProjectIds.has(row.projectId) - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: threadRows.map((row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - activeOrderKey: row.activeOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null && activeProjectIds.has(row.projectId) + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - })), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + threads: threadRows.map((row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + })), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2861,7 +3070,7 @@ pending_approval_requests AS ( const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { - const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ + const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2886,6 +3095,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query", + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows", + ), + ), + ), ]); if (Option.isNone(threadRow)) { @@ -2901,10 +3118,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 + ? 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, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -3112,6 +3334,7 @@ pending_approval_requests AS ( threadRow, messageRows, proposedPlanRows, + pullRequestRows, activities, checkpointRows, latestTurnRow, @@ -3144,6 +3367,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( @@ -3184,10 +3415,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 + ? 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, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts new file mode 100644 index 000000000000..414f76f32496 --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -0,0 +1,705 @@ +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("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 000000000000..0a39fa5d91d1 --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -0,0 +1,328 @@ +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 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) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + const key = threadPullRequestKeyOf(link); + const entries = groups.get(key) ?? []; + entries.push({ thread, 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 7e866cf89592..29468dc3f84e 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 f9872e6dd22a..d6572083dad2 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 b694bacbbb33..86026632a6af 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) => 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), @@ -138,7 +133,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)); @@ -189,6 +184,7 @@ export const make = Effect.gen(function* () { let replacement: ThreadLinkedPullRequest | undefined; if ( + thread.pullRequests.length === 0 && thread.linkedPullRequest != null && detected?.state === "open" && detectedReference !== null && @@ -334,7 +330,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 61c512e6fd91..252b99439400 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 1df7855d1e04..92063745eff5 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 dbb3c5f9f755..0690eea2d50e 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, @@ -301,6 +302,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 d925df5d98f1..61fc5d4ab863 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -103,7 +103,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 93777c67d3e1..a3db108f2020 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 58a7f5c054ec..39bccba11460 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 4ad00ba994b4..7d6f55d8f622 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 000000000000..49bfd4bea4de --- /dev/null +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -0,0 +1,532 @@ +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("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 6c3cdc62e7de..53e87d7593cc 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 abc5cff37e3f..bcb2408c21e1 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 1012240b18a0..505bb79df034 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 b29c8ffda676..c032f33d0d01 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 304defb80c89..c0787a18d096 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"; @@ -121,6 +129,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"]; @@ -876,6 +891,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 branch = command.branch !== undefined && command.expectedBranch !== undefined && @@ -920,6 +1009,138 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pull-request.link": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + 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 5c7b495bc245..a61da0ca78b3 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 000000000000..6876cdde4f0c --- /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 e973b523275f..39aaacd8739d 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -86,6 +86,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], branchPullRequest: null, latestTurn: null, createdAt: now, @@ -117,7 +118,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 c048247f4128..02435ea5ba44 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, @@ -6,6 +14,11 @@ import { OrchestrationSession, OrchestrationThread, } 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"; @@ -28,6 +41,9 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadPullRequestLinkedPayload, + ThreadPullRequestSyncedPayload, + ThreadPullRequestUnlinkedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -101,6 +117,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, @@ -336,6 +423,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + pullRequests: [], branchPullRequest: null, latestTurn: null, createdAt: payload.createdAt, @@ -498,30 +586,129 @@ 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 } : {}), - ...(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 } + : {}), + ...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/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 68ceb8573b75..b7b2fdefba2f 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, ), @@ -529,4 +535,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.ts b/apps/server/src/persistence/Migrations.ts index bc176f62cc3c..a728e6e6e22e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps. import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; +import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; /** * Migration loader with all migrations defined inline. @@ -122,6 +123,7 @@ const migrationEntries = [ [47, "ProjectionProjectIcon", Migration0047], [48, "ProjectionThreadBranchPullRequest", Migration0048], [49, "ProjectionThreadsActiveOrderKey", Migration0049], + [50, "ProjectionThreadPullRequests", Migration0050], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts new file mode 100644 index 000000000000..61d9f0618e9c --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts @@ -0,0 +1,181 @@ +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("050_ProjectionThreadPullRequests", (it) => { + it.effect("creates the link table and backfills legacy single links", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 49 }); + + 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' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 50 }); + + 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")); + }), + ); +}); + +it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("050 Azure legacy links", (it) => { + it.effect("keeps legacy Azure repositories distinct across organizations", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 49 }); + 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: 50 }); + 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/050_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts new file mode 100644 index 000000000000..2a53307c8901 --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_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 000000000000..9f41993bd679 --- /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 38d6ad1d4331..4eb03a5cc036 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -124,6 +124,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/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 288db92799bc..5422a8730f27 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,3 +1,4 @@ +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; @@ -392,8 +393,7 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(createInput?.options.systemPrompt, { type: "preset", preset: "claude_code", - append: - "In case you're asked: you are running in T3 Code through the Claude Code harness. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.", + append: buildRuntimeInstructions({ harness: "Claude Code" }), }); assert.equal(createInput?.options.permissionMode, "bypassPermissions"); assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2d88e58dc1fb..5c3d1f08e2d0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2283,6 +2283,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( "-c", 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', ], + browserToolsAvailable: mcpSession.preview, } : {}), }; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 78f4b9aa8e50..2ec77ecb7000 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + /** + * Whether the attached `t3-code` MCP server exposes the preview tools. The + * server is attached for every session now (the pull request toolkit is + * always on), so its presence in `appServerArgs` no longer implies browser + * access; the credential's own capability decides the developer prompt. + */ + readonly browserToolsAvailable?: boolean; } export interface CodexSessionRuntimeSendTurnInput { @@ -2349,10 +2356,12 @@ export const makeCodexSessionRuntime = ( ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - // Derived from the session's own MCP configuration rather than the + // Derived from the session's own credential rather than the // setting, so the prompt describes the tools this turn actually // has even if the setting changed after the session started. - browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs), + browserToolsAvailable: + hasConfiguredMcpServer(options.appServerArgs) && + (options.browserToolsAvailable ?? true), }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e7647866d604..1ce1a396796f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4797,7 +4797,6 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); describe("agent browser access", () => { - const revokedThreads: Array = []; const projectId = ProjectId.make("project-browser-access"); const startSessionWith = ( @@ -4806,7 +4805,7 @@ describe("agent browser access", () => { projectOverride?: boolean, ) => Effect.gen(function* () { - const issued: Array = []; + const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = []; const codex = makeFakeCodexAdapter(); const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, @@ -4865,10 +4864,9 @@ describe("agent browser access", () => { const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { - issued.push(request.threadId); + issued.push({ threadId: request.threadId, preview: request.preview }); return undefined; }), - revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)), }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), @@ -4903,48 +4901,34 @@ describe("agent browser access", () => { return issued; }); - // Credential issuance is the observable that matters: it is the only place a - // credential is minted, and `/mcp` accepts nothing else, so withholding it is - // what actually denies every provider and external MCP client. - it.effect("requests no MCP credential when agent browser access is off", () => + // The capability on the credential is the observable that matters: a session + // always gets a credential (the pull request toolkit is never withheld), and + // `preview` on it is what actually grants or denies the browser tools. + it.effect("issues a credential without preview when agent browser access is off", () => Effect.gen(function* () { - const issued = yield* startSessionWith(false, asThreadId("thread-browser-off")); + const threadId = asThreadId("thread-browser-off"); - assert.deepEqual(issued, []); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("revokes an already-issued credential when access is off", () => - Effect.gen(function* () { - const threadId = asThreadId("thread-browser-revoke"); - revokedThreads.length = 0; - - yield* startSessionWith(false, threadId); + const issued = yield* startSessionWith(false, threadId); - // Clearing the in-memory map is not enough: a token issued before the - // toggle flipped stays valid against `/mcp` for its whole liveness - // window, and later turns refresh it. - assert.deepEqual(revokedThreads, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: false }]); }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("requests an MCP credential when agent browser access is on", () => + it.effect("issues a credential with preview when agent browser access is on", () => Effect.gen(function* () { const threadId = asThreadId("thread-browser-on"); const issued = yield* startSessionWith(true, threadId); - assert.deepEqual(issued, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: true }]); }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("withholds and revokes MCP credentials when the project disables browser access", () => + it.effect("issues a credential without preview when the project disables browser access", () => Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-off"); - revokedThreads.length = 0; const issued = yield* startSessionWith(true, threadId, false); - assert.deepEqual(issued, []); - assert.deepEqual(revokedThreads, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: false }]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -4952,7 +4936,7 @@ describe("agent browser access", () => { Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-on"); const issued = yield* startSessionWith(false, threadId, true); - assert.deepEqual(issued, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: true }]); }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 016bcd5c4a28..5b9059bfa643 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -249,8 +249,6 @@ export interface ProviderServiceLiveOptions { * test see whether a credential was requested at all. */ readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; - /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */ - readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread; } interface TurnAnalyticsMetadata { @@ -479,8 +477,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; - const revokeMcpCredential = - options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; const fileSystem = yield* FileSystem.FileSystem; const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); @@ -853,14 +849,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* recordCompletedTurnProperties(properties); }); /** - * Attach the `t3-code` MCP server to the session that is about to start. + * Whether the credential minted below may drive the user's browser. * - * This is the only place a credential is minted, so withholding one here is - * what disables agent browser access everywhere: every adapter already - * treats a missing session as "no MCP server", and the `/mcp` endpoint - * accepts nothing but tokens issued from this path. - */ - /** * Deny on an unreadable settings file rather than letting the read failure * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen * a union every caller handles, for a branch that only decides whether one @@ -889,20 +879,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); + /** + * Attach the `t3-code` MCP server to the session that is about to start. + * + * Every session gets a credential: the pull request toolkit is always on, + * since it only registers links on the session's own thread. Browser access + * is a capability on that credential, so turning the setting off withholds + * the preview tools without taking the server away. `issueActiveMcpCredential` + * revokes the thread's previous token first, which matters because a session + * restart (runtime mode, cwd, model) re-prepares without stopping. + */ const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled(threadId))) { - // Revoke as well as clear. Every other prepare path reaches - // `issueActiveMcpCredential`, which revokes the thread first, so - // skipping it here would leave a previously issued bearer token valid - // against `/mcp` for the rest of its liveness window — and later turns - // would keep refreshing it. A session restart (runtime mode, cwd, - // model) re-prepares without stopping, so it relies on this. - yield* revokeMcpCredential(threadId); - yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)); - return undefined; - } - const credential = yield* issueMcpCredential({ threadId, providerInstanceId }); + const preview = yield* agentBrowserAccessEnabled(threadId); + const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview }); if (credential) { yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)); } diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3ab2b4618a9e..2538e05e37dc 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 320aa332c937..863a87cde6b2 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from "vite-plus/test"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { + 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({ @@ -17,4 +25,10 @@ describe("buildRuntimeInstructions", () => { expect(instructions).toContain("through the Cursor harness."); expect(instructions).not.toContain("reasoning effort"); }); + + it("tells the agent how to nest fenced code inside a code block", () => { + expect(buildRuntimeInstructions({ harness: "Claude Code" })).toContain( + "must be fenced with a longer fence (four backticks or a ~~~ fence)", + ); + }); }); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 102c49c78a9e..68314fcf0ef5 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 T3 Code 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 T3 Code 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. Responses render as CommonMark: a code block that itself contains triple backticks must be fenced with a longer fence (four backticks or a ~~~ fence), otherwise the inner closing fence ends the block early.\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 f61b7c3233f6..6ef485627afb 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 6152df75fb73..5e1950a6eeb0 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 8fd0f09dd3ea..fe5032d70d5c 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 e75ef3547c04..66a247efb90e 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 a021db91007e..2309268bba68 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 000000000000..f94ff3cf586d --- /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 000000000000..62d1cffc3c83 --- /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 eb2f913c9206..3117c0072a78 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 2229a4f652c0..37716da44005 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 f6f5957f5875..f9f6c4ce3d26 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 7887a81617d6..42fa0ba0e04f 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 000000000000..4ed254514ee0 --- /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 000000000000..6e72836e6e40 --- /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 000000000000..08237d0e3ef4 --- /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 000000000000..26c751f0b817 --- /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 000000000000..2b21e20d15fc --- /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 000000000000..334d48762f69 --- /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 59049500729d..1e7cdc3f00e6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -334,6 +334,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -477,6 +478,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: { turnId: "turn-1" as TurnId, state: "running", @@ -668,6 +670,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 2f32b6524d7b..01f2dd96b18f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -117,6 +117,7 @@ import { } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.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"; @@ -331,6 +332,7 @@ const makeDefaultOrchestrationReadModel = () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -361,6 +363,7 @@ const makeDefaultOrchestrationThreadShell = ( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -384,7 +387,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)({ @@ -956,6 +959,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( @@ -8104,6 +8112,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 fd8ee4a4f699..3831763a3eac 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; 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"; @@ -92,6 +93,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"; @@ -280,6 +282,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), 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), @@ -316,6 +319,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 740be1330817..5e4ca0a18db9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -70,6 +70,7 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + type PullRequestRef, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -124,6 +125,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"; @@ -142,6 +144,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"; @@ -476,7 +482,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; const threadDeletionReactor = yield* ThreadDeletionReactor; const analytics = yield* AnalyticsService.AnalyticsService; @@ -604,6 +621,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; @@ -2133,6 +2151,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", @@ -2156,9 +2192,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", @@ -2196,9 +2244,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, @@ -2507,8 +2569,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)), ), }), @@ -2932,6 +3011,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), }); const pullRequests = yield* PullRequestService.PullRequestService; + const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", "/ws", @@ -2966,6 +3046,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( previewAutomationBroker, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..7bf122c46817 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 e9b5360cbc45..4bb5f0aefd89 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 2c4a1fa7af6e..d21960a28e34 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 02c61d8e8207..192abaf385f7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -26,7 +27,7 @@ import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill, - ThreadLinkedPullRequest, + ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { @@ -155,7 +156,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, @@ -164,7 +164,6 @@ import { } from "../workspaceBasenameLookup"; import { findProjectForChangeRequest, - matchesLinkedPullRequestUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, useOpenChangeRequestLink, @@ -2185,9 +2184,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( @@ -2239,9 +2236,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); @@ -2331,52 +2325,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) => { @@ -2588,6 +2563,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2614,6 +2590,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2732,6 +2709,7 @@ const CHAT_MARKDOWN_COMPONENTS = { linkTargetPreference, openExternalLinkInPreview, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, serverConfig, updateThreadPullRequestLink, @@ -2866,13 +2844,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 17d9c04b06cc..344eca250ce9 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 821044f2e774..06fcce22ee3f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -726,6 +726,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], activities: [], checkpoints: [], + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -843,6 +844,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 950a9c73fa91..66214df385e8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -353,6 +353,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 0fbef88c81e1..f1b5afba94e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -203,6 +203,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; +import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; +import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { deriveAgentPanelModel, foldSubagentActivities, @@ -577,6 +579,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])', @@ -4127,6 +4130,12 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().open(activeThreadRef, "agents"); }, [activeThreadRef]); + const supportsThreadPullRequests = + serverConfig?.environment.capabilities.threadPullRequests === true; + const addPullRequestsSurface = useCallback(() => { + if (!activeThreadRef || !supportsThreadPullRequests) return; + useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); + }, [activeThreadRef, supportsThreadPullRequests]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -5375,9 +5384,16 @@ export default function ChatView(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; @@ -8037,11 +8053,21 @@ export default function ChatView(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, }} @@ -8062,7 +8088,14 @@ export default function ChatView(props: ChatViewProps) { : "page" } composerDraftTarget={composerDraftTarget} + onBack={ + activeThreadRef !== null && supportsThreadPullRequests + ? addPullRequestsSurface + : undefined + } /> + ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "agents" ? ( @@ -8670,12 +8705,14 @@ export default function ChatView(props: ChatViewProps) { onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} + onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} + pullRequestsAvailable={isServerThread && supportsThreadPullRequests} agentsAvailable liveAgentCount={agentPanelModel.liveCount} > @@ -8684,6 +8721,7 @@ export default function ChatView(props: ChatViewProps) { ) : null} + {expandedImage && ( { + 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; @@ -47,6 +90,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", @@ -149,6 +218,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; @@ -507,3 +577,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 e91de65d5ca9..95313ee940e6 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": @@ -186,6 +226,7 @@ export type BuildThreadActionItemsThread = Pick< | "title" | "worktreePath" > & { + pullRequests?: SidebarThreadSummary["pullRequests"]; updatedAt: string; latestUserMessageAt?: string | null; }; @@ -240,6 +281,7 @@ export function buildThreadActionItems location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); + const [linkedThreadSearch, setLinkedThreadSearch] = useState( + openIntent?.kind === "search" ? openIntent : null, + ); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); @@ -605,12 +619,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, }); @@ -688,6 +706,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 projectGroupingSettings = useMemo( @@ -1489,6 +1508,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; @@ -1591,6 +1622,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", @@ -1768,7 +1828,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 handleAddProjectForEnvironment = useCallback( @@ -1974,6 +2047,7 @@ function OpenCommandPaletteDialog(props: { } setIsRemoteProjectLookingUp(true); + const lookupGeneration = ++cloneLookupGeneration.current; const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, input: { @@ -1981,6 +2055,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 c575f270f0bc..f816f60b4026 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, }); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index c0e16c7cce71..3a3936623275 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,10 @@ +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, @@ -465,10 +472,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; @@ -579,17 +592,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) => { @@ -729,6 +751,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} @@ -126,6 +129,7 @@ function renderTabs( diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} + pullRequestsAvailable={false} agentsAvailable={false} >
content
@@ -278,3 +282,47 @@ describe("tabMuteMenuItem", () => { }); }); }); + +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 e9dab0c9d2b4..d4ba544d8588 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, Plus, TerminalSquare, @@ -104,12 +112,14 @@ interface RightPanelTabsProps { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; pullRequestStatusSeeds?: Readonly>; /** Running + waiting subagents; badges the Agents card in the empty state. */ @@ -139,6 +149,7 @@ const SURFACE_DISABLED_REASONS = { files: "Files are only available when a project 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.", } as const; @@ -161,6 +172,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; @@ -298,12 +310,14 @@ function RightPanelEmptyState(props: { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; liveAgentCount: number; }) { @@ -361,6 +375,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.", @@ -602,6 +626,8 @@ function surfaceTitle( ); case "pull-request": return `#${surface.number}`; + case "pull-requests": + return "Pull requests"; case "agents": return "Agents"; case "preview": { @@ -683,11 +709,42 @@ function SurfaceIcon({ seed={pullRequestStatusSeeds?.[surface.id]} /> ); + case "pull-requests": + return ; case "agents": return ; } } +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, @@ -699,13 +756,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, }, @@ -714,11 +794,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 ; } @@ -806,6 +890,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, @@ -1250,12 +1342,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} + onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} + pullRequestsAvailable={props.pullRequestsAvailable} agentsAvailable={props.agentsAvailable} liveAgentCount={props.liveAgentCount} /> diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4a0584821a9b..33157e7b4b4b 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 9b59675e2eb1..0150eb473d51 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { @@ -875,17 +876,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 ebc1078e672b..78c59e296b90 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,9 @@ +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, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, @@ -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. @@ -1156,7 +1177,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; @@ -1339,17 +1359,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 @@ -1456,28 +1485,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; unrelated links show the current PR and a remainder 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 ? ( + ) : null} {sortable?.isDragging ? ( dragDestination ) : ( @@ -1889,6 +1923,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} {prBadge} + {prBadge && + pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( + + ) : null} {diff ? ( +{diff.insertions}{" "} @@ -2572,7 +2613,7 @@ export default function Sidebar() { [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), + () => searchSidebarThreads(searchableThreads, threadSearchQuery), [searchableThreads, threadSearchQuery], ); const threadSearchResultOrderKey = threadSearchResults @@ -4304,7 +4345,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 868bd2cd99c0..2ad3ef64448e 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 afe56608eda5..6a73a805fb67 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,231 @@ 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: "" }, + }; +} + +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"; + 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]", + isStack ? PR_STATE_COLOR_CLASS[badge.state] : (status?.colorClass ?? "text-muted-foreground"), + ); + const content = ( + <> + + {isStack ? badge.layers : number} + {badge?.kind === "pull-request" && badge.others > 0 ? ( + +{badge.others} + ) : null} + + ); + return ( + + event.stopPropagation()} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onOpenStack(); + }} + /> + ) : ( + event.stopPropagation()} + onClick={onOpenPullRequest} + /> + ) + } + > + {content} + + {label} + + ); +} - return useMemo( +/** + * 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 +483,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 +497,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 +525,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ) : null} + {pendingLink ? ( + + ) : null} {threadStatus ? : null}
); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 76345c1b28e0..f4de19717c61 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -16,6 +16,7 @@ import { } from "./SnapShotAttachmentDetails"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -63,6 +64,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); + const zoomableImageRef = useRef(null); const [failedImageSrc, setFailedImageSrc] = useState(null); const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; @@ -117,6 +119,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose(); return; } + if (zoomableImageRef.current?.pan(event.key)) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (preview.images.length <= 1) return; if (event.key === "ArrowLeft") { event.preventDefault(); @@ -206,11 +213,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ {openOriginalLink} ) : ( - {item.name} setFailedImageSrc(item.src)} /> )} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e1cf7f0dc6e7..0c5e53261b4f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -390,6 +390,7 @@ describe("streaming row projection", () => { runtimeMode: "full-access", interactionMode: "default", branch: null, + pullRequests: [], worktreePath: null, latestTurn: { ...initial.input.latestTurn, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a87531673506..0082c7dd227c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -337,7 +337,7 @@ export type MessagesTimelineRow = summaryKind: ToolGroupSummaryKind; toolSurface?: WorkLogEntry["toolSurface"]; toolIcon?: WorkLogEntry["toolIcon"]; - summaryToolIcon?: "browser" | "t3-code"; + summaryToolIcon?: "browser" | "t3-code" | "pull-request"; hasFailure: boolean; } | { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1d4abe39bf39..8e9635f89e43 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import { GitPullRequestIcon } from "lucide-react"; import { type AssistantCitation, type EnvironmentId, @@ -2225,6 +2226,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": @@ -2801,6 +2807,7 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "pull-request" | "t3-code" | "wrench" | "x" @@ -3005,6 +3012,8 @@ function ToolActivityImageIcon(props: { function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { + case "pull-request": + return ; case "bot": return ; case "brain": @@ -3309,13 +3318,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, @@ -3396,9 +3404,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { 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 000000000000..bbcb9e144190 --- /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 000000000000..6d11ce7cd944 --- /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 000000000000..aa5dfa789524 --- /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 de6f32aaa121..54ad13980236 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({ + + } + /> + + 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 000000000000..451131cb11fe --- /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 b06630f3bd04..fed932d457a5 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 + .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 000000000000..c9ef8974ef13 --- /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 000c594ec6ec..60884bbb7210 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 24b37aceafc2..64792164814f 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 000000000000..fc86c0a694d6 --- /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 000000000000..6602b2f391f4 --- /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/pullRequestReviewStore.test.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts index abbcab162360..8466903eb536 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 41906a710fc8..fba8d4c56e13 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 000000000000..621ba3124e8e --- /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 000000000000..4e48a2f3705b --- /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 83adbeb97320..cbb95472da19 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 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 }; @@ -21,3 +21,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 7866578cfa4a..505a86ff0eac 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -96,6 +96,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; @@ -181,6 +196,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; @@ -502,11 +519,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` : ""} + +
& { underline?: boolean }) { + return ( +