authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-09 15:50:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 15:50:58-07:00
logafb84898be3bf9b83bd4cb98c608b79c433579cc
treed57590e4e2bbbbef6477cb1f1f1abb2bd412e212
parent383cc40f4d5d9f61d47b3caa28fac839a8b9c433
signature Signed by PGP key B5690EEEBB952194

feat(pull-requests): link multiple pull requests to threads (#10839)


172 files changed, 12057 insertions(+), 908 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+2
...@@ -78,6 +78,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch";...@@ -78,6 +78,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch";
78import IconServer from "@tabler/icons-react-native/IconServer";78import IconServer from "@tabler/icons-react-native/IconServer";
79import IconSettings from "@tabler/icons-react-native/IconSettings";79import IconSettings from "@tabler/icons-react-native/IconSettings";
80import IconSparkles from "@tabler/icons-react-native/IconSparkles";80import IconSparkles from "@tabler/icons-react-native/IconSparkles";
81import IconStack2 from "@tabler/icons-react-native/IconStack2";
81import IconSun from "@tabler/icons-react-native/IconSun";82import IconSun from "@tabler/icons-react-native/IconSun";
82import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";83import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
83import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";84import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
...@@ -100,6 +101,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -100,6 +101,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
100 "arrow.right.circle": IconArrowRightCircle,101 "arrow.right.circle": IconArrowRightCircle,
101 "arrow.triangle.branch": IconGitBranch,102 "arrow.triangle.branch": IconGitBranch,
102 "arrow.triangle.pull": IconGitPullRequest,103 "arrow.triangle.pull": IconGitPullRequest,
104 "square.3.layers.3d": IconStack2,
103 "arrow.turn.left.up": IconArrowBackUp,105 "arrow.turn.left.up": IconArrowBackUp,
104 "arrow.up": IconArrowUp,106 "arrow.up": IconArrowUp,
105 "arrow.up.circle": IconArrowUpCircle,107 "arrow.up.circle": IconArrowUpCircle,
apps/mobile/src/features/archive/archivedThreadList.test.ts+1
...@@ -31,6 +31,7 @@ function makeThread(...@@ -31,6 +31,7 @@ function makeThread(
31 interactionMode: "default",31 interactionMode: "default",
32 branch: null,32 branch: null,
33 worktreePath: null,33 worktreePath: null,
34 pullRequests: [],
34 latestTurn: null,35 latestTurn: null,
35 createdAt: "2026-06-01T00:00:00.000Z",36 createdAt: "2026-06-01T00:00:00.000Z",
36 updatedAt: "2026-06-01T00:00:00.000Z",37 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/home/homeListItems.test.ts+1
...@@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {...@@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
43 interactionMode: "default",43 interactionMode: "default",
44 branch: null,44 branch: null,
45 worktreePath: null,45 worktreePath: null,
46 pullRequests: [],
46 latestTurn: null,47 latestTurn: null,
47 createdAt: "2026-06-01T00:00:00.000Z",48 createdAt: "2026-06-01T00:00:00.000Z",
48 updatedAt: "2026-06-01T00:00:00.000Z",49 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/home/homeThreadList.test.ts+1
...@@ -36,6 +36,7 @@ function makeThread(...@@ -36,6 +36,7 @@ function makeThread(
36 interactionMode: "default",36 interactionMode: "default",
37 branch: null,37 branch: null,
38 worktreePath: null,38 worktreePath: null,
39 pullRequests: [],
39 latestTurn: null,40 latestTurn: null,
40 createdAt: "2026-06-01T00:00:00.000Z",41 createdAt: "2026-06-01T00:00:00.000Z",
41 updatedAt: "2026-06-01T00:00:00.000Z",42 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+1
...@@ -44,6 +44,7 @@ export function HardwareKeyboardCommandProvider({...@@ -44,6 +44,7 @@ export function HardwareKeyboardCommandProvider({
44 ? null44 ? null
45 : resolveThreadReferenceCopyTarget({45 : resolveThreadReferenceCopyTarget({
46 threadId: activeThread?.id ?? activeThreadRef.threadId,46 threadId: activeThread?.id ?? activeThreadRef.threadId,
47 pullRequests: activeThread?.pullRequests,
47 linkedPullRequestUrl:48 linkedPullRequestUrl:
48 (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,49 (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
49 }),50 }),
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+58-1
...@@ -4,6 +4,10 @@ import {...@@ -4,6 +4,10 @@ import {
4 getGitActionDisabledReason,4 getGitActionDisabledReason,
5 requiresDefaultBranchConfirmation,5 requiresDefaultBranchConfirmation,
6} from "@t3tools/client-runtime/state/vcs";6} from "@t3tools/client-runtime/state/vcs";
7import {
8 resolveThreadPullRequestChains,
9 threadPullRequestKeyOf,
10} from "@t3tools/shared/threadPullRequests";
7import { EnvironmentId, ThreadId } from "@t3tools/contracts";11import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import {12import {
9 CommonActions,13 CommonActions,
...@@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
49 const isInspector = presentation === "inspector";53 const isInspector = presentation === "inspector";
50 const environmentId = EnvironmentId.make(props.route.params.environmentId);54 const environmentId = EnvironmentId.make(props.route.params.environmentId);
51 const threadId = ThreadId.make(props.route.params.threadId);55 const threadId = ThreadId.make(props.route.params.threadId);
52 const { selectedThread } = useThreadSelection();56 const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection();
53 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();57 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
58 const supportsLinkedPrSnapshots =
59 selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true;
60 const linkedPrChains = useMemo(
61 () =>
62 resolveThreadPullRequestChains(
63 supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [],
64 ),
65 [selectedThread?.pullRequests, supportsLinkedPrSnapshots],
66 );
54 const gitState = useSelectedThreadGitState();67 const gitState = useSelectedThreadGitState();
55 const gitActions = useSelectedThreadGitActions();68 const gitActions = useSelectedThreadGitActions();
56 const theme = useUniwindTheme();69 const theme = useUniwindTheme();
...@@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
285 />298 />
286 </View>299 </View>
287300
301 {linkedPrChains.length > 0 ? (
302 <View className="gap-2">
303 <Text className="px-1 text-xs font-t3-bold text-foreground-muted">
304 Linked pull requests
305 </Text>
306 {linkedPrChains.map((chain) => (
307 <View
308 key={threadPullRequestKeyOf(chain.layers[0]!)}
309 className="overflow-hidden rounded-2xl border border-border bg-card px-3 py-1"
310 >
311 {chain.layers.length > 1 ? (
312 <View className="flex-row items-center gap-2 px-1 pt-2 pb-1">
313 <SymbolView
314 name="square.3.layers.3d"
315 size={14}
316 tintColorClassName="accent-foreground-muted"
317 />
318 <Text className="text-xs text-foreground-muted">
319 {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs
320 · bottom to top
321 </Text>
322 </View>
323 ) : null}
324 {chain.layers.map((link, index) => (
325 <View key={threadPullRequestKeyOf(link)}>
326 {index > 0 ? <View className="ml-12 h-px bg-border" /> : null}
327 <SheetListRow
328 icon="arrow.triangle.pull"
329 title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
330 subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}`}
331 onPress={() => {
332 void tryOpenExternalUrl(link.url, "pull-request").then((opened) => {
333 if (!opened)
334 Alert.alert("Unable to open PR", "The pull request could not be opened.");
335 });
336 }}
337 />
338 </View>
339 ))}
340 </View>
341 ))}
342 </View>
343 ) : null}
344
288 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}345 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
289 </ScrollView>346 </ScrollView>
290 );347 );
apps/mobile/src/features/threads/thread-list-items.tsx+25-9
...@@ -56,6 +56,7 @@ function pullRequestTintColor(...@@ -56,6 +56,7 @@ function pullRequestTintColor(
56 return dark ? "#34d399" : "#059669";56 return dark ? "#34d399" : "#059669";
57 case "merged":57 case "merged":
58 return dark ? "#a78bfa" : "#7c3aed";58 return dark ? "#a78bfa" : "#7c3aed";
59 case null:
59 case "closed":60 case "closed":
60 return dark ? "#a1a1aa" : "#71717a";61 return dark ? "#a1a1aa" : "#71717a";
61 }62 }
...@@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
618 </>619 </>
619 ) : null}620 ) : null}
620 {pr !== null ? (621 {pr !== null ? (
621 <View className="flex-row items-center gap-0.5">622 <View
622 <PullRequestIcon623 className="flex-row items-center gap-0.5"
623 size={compact ? 13 : 11}624 accessibilityLabel={pr.accessibilityLabel}
624 color={625 >
625 visuallySelected626 {pr.kind === "stack" ? (
626 ? String(effectiveSelectedForeground)627 <SymbolView
627 : pullRequestTintColor(pr, colorScheme)628 name="square.3.layers.3d"
628 }629 size={compact ? 13 : 11}
629 />630 tintColor={
631 visuallySelected
632 ? String(effectiveSelectedForeground)
633 : pullRequestTintColor(pr, colorScheme)
634 }
635 />
636 ) : (
637 <PullRequestIcon
638 size={compact ? 13 : 11}
639 color={
640 visuallySelected
641 ? String(effectiveSelectedForeground)
642 : pullRequestTintColor(pr, colorScheme)
643 }
644 />
645 )}
630 <Text646 <Text
631 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${647 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
632 visuallySelected648 visuallySelected
apps/mobile/src/features/threads/thread-list-v2-items.tsx+29-14
...@@ -870,20 +870,35 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -870,20 +870,35 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
870 <View className="flex-1" />870 <View className="flex-1" />
871 )}871 )}
872 {pr ? (872 {pr ? (
873 <Text873 <View className="flex-row items-center gap-1" accessibilityLabel={pr.accessibilityLabel}>
874 accessibilityLabel={pr.accessibilityLabel}874 {pr.kind === "stack" ? (
875 className={cn(875 <SymbolView
876 "text-xs",876 name="square.3.layers.3d"
877 selected877 size={12}
878 ? materialYouStyleLayoutActive878 tintColorClassName={
879 ? "text-thread-selected-foreground"879 selected
880 : "text-user-bubble-foreground"880 ? materialYouStyleLayoutActive
881 : pr.textClassName,881 ? "accent-thread-selected-foreground"
882 )}882 : "accent-user-bubble-foreground"
883 style={{ fontFamily: MONO_FONT }}883 : "accent-foreground-muted"
884 >884 }
885 #{pr.label}885 />
886 </Text>886 ) : null}
887 <Text
888 accessibilityLabel={pr.accessibilityLabel}
889 className={cn(
890 "text-xs",
891 selected
892 ? materialYouStyleLayoutActive
893 ? "text-thread-selected-foreground"
894 : "text-user-bubble-foreground"
895 : pr.textClassName,
896 )}
897 style={{ fontFamily: MONO_FONT }}
898 >
899 {pr.kind === "stack" ? pr.label : `#${pr.label}`}
900 </Text>
901 </View>
887 ) : null}902 ) : null}
888 {props.providerInstance ? (903 {props.providerInstance ? (
889 <ProviderInstanceIcon904 <ProviderInstanceIcon
apps/mobile/src/features/threads/threadListV2.test.ts+1
...@@ -47,6 +47,7 @@ function makeThread(...@@ -47,6 +47,7 @@ function makeThread(
47 interactionMode: "default",47 interactionMode: "default",
48 branch: null,48 branch: null,
49 worktreePath: null,49 worktreePath: null,
50 pullRequests: [],
50 latestTurn: null,51 latestTurn: null,
51 createdAt: "2026-06-01T00:00:00.000Z",52 createdAt: "2026-06-01T00:00:00.000Z",
52 updatedAt: "2026-06-01T00:00:00.000Z",53 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/lib/threadActivity.test.ts+1
...@@ -258,6 +258,7 @@ function makeThread(...@@ -258,6 +258,7 @@ function makeThread(
258 interactionMode: "default",258 interactionMode: "default",
259 branch: null,259 branch: null,
260 worktreePath: null,260 worktreePath: null,
261 pullRequests: [],
261 latestTurn: null,262 latestTurn: null,
262 createdAt: "2026-04-01T00:00:00.000Z",263 createdAt: "2026-04-01T00:00:00.000Z",
263 updatedAt: "2026-04-01T00:00:00.000Z",264 updatedAt: "2026-04-01T00:00:00.000Z",
apps/mobile/src/state/pending-thread-creation.ts+1
...@@ -144,6 +144,7 @@ export function pendingThreadCreationShell(...@@ -144,6 +144,7 @@ export function pendingThreadCreationShell(
144 runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,144 runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,
145 interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,145 interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
146 branch: creation.branch,146 branch: creation.branch,
147 pullRequests: [],
147 worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,148 worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,
148 linkedPullRequest: null,149 linkedPullRequest: null,
149 latestTurn: null,150 latestTurn: null,
apps/mobile/src/state/thread-pr-presentation.ts+64-2
...@@ -1,11 +1,22 @@...@@ -1,11 +1,22 @@
1import type { VcsStatusResult } from "@t3tools/contracts";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import type {
3 ExecutionEnvironmentCapabilities,
4 ThreadPullRequestLink,
5 VcsStatusResult,
6} from "@t3tools/contracts";
2import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";7import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";
38
9import {
10 resolveThreadCurrentPullRequestLink,
11 resolveThreadPullRequestBadge,
12} from "@t3tools/shared/threadPullRequests";
13
4export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;14export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
515
6export interface ThreadPrPresentation {16export interface ThreadPrPresentation {
7 readonly number: number;17 readonly number: number;
8 readonly state: ThreadPr["state"];18 readonly state: ThreadPr["state"] | null;
19 readonly kind: "pull-request" | "stack";
9 readonly isDraft: boolean;20 readonly isDraft: boolean;
10 /** Provider-side last activity, bounding when a terminal state landed. */21 /** Provider-side last activity, bounding when a terminal state landed. */
11 readonly updatedAt: string | null;22 readonly updatedAt: string | null;
...@@ -30,6 +41,7 @@ export function presentThreadPr(...@@ -30,6 +41,7 @@ export function presentThreadPr(
30 const presentation = resolveChangeRequestPresentation(provider);41 const presentation = resolveChangeRequestPresentation(provider);
31 const isDraft = pr.state === "open" && pr.isDraft === true;42 const isDraft = pr.state === "open" && pr.isDraft === true;
32 return {43 return {
44 kind: "pull-request",
33 number: pr.number,45 number: pr.number,
34 state: pr.state,46 state: pr.state,
35 isDraft,47 isDraft,
...@@ -40,3 +52,53 @@ export function presentThreadPr(...@@ -40,3 +52,53 @@ export function presentThreadPr(
40 textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],52 textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],
41 };53 };
42}54}
55
56/** Persisted links render immediately, including links awaiting their first host sync. */
57export function presentThreadLinkedPullRequests(
58 links: ReadonlyArray<ThreadPullRequestLink>,
59): ThreadPrPresentation | null {
60 const link = resolveThreadCurrentPullRequestLink(links);
61 const badge = resolveThreadPullRequestBadge(links);
62 if (link === null || badge === null) return null;
63 const snapshot = link.snapshot;
64 const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null);
65 const isDraft = snapshot?.isDraft === true && state === "open";
66 const label =
67 badge.kind === "stack"
68 ? String(badge.layers)
69 : `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`;
70 return {
71 kind: badge.kind,
72 number: link.number,
73 state,
74 isDraft,
75 updatedAt: snapshot?.updatedAt ?? null,
76 url: link.url,
77 label,
78 accessibilityLabel:
79 badge.kind === "stack"
80 ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}`
81 : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`,
82 textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state],
83 };
84}
85
86/** Only the array capability replaces legacy references with persisted snapshots. */
87export function resolveThreadPrSource(
88 thread: Pick<EnvironmentThreadShell, "pullRequests" | "linkedPullRequest" | "branchPullRequest">,
89 capabilities:
90 | Pick<ExecutionEnvironmentCapabilities, "threadPullRequests" | "threadPullRequestLinking">
91 | undefined,
92) {
93 const supportsSnapshots = capabilities?.threadPullRequests === true;
94 const linkedPresentation = supportsSnapshots
95 ? presentThreadLinkedPullRequests(thread.pullRequests)
96 : null;
97 const pullRequestRef =
98 linkedPresentation !== null
99 ? null
100 : ((supportsSnapshots
101 ? thread.branchPullRequest
102 : (thread.linkedPullRequest ?? thread.branchPullRequest)) ?? null);
103 return { linkedPresentation, pullRequestRef };
104}
apps/mobile/src/state/use-selected-thread-git-actions.ts+2
...@@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() {...@@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() {
330 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),330 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
331 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),331 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
332 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),332 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
333 // A pull request the action opens is linked to the thread it ran beside.
334 threadId: thread.id,
333 });335 });
334 if (AsyncResult.isFailure(result)) {336 if (AsyncResult.isFailure(result)) {
335 return result;337 return result;
apps/mobile/src/state/use-thread-pr.test.ts+145-2
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import type { VcsStatusResult } from "@t3tools/contracts";1import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { presentThreadPr } from "./thread-pr-presentation";4import {
5 presentThreadLinkedPullRequests,
6 presentThreadPr,
7 resolveThreadPrSource,
8} from "./thread-pr-presentation";
59
6const pullRequest: NonNullable<VcsStatusResult["pr"]> = {10const pullRequest: NonNullable<VcsStatusResult["pr"]> = {
7 number: 3774,11 number: 3774,
...@@ -43,3 +47,142 @@ describe("presentThreadPr", () => {...@@ -43,3 +47,142 @@ describe("presentThreadPr", () => {
43 });47 });
44 });48 });
45});49});
50
51function linkedPr(
52 number: number,
53 overrides: Partial<ThreadPullRequestLink> = {},
54): ThreadPullRequestLink {
55 return {
56 host: "github.com",
57 repository: "t3tools/t3code",
58 number,
59 url: `https://github.com/t3tools/t3code/pull/${number}`,
60 source: "manual",
61 linkedAt: "2026-09-08T00:00:00.000Z",
62 stack: null,
63 snapshot: {
64 state: "open",
65 title: `Change ${number}`,
66 headBranch: `change-${number}`,
67 baseBranch: "main",
68 isDraft: false,
69 updatedAt: null,
70 syncedAt: "2026-09-08T00:00:00.000Z",
71 },
72 ...overrides,
73 };
74}
75
76describe("presentThreadLinkedPullRequests", () => {
77 it("renders unsynced links with neutral pending status", () => {
78 expect(presentThreadLinkedPullRequests([linkedPr(1, { snapshot: null })])).toMatchObject({
79 number: 1,
80 label: "1",
81 state: null,
82 textClassName: "text-foreground-muted",
83 accessibilityLabel: "#1 pull request status pending",
84 });
85 });
86
87 it("counts unrelated links without labelling them a stack", () => {
88 expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({
89 kind: "pull-request",
90 label: "1 +1",
91 });
92 });
93
94 it("uses the top of a derived stack even when its bottom was linked later", () => {
95 const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" });
96 const top = linkedPr(2);
97 expect(
98 presentThreadLinkedPullRequests([
99 bottom,
100 {
101 ...top,
102 snapshot: { ...top.snapshot!, baseBranch: "change-1" },
103 },
104 ]),
105 ).toMatchObject({ kind: "stack", label: "2", number: 2, url: top.url });
106 });
107
108 it("hides dismissed stack members", () => {
109 expect(
110 presentThreadLinkedPullRequests([linkedPr(1, { source: "stack-dismissed" })]),
111 ).toBeNull();
112 });
113
114 it("retains merged state from the persisted snapshot", () => {
115 const link = linkedPr(1);
116 expect(
117 presentThreadLinkedPullRequests([
118 { ...link, snapshot: { ...link.snapshot!, state: "merged" } },
119 ]),
120 ).toMatchObject({ state: "merged", textClassName: "text-adaptive-violet-600-400" });
121 });
122});
123
124describe("resolveThreadPrSource compatibility", () => {
125 const legacyRef = {
126 projectId: ProjectId.make("project"),
127 repository: "t3tools/t3code",
128 number: 1,
129 url: "https://github.com/t3tools/t3code/pull/1",
130 };
131 const branchRef = { ...legacyRef, number: 2, url: "https://github.com/t3tools/t3code/pull/2" };
132
133 it("polls the legacy reference when only the older linking capability exists", () => {
134 expect(
135 resolveThreadPrSource(
136 { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
137 { threadPullRequestLinking: true },
138 ),
139 ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef });
140 });
141
142 it("keeps the legacy reference for a server without either capability", () => {
143 expect(resolveThreadPrSource({ pullRequests: [], linkedPullRequest: legacyRef }, {})).toEqual({
144 linkedPresentation: null,
145 pullRequestRef: legacyRef,
146 });
147 });
148
149 it("ignores stale snapshots after reconnecting to a server without array support", () => {
150 expect(
151 resolveThreadPrSource(
152 { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef },
153 { threadPullRequestLinking: true, threadPullRequests: false },
154 ),
155 ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef });
156 });
157
158 it("uses snapshots without polling when both capabilities are advertised", () => {
159 expect(
160 resolveThreadPrSource(
161 { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
162 { threadPullRequestLinking: true, threadPullRequests: true },
163 ),
164 ).toMatchObject({ linkedPresentation: { number: 3 }, pullRequestRef: null });
165 });
166
167 it("does not revive a removed modern link from the compatibility reference", () => {
168 expect(
169 resolveThreadPrSource(
170 { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
171 { threadPullRequests: true },
172 ),
173 ).toEqual({ linkedPresentation: null, pullRequestRef: branchRef });
174 });
175
176 it("does not poll when a modern link is waiting for its first snapshot", () => {
177 expect(
178 resolveThreadPrSource(
179 {
180 pullRequests: [linkedPr(3, { snapshot: null })],
181 linkedPullRequest: legacyRef,
182 branchPullRequest: branchRef,
183 },
184 { threadPullRequests: true },
185 ),
186 ).toMatchObject({ linkedPresentation: { number: 3, state: null }, pullRequestRef: null });
187 });
188});
apps/mobile/src/state/use-thread-pr.ts+25-5
...@@ -10,8 +10,13 @@ import { useCallback, useEffect, useMemo } from "react";...@@ -10,8 +10,13 @@ import { useCallback, useEffect, useMemo } from "react";
1010
11import { connectionAtomRuntime } from "../connection/runtime";11import { connectionAtomRuntime } from "../connection/runtime";
12import { appAtomRegistry } from "./atom-registry";12import { appAtomRegistry } from "./atom-registry";
13import { serverEnvironment } from "./server";
13import { useEnvironmentQuery } from "./query";14import { useEnvironmentQuery } from "./query";
14import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";15import {
16 resolveThreadPrSource,
17 presentThreadPr,
18 type ThreadPrPresentation,
19} from "./thread-pr-presentation";
1520
16const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);21const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
17const MAX_THREAD_PR_SNAPSHOTS = 500;22const MAX_THREAD_PR_SNAPSHOTS = 500;
...@@ -35,11 +40,26 @@ export {...@@ -35,11 +40,26 @@ export {
35} from "./thread-pr-presentation";40} from "./thread-pr-presentation";
3641
37/**42/**
38 * Live status for a thread's server-provided PR. Visible rows share a summary43 * Linked PRs use server snapshots. Branch fallback and legacy references share
39 * request for the same PR in the same environment.44 * a live summary request across visible rows in the same environment.
40 */45 */
41export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {46export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {
42 const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null;47 const supportsLinks = useAtomValue(
48 serverEnvironment.configValueAtom(thread.environmentId),
49 (config) => config?.environment.capabilities.threadPullRequests === true,
50 );
51 const { linkedPresentation, pullRequestRef } = useMemo(
52 () =>
53 resolveThreadPrSource(
54 {
55 pullRequests: thread.pullRequests,
56 linkedPullRequest: thread.linkedPullRequest,
57 branchPullRequest: thread.branchPullRequest,
58 },
59 { threadPullRequests: supportsLinks },
60 ),
61 [thread.pullRequests, thread.linkedPullRequest, thread.branchPullRequest, supportsLinks],
62 );
43 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));63 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
44 const snapshotIdentity = JSON.stringify(pullRequestRef);64 const snapshotIdentity = JSON.stringify(pullRequestRef);
45 // Select this row's entry so writes for other rows do not re-render it.65 // 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...@@ -101,5 +121,5 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio
101 });121 });
102 }, [live, snapshotIdentity, threadKey]);122 }, [live, snapshotIdentity, threadKey]);
103123
104 return live === undefined ? snapshot : live;124 return linkedPresentation ?? (live === undefined ? snapshot : live);
105}125}
apps/mobile/src/state/use-thread-selection.ts+1
...@@ -64,6 +64,7 @@ function threadDetailToShell(...@@ -64,6 +64,7 @@ function threadDetailToShell(
64 branch: thread.branch,64 branch: thread.branch,
65 worktreePath: thread.worktreePath,65 worktreePath: thread.worktreePath,
66 linkedPullRequest: thread.linkedPullRequest ?? null,66 linkedPullRequest: thread.linkedPullRequest ?? null,
67 pullRequests: thread.pullRequests,
67 branchPullRequest: thread.branchPullRequest ?? null,68 branchPullRequest: thread.branchPullRequest ?? null,
68 latestTurn: thread.latestTurn,69 latestTurn: thread.latestTurn,
69 createdAt: thread.createdAt,70 createdAt: thread.createdAt,
apps/server/integration/OrchestrationEngineHarness.integration.ts+8
...@@ -67,6 +67,7 @@ import {...@@ -67,6 +67,7 @@ import {
67} from "../src/orchestration/Services/OrchestrationEngine.ts";67} from "../src/orchestration/Services/OrchestrationEngine.ts";
68import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";68import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
69import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";69import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
70import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts";
70import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";71import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
71import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";72import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
72import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";73import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
...@@ -404,6 +405,13 @@ export const makeOrchestrationIntegrationHarness = (...@@ -404,6 +405,13 @@ export const makeOrchestrationIntegrationHarness = (
404 drain: Effect.void,405 drain: Effect.void,
405 }),406 }),
406 ),407 ),
408 Layer.provideMerge(
409 Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, {
410 start: () => Effect.void,
411 drain: Effect.void,
412 requestSync: () => Effect.void,
413 }),
414 ),
407 Layer.provideMerge(415 Layer.provideMerge(
408 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {416 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
409 publishThread: () => Effect.void,417 publishThread: () => Effect.void,
apps/server/src/auth/RpcAuthorization.ts+2
...@@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = {
68 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,68 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
69 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,69 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
70 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,70 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
71 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
72 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
71 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,73 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
72 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,74 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
73 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,75 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
169 expect(second.capabilities.usagePriceOverrides).toBe(true);169 expect(second.capabilities.usagePriceOverrides).toBe(true);
170 expect(second.capabilities.threadActiveReorder).toBe(true);170 expect(second.capabilities.threadActiveReorder).toBe(true);
171 expect(second.capabilities.threadTitleRegeneration).toBe(true);171 expect(second.capabilities.threadTitleRegeneration).toBe(true);
172 expect(second.capabilities.threadPullRequests).toBe(true);
172 expect(second.capabilities.threadPullRequestLinking).toBe(true);173 expect(second.capabilities.threadPullRequestLinking).toBe(true);
173 expect(second.capabilities.agentActivityPublishing).toBe(false);174 expect(second.capabilities.agentActivityPublishing).toBe(false);
174 }),175 }),
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -230,6 +230,7 @@ export const make = Effect.gen(function* () {...@@ -230,6 +230,7 @@ export const make = Effect.gen(function* () {
230 threadPinReorder: true,230 threadPinReorder: true,
231 threadActiveReorder: true,231 threadActiveReorder: true,
232 threadTitleRegeneration: true,232 threadTitleRegeneration: true,
233 threadPullRequests: true,
233 threadPullRequestLinking: true,234 threadPullRequestLinking: true,
234 environmentIcon: true,235 environmentIcon: true,
235 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),236 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
apps/server/src/git/linkCreatedPullRequest.test.ts created+230
...@@ -0,0 +1,230 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type GitRunStackedActionResult,
7 type OrchestrationCommand,
8 type OrchestrationProjectShell,
9 type OrchestrationThreadShell,
10} from "@t3tools/contracts";
11import { describe, expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Ref from "effect/Ref";
16import * as Stream from "effect/Stream";
17
18import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts";
19import {
20 OrchestrationEngineService,
21 type OrchestrationEngineShape,
22} from "../orchestration/Services/OrchestrationEngine.ts";
23import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
24import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts";
25
26const PROJECT_ID = ProjectId.make("project-1");
27const THREAD_ID = ThreadId.make("thread-1");
28const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test"));
29
30const project: OrchestrationProjectShell = {
31 id: PROJECT_ID,
32 title: "Project",
33 workspaceRoot: "/workspace/project",
34 defaultModelSelection: null,
35 scripts: [],
36 repositoryIdentity: {
37 canonicalKey: "github.acme.test/platform/api",
38 locator: {
39 source: "git-remote",
40 remoteName: "origin",
41 remoteUrl: "git@github.acme.test:Platform/API.git",
42 },
43 provider: "github",
44 displayName: "Platform/API",
45 owner: "Platform",
46 name: "API",
47 },
48 createdAt: "2026-08-01T00:00:00.000Z",
49 updatedAt: "2026-08-01T00:00:00.000Z",
50};
51
52const thread: OrchestrationThreadShell = {
53 id: THREAD_ID,
54 projectId: PROJECT_ID,
55 title: "Thread",
56 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
57 runtimeMode: "full-access",
58 interactionMode: "default",
59 branch: null,
60 worktreePath: null,
61 pullRequests: [],
62 latestTurn: null,
63 createdAt: "2026-08-01T00:00:00.000Z",
64 updatedAt: "2026-08-20T00:00:00.000Z",
65 archivedAt: null,
66 settledOverride: null,
67 settledAt: null,
68 session: null,
69 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
70 hasPendingApprovals: false,
71 hasPendingUserInput: false,
72 hasActionableProposedPlan: false,
73};
74
75function prResult(pr: GitRunStackedActionResult["pr"]): Pick<GitRunStackedActionResult, "pr"> {
76 return { pr };
77}
78
79const makeDependencies = (
80 dispatch: OrchestrationEngineShape["dispatch"],
81 threadShell: OrchestrationThreadShell | null = thread,
82) =>
83 Layer.mergeAll(
84 Layer.mock(ProjectionSnapshotQuery)({
85 getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)),
86 getProjectShellById: () => Effect.succeed(Option.some(project)),
87 }),
88 Layer.mock(OrchestrationEngineService)({
89 readEvents: () => Stream.empty,
90 dispatch,
91 streamDomainEvents: Stream.empty,
92 latestSequence: Effect.succeed(0),
93 }),
94 );
95
96const recordingDispatch = Effect.fn("recordingDispatch")(function* () {
97 const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
98 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
99 Ref.update(commands, (recorded) => [...recorded, command]).pipe(Effect.as({ sequence: 1 }));
100 return { commands, dispatch };
101});
102
103describe("createdPullRequestKey", () => {
104 it("reads host and repository from the URL when it is recognisable", () => {
105 expect(
106 createdPullRequestKey(
107 prResult({
108 status: "created",
109 number: 12,
110 url: "https://github.com/Other/Fork/pull/12",
111 }),
112 project,
113 ),
114 ).toEqual({
115 host: "github.com",
116 repository: "other/fork",
117 number: 12,
118 url: "https://github.com/Other/Fork/pull/12",
119 });
120 });
121
122 it("falls back to the project's host and repository for an unreadable URL", () => {
123 expect(
124 createdPullRequestKey(
125 prResult({ status: "opened_existing", number: 3, url: "https://ghe.internal/x/3" }),
126 project,
127 ),
128 ).toEqual({
129 host: "github.acme.test",
130 repository: "platform/api",
131 number: 3,
132 url: "https://ghe.internal/x/3",
133 });
134 expect(
135 createdPullRequestKey(
136 prResult({ status: "created", number: 3, url: "https://ghe.internal/x/3" }),
137 undefined,
138 ),
139 ).toBeNull();
140 });
141
142 it("yields nothing when no pull request came out of the action", () => {
143 expect(
144 createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project),
145 ).toBeNull();
146 expect(
147 createdPullRequestKey(
148 prResult({ status: "created", url: "https://github.com/a/b/pull/1" }),
149 project,
150 ),
151 ).toBeNull();
152 expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull();
153 });
154});
155
156describe("linkCreatedPullRequest", () => {
157 it.effect("links a created pull request to the thread with source created", () =>
158 Effect.gen(function* () {
159 const { commands, dispatch } = yield* recordingDispatch();
160 yield* linkCreatedPullRequest({
161 threadId: THREAD_ID,
162 result: prResult({
163 status: "created",
164 number: 42,
165 url: "https://github.com/t3tools/t3code/pull/42",
166 }),
167 commandId,
168 }).pipe(Effect.provide(makeDependencies(dispatch)));
169
170 expect(yield* Ref.get(commands)).toEqual([
171 {
172 type: "thread.pull-request.link",
173 commandId: "server:pr-created-link:test",
174 threadId: THREAD_ID,
175 host: "github.com",
176 repository: "t3tools/t3code",
177 number: 42,
178 url: "https://github.com/t3tools/t3code/pull/42",
179 source: "created",
180 },
181 ]);
182 }),
183 );
184
185 it.effect("dispatches nothing when the action produced no pull request", () =>
186 Effect.gen(function* () {
187 const { commands, dispatch } = yield* recordingDispatch();
188 const dependencies = makeDependencies(dispatch);
189 yield* linkCreatedPullRequest({
190 threadId: THREAD_ID,
191 result: prResult({ status: "skipped_not_requested" }),
192 commandId,
193 }).pipe(Effect.provide(dependencies));
194 yield* linkCreatedPullRequest({
195 threadId: THREAD_ID,
196 result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }),
197 commandId,
198 }).pipe(Effect.provide(dependencies));
199
200 expect(yield* Ref.get(commands)).toEqual([]);
201 }),
202 );
203
204 it.effect("swallows an already-linked rejection and other dispatch failures", () =>
205 Effect.gen(function* () {
206 const rejecting: OrchestrationEngineShape["dispatch"] = (command) =>
207 Effect.fail(
208 new OrchestrationCommandInvariantError({
209 commandType: command.type,
210 detail: "already linked",
211 }),
212 );
213 const result = prResult({
214 status: "opened_existing",
215 number: 7,
216 url: "https://github.com/t3tools/t3code/pull/7",
217 });
218 yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
219 Effect.provide(makeDependencies(rejecting)),
220 );
221 yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
222 Effect.provide(makeDependencies(() => Effect.die(new Error("engine down")))),
223 );
224 // A thread that vanished between the action and the link is not an error either.
225 yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe(
226 Effect.provide(makeDependencies(() => Effect.die(new Error("unreachable")), null)),
227 );
228 }),
229 );
230});
apps/server/src/git/linkCreatedPullRequest.ts created+98
...@@ -0,0 +1,98 @@
1import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
2import {
3 type CommandId,
4 pullRequestHostOf,
5 type GitRunStackedActionResult,
6 type OrchestrationProjectShell,
7 type SourceControlProviderKind,
8 type ThreadId,
9} from "@t3tools/contracts";
10import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
11import * as Cause from "effect/Cause";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14
15import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
16import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
17
18export interface CreatedPullRequestKey {
19 readonly host: string;
20 readonly repository: string;
21 readonly number: number;
22 readonly url: string;
23}
24
25/**
26 * The identity a stacked action's pull request should be linked under, or
27 * null when the action did not leave one behind. Reading the host and
28 * repository from the URL keeps the link host-level even when the checkout's
29 * remote differs from where the PR was opened (a fork, say); the project is
30 * only consulted when the URL is one this cannot read.
31 */
32export function createdPullRequestKey(
33 result: Pick<GitRunStackedActionResult, "pr">,
34 project: OrchestrationProjectShell | undefined,
35): CreatedPullRequestKey | null {
36 const { status, number, url } = result.pr;
37 if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) {
38 return null;
39 }
40 const parsed = parseChangeRequestUrl(url);
41 if (parsed !== null) return { ...parsed, url };
42 const identity = project?.repositoryIdentity;
43 const kind = identity?.provider as SourceControlProviderKind | undefined;
44 const repository = sourceControlRepositorySelector(identity);
45 if (!identity || kind === undefined || repository === null) return null;
46 return {
47 host: pullRequestHostOf(identity, kind),
48 repository: repository.toLowerCase(),
49 number,
50 url,
51 };
52}
53
54/**
55 * Links the pull request a `create_pr`-shaped action produced to the thread it
56 * ran beside. Never fails: the git action already succeeded and its result is
57 * on its way to the client, so a link that cannot be made is logged and
58 * dropped. A duplicate link is the decider saying the thread already knew.
59 */
60export const linkCreatedPullRequest = <E>(input: {
61 readonly threadId: ThreadId;
62 readonly result: Pick<GitRunStackedActionResult, "pr">;
63 readonly commandId: Effect.Effect<CommandId, E>;
64}): Effect.Effect<
65 void,
66 never,
67 OrchestrationEngine.OrchestrationEngineService | ProjectionSnapshotQuery.ProjectionSnapshotQuery
68> =>
69 Effect.gen(function* () {
70 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
71 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
72 const thread = yield* snapshots.getThreadShellById(input.threadId);
73 if (Option.isNone(thread)) return;
74 const project = Option.getOrUndefined(
75 yield* snapshots.getProjectShellById(thread.value.projectId),
76 );
77 const key = createdPullRequestKey(input.result, project);
78 if (key === null) return;
79 const commandId = yield* input.commandId;
80 yield* engine
81 .dispatch({
82 type: "thread.pull-request.link",
83 commandId,
84 threadId: input.threadId,
85 ...key,
86 source: "created",
87 })
88 .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void }));
89 }).pipe(
90 Effect.withSpan("linkCreatedPullRequest"),
91 Effect.catchCause((cause) =>
92 Cause.hasInterruptsOnly(cause)
93 ? Effect.failCause(cause as Cause.Cause<never>)
94 : Effect.logWarning("failed to link created pull request to thread", {
95 threadId: input.threadId,
96 }),
97 ),
98 );
apps/server/src/mcp/McpHttpServer.test.ts+47
...@@ -6,12 +6,15 @@ import * as Deferred from "effect/Deferred";...@@ -6,12 +6,15 @@ import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
9import * as Path from "effect/Path";10import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";12import * as Stream from "effect/Stream";
12import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";13import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";
13import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";14import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";
1415
16import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
17import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
15import * as ServerConfig from "../config.ts";18import * as ServerConfig from "../config.ts";
16import * as McpHttpServer from "./McpHttpServer.ts";19import * as McpHttpServer from "./McpHttpServer.ts";
17import * as McpInvocationContext from "./McpInvocationContext.ts";20import * as McpInvocationContext from "./McpInvocationContext.ts";
...@@ -48,6 +51,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(...@@ -48,6 +51,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
48 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),51 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),
49 Layer.provideMerge(NodeServices.layer),52 Layer.provideMerge(NodeServices.layer),
50);53);
54const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive.pipe(
55 Layer.provideMerge(McpServer.McpServer.layer),
56 Layer.provide(
57 Layer.mergeAll(
58 Layer.mock(ProjectionSnapshotQuery)({
59 getThreadShellById: () => Effect.succeed(Option.none()),
60 }),
61 Layer.mock(OrchestrationEngineService)({}),
62 NodeServices.layer,
63 ),
64 ),
65);
5166
52const snapshotResult = {67const snapshotResult = {
53 url: "http://example.test/",68 url: "http://example.test/",
...@@ -345,6 +360,38 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () =>...@@ -345,6 +360,38 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () =>
345 ).pipe(Effect.provide(TestLayer)),360 ).pipe(Effect.provide(TestLayer)),
346);361);
347362
363it.effect(
364 "registers the pull request toolkit and surfaces a missing capability as a tool error",
365 () =>
366 Effect.gen(function* () {
367 const server = yield* McpServer.McpServer;
368 const names = server.tools.map(({ tool }) => tool.name);
369 expect(names).toEqual(
370 expect.arrayContaining([
371 "link_pull_request",
372 "unlink_pull_request",
373 "list_thread_pull_requests",
374 ]),
375 );
376 const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request");
377 expect(linkTool?.tool.annotations?.idempotentHint).toBe(true);
378 expect(linkTool?.tool.annotations?.openWorldHint).toBe(false);
379 expect(linkTool?.tool.description).toContain("Register every pull request you open");
380
381 const denied = yield* server
382 .callTool({ name: "list_thread_pull_requests", arguments: {} })
383 .pipe(
384 // A preview-only credential: the token predates the toolkit or was minted elsewhere.
385 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
386 Effect.provideService(McpSchema.McpServerClient, client),
387 );
388 expect(denied.isError).toBe(true);
389 expect(denied.content).toEqual([
390 { type: "text", text: "MCP credential does not grant the pull-requests capability." },
391 ]);
392 }).pipe(Effect.provide(PullRequestsTestLayer)),
393);
394
348it.effect("keeps the snapshot text under the agent's output ceiling", () =>395it.effect("keeps the snapshot text under the agent's output ceiling", () =>
349 Effect.scoped(396 Effect.scoped(
350 Effect.gen(function* () {397 Effect.gen(function* () {
apps/server/src/mcp/McpHttpServer.ts+10-1
...@@ -28,6 +28,8 @@ import {...@@ -28,6 +28,8 @@ import {
28 PreviewSnapshotToolkit,28 PreviewSnapshotToolkit,
29 PreviewStandardToolkit,29 PreviewStandardToolkit,
30} from "./toolkits/preview/tools.ts";30} from "./toolkits/preview/tools.ts";
31import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
32import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
3133
32const unauthorized = HttpServerResponse.jsonUnsafe(34const unauthorized = HttpServerResponse.jsonUnsafe(
33 {35 {
...@@ -437,6 +439,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll(...@@ -437,6 +439,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll(
437 PreviewSnapshotRegistrationLive,439 PreviewSnapshotRegistrationLive,
438);440);
439441
442export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe(
443 Layer.provide(PullRequestsToolkitHandlersLive),
444);
445
440const McpTransportLive = McpServer.layerHttp({446const McpTransportLive = McpServer.layerHttp({
441 name: "T3 Code",447 name: "T3 Code",
442 version: packageJson.version,448 version: packageJson.version,
...@@ -444,4 +450,7 @@ const McpTransportLive = McpServer.layerHttp({...@@ -444,4 +450,7 @@ const McpTransportLive = McpServer.layerHttp({
444 protocols: [McpProtocol.v2025_06_18],450 protocols: [McpProtocol.v2025_06_18],
445}).pipe(Layer.provide(McpAuthMiddlewareLive));451}).pipe(Layer.provide(McpAuthMiddlewareLive));
446452
447export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive));453export const layer = Layer.mergeAll(
454 PreviewToolkitRegistrationLive,
455 PullRequestsToolkitRegistrationLive,
456).pipe(Layer.provideMerge(McpTransportLive));
apps/server/src/mcp/McpInvocationContext.test.ts+27
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import {2import {
3 EnvironmentId,3 EnvironmentId,
4 McpCapabilityUnavailableError,
4 PreviewAutomationUnavailableError,5 PreviewAutomationUnavailableError,
5 ProviderInstanceId,6 ProviderInstanceId,
6 ThreadId,7 ThreadId,
...@@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav...@@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav
36 expect(error.message).toBe("MCP credential does not grant the preview capability.");37 expect(error.message).toBe("MCP credential does not grant the preview capability.");
37 });38 });
38});39});
40
41it.effect("reports other missing capabilities with the neutral error", () => {
42 const invocation: McpInvocationContext.McpInvocationScope = {
43 environmentId: EnvironmentId.make("environment-1"),
44 threadId: ThreadId.make("thread-1"),
45 providerSessionId: "provider-session-1",
46 providerInstanceId: ProviderInstanceId.make("codex"),
47 capabilities: new Set(["preview"]),
48 issuedAt: 1,
49 };
50
51 return Effect.gen(function* () {
52 const error = yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe(
53 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
54 Effect.flip,
55 );
56
57 expect(error).toBeInstanceOf(McpCapabilityUnavailableError);
58 expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId });
59
60 const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe(
61 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
62 );
63 expect(scope).toBe(invocation);
64 });
65});
apps/server/src/mcp/McpInvocationContext.ts+30-15
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 McpCapabilityUnavailableError,
3 PreviewAutomationUnavailableError,4 PreviewAutomationUnavailableError,
4 type ProviderInstanceId,5 type ProviderInstanceId,
5 type ThreadId,6 type ThreadId,
...@@ -7,7 +8,7 @@ import {...@@ -7,7 +8,7 @@ import {
7import * as Context from "effect/Context";8import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
910
10export type McpCapability = "preview";11export type McpCapability = "preview" | "pull-requests";
1112
12export interface McpInvocationScope {13export interface McpInvocationScope {
13 readonly environmentId: EnvironmentId;14 readonly environmentId: EnvironmentId;
...@@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service<...@@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service<
23 McpInvocationScope24 McpInvocationScope
24>()("t3/mcp/McpInvocationContext") {}25>()("t3/mcp/McpInvocationContext") {}
2526
26export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* (27/** The error a missing capability surfaces as; preview keeps its own so the broker can route it. */
28export type McpCapabilityError<C extends McpCapability> = C extends "preview"
29 ? PreviewAutomationUnavailableError
30 : McpCapabilityUnavailableError;
31
32const missingCapability = (
33 invocation: McpInvocationScope,
27 capability: McpCapability,34 capability: McpCapability,
28) {35): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {
29 const invocation = yield* McpInvocationContext;36 const fields = {
30 if (!invocation.capabilities.has(capability)) {37 environmentId: invocation.environmentId,
31 return yield* new PreviewAutomationUnavailableError({38 threadId: invocation.threadId,
32 capability,39 providerSessionId: invocation.providerSessionId,
33 environmentId: invocation.environmentId,40 providerInstanceId: invocation.providerInstanceId,
34 threadId: invocation.threadId,41 };
35 providerSessionId: invocation.providerSessionId,42 return capability === "preview"
36 providerInstanceId: invocation.providerInstanceId,43 ? new PreviewAutomationUnavailableError({ capability, ...fields })
37 });44 : new McpCapabilityUnavailableError({ capability, ...fields });
38 }45};
39 return invocation;46
40});47export const requireMcpCapability = <const C extends McpCapability>(
48 capability: C,
49): Effect.Effect<McpInvocationScope, McpCapabilityError<C>, McpInvocationContext> =>
50 Effect.flatMap(McpInvocationContext, (invocation) =>
51 invocation.capabilities.has(capability)
52 ? Effect.succeed(invocation)
53 : // The conditional type narrows what the literal argument decided at runtime.
54 Effect.fail(missingCapability(invocation, capability) as McpCapabilityError<C>),
55 ).pipe(Effect.withSpan("mcp.requireCapability"));
apps/server/src/mcp/McpProviderSession.ts+2
...@@ -7,6 +7,8 @@ export interface McpProviderSessionConfig {...@@ -7,6 +7,8 @@ export interface McpProviderSessionConfig {
7 readonly providerInstanceId: ProviderInstanceId;7 readonly providerInstanceId: ProviderInstanceId;
8 readonly endpoint: string;8 readonly endpoint: string;
9 readonly authorizationHeader: string;9 readonly authorizationHeader: string;
10 /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */
11 readonly preview: boolean;
10}12}
1113
12const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();14const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();
apps/server/src/mcp/McpSessionRegistry.test.ts+28
...@@ -39,6 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t...@@ -39,6 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
39 const issued = yield* registry.issue({39 const issued = yield* registry.issue({
40 threadId,40 threadId,
41 providerInstanceId: ProviderInstanceId.make("codex"),41 providerInstanceId: ProviderInstanceId.make("codex"),
42 preview: true,
42 });43 });
43 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");44 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");
44 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");45 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...@@ -54,6 +55,29 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
54 }),55 }),
55);56);
5657
58it.effect("always grants pull-requests and gates preview on the request", () =>
59 Effect.gen(function* () {
60 const registry = yield* makeRegistry(() => 1_000);
61 const withPreview = yield* registry.issue({
62 threadId: ThreadId.make("thread-preview"),
63 providerInstanceId: ProviderInstanceId.make("codex"),
64 preview: true,
65 });
66 const withoutPreview = yield* registry.issue({
67 threadId: ThreadId.make("thread-no-preview"),
68 providerInstanceId: ProviderInstanceId.make("codex"),
69 preview: false,
70 });
71 const capabilitiesOf = (issued: typeof withPreview) =>
72 registry
73 .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, ""))
74 .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort()));
75
76 expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]);
77 expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]);
78 }),
79);
80
57it.effect("builds MCP endpoints from the bound server host", () =>81it.effect("builds MCP endpoints from the bound server host", () =>
58 Effect.gen(function* () {82 Effect.gen(function* () {
59 const cases = [83 const cases = [
...@@ -68,6 +92,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>...@@ -68,6 +92,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>
68 const issued = yield* registry.issue({92 const issued = yield* registry.issue({
69 threadId: ThreadId.make(`thread-${hostname}`),93 threadId: ThreadId.make(`thread-${hostname}`),
70 providerInstanceId: ProviderInstanceId.make("codex"),94 providerInstanceId: ProviderInstanceId.make("codex"),
95 preview: true,
71 });96 });
72 expect(issued.config.endpoint).toBe(expectedEndpoint);97 expect(issued.config.endpoint).toBe(expectedEndpoint);
73 }98 }
...@@ -81,6 +106,7 @@ it.effect("expires credentials once their session stops showing signs of life",...@@ -81,6 +106,7 @@ it.effect("expires credentials once their session stops showing signs of life",
81 const issued = yield* registry.issue({106 const issued = yield* registry.issue({
82 threadId: ThreadId.make("thread-2"),107 threadId: ThreadId.make("thread-2"),
83 providerInstanceId: ProviderInstanceId.make("claude"),108 providerInstanceId: ProviderInstanceId.make("claude"),
109 preview: true,
84 });110 });
85 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");111 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
86 timestamp += 101;112 timestamp += 101;
...@@ -96,6 +122,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",...@@ -96,6 +122,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",
96 const issued = yield* registry.issue({122 const issued = yield* registry.issue({
97 threadId,123 threadId,
98 providerInstanceId: ProviderInstanceId.make("claude"),124 providerInstanceId: ProviderInstanceId.make("claude"),
125 preview: true,
99 });126 });
100 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");127 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
101128
...@@ -117,6 +144,7 @@ it.effect("does not keep credentials of other threads alive", () =>...@@ -117,6 +144,7 @@ it.effect("does not keep credentials of other threads alive", () =>
117 const issued = yield* registry.issue({144 const issued = yield* registry.issue({
118 threadId: ThreadId.make("thread-4"),145 threadId: ThreadId.make("thread-4"),
119 providerInstanceId: ProviderInstanceId.make("codex"),146 providerInstanceId: ProviderInstanceId.make("codex"),
147 preview: true,
120 });148 });
121 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");149 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
122150
apps/server/src/mcp/McpSessionRegistry.ts+10-2
...@@ -14,6 +14,11 @@ import * as McpProviderSession from "./McpProviderSession.ts";...@@ -14,6 +14,11 @@ import * as McpProviderSession from "./McpProviderSession.ts";
14export interface McpCredentialRequest {14export interface McpCredentialRequest {
15 readonly threadId: ThreadId;15 readonly threadId: ThreadId;
16 readonly providerInstanceId: ProviderInstanceId;16 readonly providerInstanceId: ProviderInstanceId;
17 /**
18 * Whether the credential may drive the user's browser. The pull request
19 * toolkit is always granted: it only touches the thread's own links.
20 */
21 readonly preview: boolean;
17}22}
1823
19export interface McpIssuedCredential {24export interface McpIssuedCredential {
...@@ -68,7 +73,7 @@ export interface McpSessionRegistryOptions {...@@ -68,7 +73,7 @@ export interface McpSessionRegistryOptions {
68 *73 *
69 * The bound matters because `/mcp` is mounted outside the environment auth74 * The bound matters because `/mcp` is mounted outside the environment auth
70 * stack and is reachable on whatever host the server binds to, so this token is75 * stack and is reachable on whatever host the server binds to, so this token is
71 * the only thing guarding the preview toolkit on a remote-reachable server.76 * the only thing guarding the `t3-code` toolkits on a remote-reachable server.
72 */77 */
73const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000;78const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000;
7479
...@@ -128,7 +133,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -128,7 +133,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
128 threadId: ThreadId.make(request.threadId),133 threadId: ThreadId.make(request.threadId),
129 providerSessionId,134 providerSessionId,
130 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),135 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
131 capabilities: new Set(["preview"]),136 capabilities: new Set<McpInvocationContext.McpCapability>(
137 request.preview ? ["pull-requests", "preview"] : ["pull-requests"],
138 ),
132 issuedAt,139 issuedAt,
133 };140 };
134 yield* SynchronizedRef.update(state, ({ records }) => {141 yield* SynchronizedRef.update(state, ({ records }) => {
...@@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
144 providerInstanceId: scope.providerInstanceId,151 providerInstanceId: scope.providerInstanceId,
145 endpoint,152 endpoint,
146 authorizationHeader: `Bearer ${rawToken}`,153 authorizationHeader: `Bearer ${rawToken}`,
154 preview: request.preview,
147 },155 },
148 };156 };
149 },157 },
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts created+418
...@@ -0,0 +1,418 @@
1import {
2 EnvironmentId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationCommand,
7 type OrchestrationProjectShell,
8 type OrchestrationThreadShell,
9 type ThreadPullRequestLink,
10} from "@t3tools/contracts";
11import { describe, expect, it } from "@effect/vitest";
12import * as Crypto from "effect/Crypto";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Ref from "effect/Ref";
17import * as Stream from "effect/Stream";
18import type { Tool } from "effect/unstable/ai";
19
20import { OrchestrationCommandInvariantError } from "../../../orchestration/Errors.ts";
21import {
22 OrchestrationEngineService,
23 type OrchestrationEngineShape,
24} from "../../../orchestration/Services/OrchestrationEngine.ts";
25import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
26import * as McpInvocationContext from "../../McpInvocationContext.ts";
27import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts";
28import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts";
29
30const PROJECT_ID = ProjectId.make("project-1");
31const THREAD_ID = ThreadId.make("thread-1");
32
33const testCrypto = Crypto.make({
34 randomBytes: (size) => new Uint8Array(size).fill(7),
35 digest: (_algorithm, data) => Effect.succeed(data),
36});
37
38const invocation = (
39 capabilities: ReadonlyArray<McpInvocationContext.McpCapability>,
40): McpInvocationContext.McpInvocationScope => ({
41 environmentId: EnvironmentId.make("environment-1"),
42 threadId: THREAD_ID,
43 providerSessionId: "provider-session-1",
44 providerInstanceId: ProviderInstanceId.make("codex"),
45 capabilities: new Set(capabilities),
46 issuedAt: 1,
47});
48
49function makeProject(
50 repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = {
51 canonicalKey: "github.com/t3tools/t3code",
52 locator: {
53 source: "git-remote",
54 remoteName: "origin",
55 remoteUrl: "git@github.com:T3Tools/T3Code.git",
56 },
57 provider: "github",
58 displayName: "T3Tools/T3Code",
59 owner: "T3Tools",
60 name: "T3Code",
61 },
62): OrchestrationProjectShell {
63 return {
64 id: PROJECT_ID,
65 title: "Project",
66 workspaceRoot: "/workspace/project",
67 defaultModelSelection: null,
68 scripts: [],
69 repositoryIdentity,
70 createdAt: "2026-08-01T00:00:00.000Z",
71 updatedAt: "2026-08-01T00:00:00.000Z",
72 };
73}
74
75function makeThread(pullRequests: ReadonlyArray<ThreadPullRequestLink>): OrchestrationThreadShell {
76 return {
77 id: THREAD_ID,
78 projectId: PROJECT_ID,
79 title: "Thread",
80 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
81 runtimeMode: "full-access",
82 interactionMode: "default",
83 branch: null,
84 worktreePath: null,
85 pullRequests,
86 latestTurn: null,
87 createdAt: "2026-08-01T00:00:00.000Z",
88 updatedAt: "2026-08-20T00:00:00.000Z",
89 archivedAt: null,
90 settledOverride: null,
91 settledAt: null,
92 session: null,
93 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
94 hasPendingApprovals: false,
95 hasPendingUserInput: false,
96 hasActionableProposedPlan: false,
97 };
98}
99
100function makeLink(
101 number: number,
102 overrides: Partial<ThreadPullRequestLink> & {
103 readonly headBranch?: string;
104 readonly baseBranch?: string;
105 } = {},
106): ThreadPullRequestLink {
107 const { headBranch, baseBranch, ...rest } = overrides;
108 return {
109 host: "github.com",
110 repository: "t3tools/t3code",
111 number,
112 url: `https://github.com/t3tools/t3code/pull/${number}`,
113 source: "manual",
114 linkedAt: "2026-08-10T00:00:00.000Z",
115 snapshot:
116 headBranch === undefined
117 ? null
118 : {
119 state: "open",
120 title: `PR ${number}`,
121 headBranch,
122 baseBranch: baseBranch ?? "main",
123 isDraft: false,
124 updatedAt: null,
125 syncedAt: "2026-08-27T00:00:00.000Z",
126 },
127 stack: null,
128 ...rest,
129 };
130}
131
132interface HarnessOptions {
133 readonly thread?: OrchestrationThreadShell | null;
134 readonly project?: OrchestrationProjectShell | null;
135 readonly reject?: (command: OrchestrationCommand) => OrchestrationCommandInvariantError | null;
136}
137
138const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
139 options: HarnessOptions = {},
140) {
141 const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
142 const thread = options.thread === undefined ? makeThread([]) : options.thread;
143 const project = options.project === undefined ? makeProject() : options.project;
144 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
145 Effect.gen(function* () {
146 const rejection = options.reject?.(command) ?? null;
147 if (rejection !== null) return yield* rejection;
148 yield* Ref.update(commands, (recorded) => [...recorded, command]);
149 return { sequence: 1 };
150 });
151 const dependencies = Layer.mergeAll(
152 Layer.mock(ProjectionSnapshotQuery)({
153 getThreadShellById: (threadId) =>
154 Effect.succeed(threadId === THREAD_ID ? Option.fromNullishOr(thread) : Option.none()),
155 getProjectShellById: () => Effect.succeed(Option.fromNullishOr(project)),
156 }),
157 Layer.mock(OrchestrationEngineService)({
158 readEvents: () => Stream.empty,
159 dispatch,
160 streamDomainEvents: Stream.empty,
161 latestSequence: Effect.succeed(0),
162 }),
163 Layer.succeed(Crypto.Crypto, testCrypto),
164 );
165 const toolkit = yield* PullRequestsToolkit.pipe(
166 Effect.provide(PullRequestsToolkitHandlersLive.pipe(Layer.provide(dependencies))),
167 );
168 const call = <Name extends keyof typeof PullRequestsToolkit.tools>(
169 name: Name,
170 params: Parameters<typeof toolkit.handle<Name>>[1],
171 capabilities: ReadonlyArray<McpInvocationContext.McpCapability> = ["pull-requests"],
172 ) =>
173 toolkit.handle(name, params).pipe(
174 Stream.unwrap,
175 Stream.runCollect,
176 // Failure mode is "error", so a delivered result is always the success shape.
177 Effect.map(
178 (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>,
179 ),
180 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)),
181 Effect.provide(dependencies),
182 );
183 return { commands, call };
184});
185
186describe("pull request toolkit handlers", () => {
187 it.effect("refuses a credential without the pull-requests capability", () =>
188 Effect.gen(function* () {
189 const harness = yield* makeHarness();
190 const error = yield* harness
191 .call("list_thread_pull_requests", {}, ["preview"])
192 .pipe(Effect.flip);
193 expect(error).toMatchObject({
194 _tag: "McpCapabilityUnavailableError",
195 capability: "pull-requests",
196 threadId: THREAD_ID,
197 });
198 expect(yield* Ref.get(harness.commands)).toEqual([]);
199 }),
200 );
201
202 it.effect("links by URL with source agent on the token's thread", () =>
203 Effect.gen(function* () {
204 const harness = yield* makeHarness();
205 const result = yield* harness.call("link_pull_request", {
206 url: "https://github.com/T3Tools/T3Code/pull/123/files",
207 });
208 expect(result).toEqual({
209 host: "github.com",
210 repository: "t3tools/t3code",
211 number: 123,
212 url: "https://github.com/T3Tools/T3Code/pull/123/files",
213 alreadyLinked: false,
214 });
215 expect(yield* Ref.get(harness.commands)).toMatchObject([
216 {
217 type: "thread.pull-request.link",
218 threadId: THREAD_ID,
219 host: "github.com",
220 repository: "t3tools/t3code",
221 number: 123,
222 source: "agent",
223 },
224 ]);
225 }),
226 );
227
228 it.effect("links by repository and number, defaulting the host to the project's", () =>
229 Effect.gen(function* () {
230 const harness = yield* makeHarness();
231 const result = yield* harness.call("link_pull_request", {
232 repository: "T3Tools/Other",
233 number: 7,
234 });
235 expect(result).toEqual({
236 host: "github.com",
237 repository: "t3tools/other",
238 number: 7,
239 url: "https://github.com/t3tools/other/pull/7",
240 alreadyLinked: false,
241 });
242 }),
243 );
244
245 it.effect("builds the URL in the project host's own shape", () =>
246 Effect.gen(function* () {
247 const harness = yield* makeHarness({
248 project: makeProject({
249 canonicalKey: "gitlab.com/group/sub/project",
250 locator: {
251 source: "git-remote",
252 remoteName: "origin",
253 remoteUrl: "git@gitlab.com:group/sub/project.git",
254 },
255 provider: "gitlab",
256 displayName: "group/sub/project",
257 }),
258 });
259 const result = yield* harness.call("link_pull_request", {
260 repository: "group/sub/project",
261 number: 42,
262 });
263 expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42");
264 expect(result.host).toBe("gitlab.com");
265 }),
266 );
267
268 it.effect("rejects a target that names neither a URL nor repository and number", () =>
269 Effect.gen(function* () {
270 const harness = yield* makeHarness();
271 const error = yield* harness
272 .call("link_pull_request", { repository: "x/y" })
273 .pipe(Effect.flip);
274 expect(error).toMatchObject({ _tag: "PullRequestTargetIncompleteError" });
275 const unknown = yield* harness
276 .call("link_pull_request", {
277 url: "https://github.com/t3tools/t3code/issues/1?token=private-value",
278 })
279 .pipe(Effect.flip);
280 expect(unknown).toMatchObject({ _tag: "PullRequestUrlInvalidError" });
281 expect(unknown.message).not.toContain("private-value");
282 expect(yield* Ref.get(harness.commands)).toEqual([]);
283 }),
284 );
285
286 it.effect("treats a duplicate link as alreadyLinked rather than an error", () =>
287 Effect.gen(function* () {
288 const harness = yield* makeHarness({
289 thread: makeThread([makeLink(123)]),
290 reject: (command) =>
291 command.type === "thread.pull-request.link"
292 ? new OrchestrationCommandInvariantError({
293 commandType: command.type,
294 detail: "already linked",
295 })
296 : null,
297 });
298 const result = yield* harness.call("link_pull_request", {
299 url: "https://github.com/t3tools/t3code/pull/123",
300 });
301 expect(result.alreadyLinked).toBe(true);
302 }),
303 );
304
305 it.effect("unlinks a linked pull request and reports a missing one as wasLinked=false", () =>
306 Effect.gen(function* () {
307 const harness = yield* makeHarness({
308 thread: makeThread([makeLink(5)]),
309 reject: (command) =>
310 command.type === "thread.pull-request.unlink" && command.number !== 5
311 ? new OrchestrationCommandInvariantError({
312 commandType: command.type,
313 detail: "not linked",
314 })
315 : null,
316 });
317 const linked = yield* harness.call("unlink_pull_request", {
318 repository: "t3tools/t3code",
319 number: 5,
320 });
321 expect(linked).toEqual({
322 host: "github.com",
323 repository: "t3tools/t3code",
324 number: 5,
325 wasLinked: true,
326 });
327 const missing = yield* harness.call("unlink_pull_request", {
328 url: "https://github.com/t3tools/t3code/pull/9",
329 });
330 expect(missing.wasLinked).toBe(false);
331 expect(yield* Ref.get(harness.commands)).toMatchObject([
332 { type: "thread.pull-request.unlink", number: 5 },
333 ]);
334 }),
335 );
336
337 it.effect("fails cleanly when the token's thread no longer exists", () =>
338 Effect.gen(function* () {
339 const harness = yield* makeHarness({ thread: null });
340 const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip);
341 expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID });
342 }),
343 );
344
345 it.effect("lists visible links with host state and derived chain order", () =>
346 Effect.gen(function* () {
347 const harness = yield* makeHarness({
348 thread: makeThread([
349 makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }),
350 makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }),
351 makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }),
352 makeLink(9, { source: "stack-dismissed" }),
353 makeLink(10),
354 ]),
355 });
356 const result = yield* harness.call("list_thread_pull_requests", {});
357 expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]);
358 expect(result.pullRequests[0]).toEqual({
359 host: "github.com",
360 repository: "t3tools/t3code",
361 number: 3,
362 url: "https://github.com/t3tools/t3code/pull/3",
363 source: "agent",
364 state: "open",
365 title: "PR 3",
366 headBranch: "feat-c",
367 baseBranch: "feat-b",
368 isDraft: false,
369 stack: { kind: "derived", position: 3, size: 3 },
370 });
371 expect(result.pullRequests[3]).toMatchObject({
372 number: 10,
373 state: null,
374 title: null,
375 headBranch: null,
376 stack: null,
377 });
378 expect(result.chains).toEqual([
379 { kind: "derived", numbers: [1, 2, 3] },
380 { kind: "derived", numbers: [10] },
381 ]);
382 }),
383 );
384});
385
386describe("listThreadPullRequests", () => {
387 it("reports a native stack position for each member", () => {
388 const stack = {
389 kind: "native" as const,
390 id: "stack-1",
391 number: 1,
392 url: "https://github.com/t3tools/t3code/stack/1",
393 base: "main",
394 layers: [
395 { number: 1, headBranch: "a", state: "open" as const },
396 { number: 2, headBranch: "b", state: "open" as const },
397 ],
398 };
399 const result = listThreadPullRequests({
400 pullRequests: [
401 makeLink(2, { stack, source: "stack" }),
402 makeLink(1, { stack, source: "created" }),
403 ],
404 });
405 expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([
406 [2, { kind: "native", position: 2, size: 2 }],
407 [1, { kind: "native", position: 1, size: 2 }],
408 ]);
409 expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]);
410 });
411});
412
413it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => {
414 const cause = new Error("database internals");
415 const failure = new PullRequestLinkFailedError({ cause });
416 expect(failure.message).toBe("Could not link the pull request.");
417 expect(failure.cause).toBe(cause);
418});
apps/server/src/mcp/toolkits/pullRequests/handlers.ts created+243
...@@ -0,0 +1,243 @@
1import {
2 CommandId,
3 pullRequestHostOf,
4 type OrchestrationProjectShell,
5 type OrchestrationThreadShell,
6 type SourceControlProviderKind,
7 type ThreadId,
8 type ThreadPullRequestLink,
9} from "@t3tools/contracts";
10import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
11import {
12 resolveThreadPullRequestChains,
13 threadPullRequestKeyOf,
14 visibleThreadPullRequests,
15} from "@t3tools/shared/threadPullRequests";
16import * as Cause from "effect/Cause";
17import * as Crypto from "effect/Crypto";
18import * as Effect from "effect/Effect";
19import * as Option from "effect/Option";
20
21import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts";
22import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
23import * as McpInvocationContext from "../../McpInvocationContext.ts";
24import {
25 type ListThreadPullRequestsResult,
26 PullRequestLinkFailedError,
27 PullRequestUrlInvalidError,
28 PullRequestTargetIncompleteError,
29 PullRequestHostRequiredError,
30 PullRequestUnlinkFailedError,
31 PullRequestListFailedError,
32 type PullRequestTargetInput,
33 PullRequestThreadNotFoundError,
34 PullRequestsToolkit,
35 type ThreadPullRequestEntry,
36} from "./tools.ts";
37
38interface ResolvedTarget {
39 readonly host: string;
40 readonly repository: string;
41 readonly number: number;
42 readonly url: string;
43}
44
45/** The project's host and provider supply defaults for a repository-and-number input. */
46function projectHostAndProvider(project: OrchestrationProjectShell | undefined): {
47 readonly host: string | null;
48 readonly kind: SourceControlProviderKind | null;
49} {
50 const identity = project?.repositoryIdentity;
51 const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null;
52 if (!identity || kind === null) return { host: null, kind: null };
53 return {
54 host: pullRequestHostOf(identity, kind),
55 kind,
56 };
57}
58
59/**
60 * Turns whichever shape the agent passed into one host-level identity. A URL
61 * wins outright; otherwise the repository and number are completed with the
62 * thread's project host, which is where an agent working in that checkout
63 * almost always opened the pull request.
64 */
65const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
66 input: PullRequestTargetInput,
67 project: OrchestrationProjectShell | undefined,
68) {
69 if (input.url !== undefined) {
70 const parsed = parseChangeRequestUrl(input.url);
71 if (parsed === null) {
72 return yield* new PullRequestUrlInvalidError({});
73 }
74 return { ...parsed, url: input.url } satisfies ResolvedTarget;
75 }
76 if (input.repository === undefined || input.number === undefined) {
77 return yield* new PullRequestTargetIncompleteError({});
78 }
79 const projectHost = projectHostAndProvider(project);
80 const host = (input.host ?? projectHost.host)?.toLowerCase();
81 if (host === undefined) {
82 return yield* new PullRequestHostRequiredError({});
83 }
84 const repository = input.repository.toLowerCase();
85 const url =
86 changeRequestUrlFor(
87 // The project's kind only describes its own host; another host gets no URL guess.
88 host === projectHost.host ? projectHost.kind : null,
89 host,
90 repository,
91 input.number,
92 ) ?? `https://${host}/${repository}/pull/${input.number}`;
93 return { host, repository, number: input.number, url } satisfies ResolvedTarget;
94});
95
96function entryOf(
97 link: ThreadPullRequestLink,
98 chains: ReturnType<typeof resolveThreadPullRequestChains>,
99): ThreadPullRequestEntry {
100 const key = threadPullRequestKeyOf(link);
101 let stack: ThreadPullRequestEntry["stack"] = null;
102 for (const chain of chains) {
103 if (chain.layers.length < 2) continue;
104 const index = chain.layers.findIndex((layer) => threadPullRequestKeyOf(layer) === key);
105 if (index !== -1) {
106 stack = { kind: chain.kind, position: index + 1, size: chain.layers.length };
107 break;
108 }
109 }
110 return {
111 host: link.host,
112 repository: link.repository,
113 number: link.number,
114 url: link.url,
115 source: link.source,
116 state: link.snapshot?.state ?? null,
117 title: link.snapshot?.title ?? null,
118 headBranch: link.snapshot?.headBranch ?? null,
119 baseBranch: link.snapshot?.baseBranch ?? null,
120 isDraft: link.snapshot?.isDraft ?? null,
121 stack,
122 };
123}
124
125/** What the tools report from a thread shell; exported so the shape is testable without a layer. */
126export function listThreadPullRequests(
127 thread: Pick<OrchestrationThreadShell, "pullRequests">,
128): ListThreadPullRequestsResult {
129 const chains = resolveThreadPullRequestChains(thread.pullRequests);
130 return {
131 pullRequests: visibleThreadPullRequests(thread.pullRequests).map((link) =>
132 entryOf(link, chains),
133 ),
134 chains: chains.map((chain) => ({
135 kind: chain.kind,
136 numbers: chain.layers.map((layer) => layer.number),
137 })),
138 };
139}
140
141const make = Effect.gen(function* () {
142 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
143 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
144 const crypto = yield* Crypto.Crypto;
145
146 const commandId = (tag: string, threadId: ThreadId) =>
147 crypto.randomUUIDv4.pipe(
148 Effect.orDie,
149 Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)),
150 );
151
152 const requireThread = Effect.fn("PullRequestsToolkit.requireThread")(function* (
153 Failure:
154 | typeof PullRequestLinkFailedError
155 | typeof PullRequestUnlinkFailedError
156 | typeof PullRequestListFailedError,
157 ) {
158 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
159 const thread = yield* snapshots
160 .getThreadShellById(scope.threadId)
161 .pipe(Effect.mapError((cause) => new Failure({ cause })));
162 if (Option.isNone(thread)) {
163 return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId });
164 }
165 return thread.value;
166 });
167
168 const projectOf = (
169 thread: OrchestrationThreadShell,
170 Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError,
171 ) =>
172 snapshots.getProjectShellById(thread.projectId).pipe(
173 Effect.map(Option.getOrUndefined),
174 Effect.mapError((cause) => new Failure({ cause })),
175 );
176
177 const dispatchFailure =
178 (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) =>
179 <E>(
180 cause: Cause.Cause<E>,
181 ): Effect.Effect<never, PullRequestLinkFailedError | PullRequestUnlinkFailedError> =>
182 Cause.hasInterruptsOnly(cause)
183 ? Effect.failCause(cause as Cause.Cause<never>)
184 : Effect.fail(new Failure({ cause }));
185
186 return PullRequestsToolkit.of({
187 link_pull_request: (input) =>
188 Effect.gen(function* () {
189 const thread = yield* requireThread(PullRequestLinkFailedError);
190 const project = yield* projectOf(thread, PullRequestLinkFailedError);
191 const target = yield* resolveTarget(input, project);
192 const alreadyLinked = yield* engine
193 .dispatch({
194 type: "thread.pull-request.link",
195 commandId: yield* commandId("mcp-pr-link", thread.id),
196 threadId: thread.id,
197 host: target.host,
198 repository: target.repository,
199 number: target.number,
200 url: target.url,
201 source: "agent",
202 })
203 .pipe(
204 Effect.as(false),
205 // The decider rejects a second link of the same PR; for the agent that is
206 // the outcome it asked for, not an error.
207 Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(true) }),
208 Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)),
209 );
210 return { ...target, alreadyLinked };
211 }),
212 unlink_pull_request: (input) =>
213 Effect.gen(function* () {
214 const thread = yield* requireThread(PullRequestUnlinkFailedError);
215 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
216 const target = yield* resolveTarget(input, project);
217 const wasLinked = yield* engine
218 .dispatch({
219 type: "thread.pull-request.unlink",
220 commandId: yield* commandId("mcp-pr-unlink", thread.id),
221 threadId: thread.id,
222 host: target.host,
223 repository: target.repository,
224 number: target.number,
225 })
226 .pipe(
227 Effect.as(true),
228 Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(false) }),
229 Effect.catchCause(dispatchFailure(PullRequestUnlinkFailedError)),
230 );
231 return {
232 host: target.host,
233 repository: target.repository,
234 number: target.number,
235 wasLinked,
236 };
237 }),
238 list_thread_pull_requests: () =>
239 requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)),
240 });
241});
242
243export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer(make);
apps/server/src/mcp/toolkits/pullRequests/tools.ts created+231
...@@ -0,0 +1,231 @@
1import {
2 McpCapabilityUnavailableError,
3 PositiveInt,
4 PullRequestState,
5 ThreadPullRequestLinkSource,
6 TrimmedNonEmptyString,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9import * as Tool from "effect/unstable/ai/Tool";
10import * as Toolkit from "effect/unstable/ai/Toolkit";
11
12import * as McpInvocationContext from "../../McpInvocationContext.ts";
13import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts";
14import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
15
16const dependencies = [
17 McpInvocationContext.McpInvocationContext,
18 OrchestrationEngine.OrchestrationEngineService,
19 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
20];
21
22const REGISTER_EVERY_PR =
23 "Register every pull request you open for this thread, including each layer of a stack, right after creating it.";
24
25/**
26 * Either the pull request's URL or its repository and number. Both forms
27 * resolve to the same host-level identity, so the agent can pass whichever
28 * the host CLI handed back.
29 */
30export const PullRequestTargetInput = Schema.Struct({
31 url: Schema.optional(
32 TrimmedNonEmptyString.annotate({
33 description:
34 "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.",
35 }),
36 ),
37 repository: Schema.optional(
38 TrimmedNonEmptyString.annotate({
39 description:
40 "Repository path below the host, for example owner/repo. Required with number when url is omitted.",
41 }),
42 ),
43 number: Schema.optional(
44 PositiveInt.annotate({
45 description: "Pull request number. Required with repository when url is omitted.",
46 }),
47 ),
48 host: Schema.optional(
49 TrimmedNonEmptyString.annotate({
50 description:
51 "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.",
52 }),
53 ),
54});
55export type PullRequestTargetInput = typeof PullRequestTargetInput.Type;
56
57export class PullRequestUrlInvalidError extends Schema.TaggedError<PullRequestUrlInvalidError>()(
58 "PullRequestUrlInvalidError",
59 {},
60) {
61 override get message(): string {
62 return "This is not a recognised pull request URL. Pass repository and number instead.";
63 }
64}
65
66export class PullRequestTargetIncompleteError extends Schema.TaggedError<PullRequestTargetIncompleteError>()(
67 "PullRequestTargetIncompleteError",
68 {},
69) {
70 override get message(): string {
71 return "Pass either url, or both repository and number.";
72 }
73}
74
75export class PullRequestHostRequiredError extends Schema.TaggedError<PullRequestHostRequiredError>()(
76 "PullRequestHostRequiredError",
77 {},
78) {
79 override get message(): string {
80 return "This thread's project has no recognised remote. Pass host or url.";
81 }
82}
83
84export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(
85 "PullRequestThreadNotFoundError",
86 { threadId: Schema.String },
87) {
88 override get message(): string {
89 return `Thread ${this.threadId} was not found.`;
90 }
91}
92
93export class PullRequestLinkFailedError extends Schema.TaggedError<PullRequestLinkFailedError>()(
94 "PullRequestLinkFailedError",
95 { cause: Schema.Defect() },
96) {
97 override get message(): string {
98 return "Could not link the pull request.";
99 }
100}
101
102export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequestUnlinkFailedError>()(
103 "PullRequestUnlinkFailedError",
104 { cause: Schema.Defect() },
105) {
106 override get message(): string {
107 return "Could not unlink the pull request.";
108 }
109}
110
111export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
112 "PullRequestListFailedError",
113 { cause: Schema.Defect() },
114) {
115 override get message(): string {
116 return "Could not list the pull request.";
117 }
118}
119
120export const PullRequestToolError = Schema.Union([
121 McpCapabilityUnavailableError,
122 PullRequestUrlInvalidError,
123 PullRequestTargetIncompleteError,
124 PullRequestHostRequiredError,
125 PullRequestThreadNotFoundError,
126 PullRequestLinkFailedError,
127 PullRequestUnlinkFailedError,
128 PullRequestListFailedError,
129]);
130export type PullRequestToolError = typeof PullRequestToolError.Type;
131
132const PullRequestIdentity = {
133 host: Schema.String,
134 repository: Schema.String,
135 number: Schema.Int,
136 url: Schema.String,
137};
138
139export const LinkPullRequestResult = Schema.Struct({
140 ...PullRequestIdentity,
141 alreadyLinked: Schema.Boolean.annotate({
142 description: "True when the pull request was linked to this thread before the call.",
143 }),
144});
145export type LinkPullRequestResult = typeof LinkPullRequestResult.Type;
146
147export const UnlinkPullRequestResult = Schema.Struct({
148 host: Schema.String,
149 repository: Schema.String,
150 number: Schema.Int,
151 wasLinked: Schema.Boolean.annotate({
152 description: "False when the pull request was not linked to this thread to begin with.",
153 }),
154});
155export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type;
156
157export const ThreadPullRequestEntry = Schema.Struct({
158 ...PullRequestIdentity,
159 source: ThreadPullRequestLinkSource,
160 state: Schema.NullOr(PullRequestState),
161 title: Schema.NullOr(Schema.String),
162 headBranch: Schema.NullOr(Schema.String),
163 baseBranch: Schema.NullOr(Schema.String),
164 isDraft: Schema.NullOr(Schema.Boolean),
165 stack: Schema.NullOr(
166 Schema.Struct({
167 kind: Schema.Literals(["native", "derived"]),
168 /** 1-based, bottom of the stack first. */
169 position: Schema.Int,
170 size: Schema.Int,
171 }),
172 ),
173});
174export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type;
175
176export const ListThreadPullRequestsResult = Schema.Struct({
177 pullRequests: Schema.Array(ThreadPullRequestEntry),
178 chains: Schema.Array(
179 Schema.Struct({
180 kind: Schema.Literals(["native", "derived"]),
181 /** Bottom to top. */
182 numbers: Schema.Array(Schema.Int),
183 }),
184 ),
185});
186export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type;
187
188const LinkPullRequestTool = Tool.make("link_pull_request", {
189 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.`,
190 parameters: PullRequestTargetInput,
191 success: LinkPullRequestResult,
192 failure: PullRequestToolError,
193 dependencies,
194})
195 .annotate(Tool.Title, "Link pull request to thread")
196 .annotate(Tool.Readonly, false)
197 .annotate(Tool.Destructive, false)
198 .annotate(Tool.Idempotent, true)
199 .annotate(Tool.OpenWorld, false);
200
201const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
202 description:
203 "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.",
204 parameters: PullRequestTargetInput,
205 success: UnlinkPullRequestResult,
206 failure: PullRequestToolError,
207 dependencies,
208})
209 .annotate(Tool.Title, "Unlink pull request from thread")
210 .annotate(Tool.Readonly, false)
211 .annotate(Tool.Destructive, true)
212 .annotate(Tool.Idempotent, true)
213 .annotate(Tool.OpenWorld, false);
214
215const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
216 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}`,
217 success: ListThreadPullRequestsResult,
218 failure: PullRequestToolError,
219 dependencies,
220})
221 .annotate(Tool.Title, "List thread pull requests")
222 .annotate(Tool.Readonly, true)
223 .annotate(Tool.Destructive, false)
224 .annotate(Tool.Idempotent, true)
225 .annotate(Tool.OpenWorld, false);
226
227export const PullRequestsToolkit = Toolkit.make(
228 LinkPullRequestTool,
229 UnlinkPullRequestTool,
230 ListThreadPullRequestsTool,
231);
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+35-5
...@@ -60,7 +60,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value);...@@ -60,7 +60,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value);
60const asTurnId = (value: string): TurnId => TurnId.make(value);60const asTurnId = (value: string): TurnId => TurnId.make(value);
61const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);61const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
6262
63function makeOrchestrationLayer(databasePath?: string) {63function makeOrchestrationLayer(
64 databasePath?: string,
65 repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"],
66) {
64 const persistence = databasePath67 const persistence = databasePath
65 ? makeSqlitePersistenceLive(databasePath)68 ? makeSqlitePersistenceLive(databasePath)
66 : SqlitePersistenceMemory;69 : SqlitePersistenceMemory;
...@@ -78,15 +81,27 @@ function makeOrchestrationLayer(databasePath?: string) {...@@ -78,15 +81,27 @@ function makeOrchestrationLayer(databasePath?: string) {
78 Layer.provide(ThreadPlanProgress.layer),81 Layer.provide(ThreadPlanProgress.layer),
79 Layer.provide(OrchestrationEventStoreLive),82 Layer.provide(OrchestrationEventStoreLive),
80 Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),83 Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
81 Layer.provide(RepositoryIdentityResolver.layer),84 Layer.provide(
85 repositoryIdentityResolver
86 ? Layer.succeed(
87 RepositoryIdentityResolver.RepositoryIdentityResolver,
88 repositoryIdentityResolver,
89 )
90 : RepositoryIdentityResolver.layer,
91 ),
82 Layer.provide(persistence),92 Layer.provide(persistence),
83 Layer.provideMerge(ServerConfigLayer),93 Layer.provideMerge(ServerConfigLayer),
84 Layer.provideMerge(NodeServices.layer),94 Layer.provideMerge(NodeServices.layer),
85 );95 );
86}96}
8797
88async function createOrchestrationSystem(databasePath?: string) {98async function createOrchestrationSystem(
89 const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath));99 databasePath?: string,
100 repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"],
101) {
102 const runtime = ManagedRuntime.make(
103 makeOrchestrationLayer(databasePath, repositoryIdentityResolver),
104 );
90 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));105 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
91 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));106 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
92 return {107 return {
...@@ -373,6 +388,7 @@ describe("OrchestrationEngine", () => {...@@ -373,6 +388,7 @@ describe("OrchestrationEngine", () => {
373 runtimeMode: "full-access" as const,388 runtimeMode: "full-access" as const,
374 branch: null,389 branch: null,
375 worktreePath: null,390 worktreePath: null,
391 pullRequests: [],
376 latestTurn: null,392 latestTurn: null,
377 createdAt: "2026-03-03T00:00:02.000Z",393 createdAt: "2026-03-03T00:00:02.000Z",
378 updatedAt: "2026-03-03T00:00:03.000Z",394 updatedAt: "2026-03-03T00:00:03.000Z",
...@@ -1009,7 +1025,20 @@ describe("OrchestrationEngine", () => {...@@ -1009,7 +1025,20 @@ describe("OrchestrationEngine", () => {
1009 it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)(1025 it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)(
1010 "rejects PR discovery completed after a newer %s command",1026 "rejects PR discovery completed after a newer %s command",
1011 async (change) => {1027 async (change) => {
1012 const system = await createOrchestrationSystem();1028 const system = await createOrchestrationSystem(undefined, {
1029 resolve: (workspaceRoot) =>
1030 Effect.succeed({
1031 canonicalKey: "example.test/owner/repository",
1032 provider: "github",
1033 displayName: "owner/repository",
1034 rootPath: workspaceRoot,
1035 locator: {
1036 source: "git-remote",
1037 remoteName: "origin",
1038 remoteUrl: "https://example.test/owner/repository.git",
1039 },
1040 }),
1041 });
1013 try {1042 try {
1014 const projectId = ProjectId.make("pr-race-project");1043 const projectId = ProjectId.make("pr-race-project");
1015 const threadId = ThreadId.make("pr-race-thread");1044 const threadId = ThreadId.make("pr-race-thread");
...@@ -1058,6 +1087,7 @@ describe("OrchestrationEngine", () => {...@@ -1058,6 +1087,7 @@ describe("OrchestrationEngine", () => {
1058 linkedPullRequest: previous,1087 linkedPullRequest: previous,
1059 }),1088 }),
1060 );1089 );
1090 expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous);
1061 const metadataChanges = {1091 const metadataChanges = {
1062 unlink: { linkedPullRequest: null },1092 unlink: { linkedPullRequest: null },
1063 relink: {1093 relink: {
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+12
...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
13import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";14import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
14import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";15import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
15import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";16import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
...@@ -84,6 +85,16 @@ describe("OrchestrationReactor", () => {...@@ -84,6 +85,16 @@ describe("OrchestrationReactor", () => {
84 drain: Effect.void,85 drain: Effect.void,
85 }),86 }),
86 ),87 ),
88 Layer.provideMerge(
89 Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, {
90 start: () => {
91 started.push("pull-request-sync-reactor");
92 return Effect.void;
93 },
94 drain: Effect.void,
95 requestSync: () => Effect.void,
96 }),
97 ),
87 Layer.provideMerge(98 Layer.provideMerge(
88 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {99 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
89 publishThread: () => Effect.void,100 publishThread: () => Effect.void,
...@@ -107,6 +118,7 @@ describe("OrchestrationReactor", () => {...@@ -107,6 +118,7 @@ describe("OrchestrationReactor", () => {
107 "thread-deletion-reactor",118 "thread-deletion-reactor",
108 "thread-pull-request-reactor",119 "thread-pull-request-reactor",
109 "thread-settlement-reactor",120 "thread-settlement-reactor",
121 "pull-request-sync-reactor",
110 "agent-awareness-relay",122 "agent-awareness-relay",
111 ]);123 ]);
112124
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";...@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";12import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
13import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";14import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
14import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";15import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1516
...@@ -19,6 +20,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -19,6 +20,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
19 const checkpointReactor = yield* CheckpointReactor;20 const checkpointReactor = yield* CheckpointReactor;
20 const threadDeletionReactor = yield* ThreadDeletionReactor;21 const threadDeletionReactor = yield* ThreadDeletionReactor;
21 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;22 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
23 const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
22 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;24 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
23 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;25 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
2426
...@@ -29,6 +31,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -29,6 +31,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
29 yield* threadDeletionReactor.start();31 yield* threadDeletionReactor.start();
30 yield* threadPullRequestReactor.start();32 yield* threadPullRequestReactor.start();
31 yield* threadSettlementReactor.start();33 yield* threadSettlementReactor.start();
34 yield* pullRequestSyncReactor.start();
32 yield* agentAwarenessRelay.start();35 yield* agentAwarenessRelay.start();
33 });36 });
3437
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+237
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 MessageId,8 MessageId,
9 ProjectId,9 ProjectId,
10 ThreadId,10 ThreadId,
11 type ThreadPullRequestSnapshot,
11 ThreadLinkedPullRequest,12 ThreadLinkedPullRequest,
12 TurnId,13 TurnId,
13 ProviderInstanceId,14 ProviderInstanceId,
...@@ -686,6 +687,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))(...@@ -686,6 +687,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))(
686 },687 },
687);688);
688689
690it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull-requests-")))(
691 "OrchestrationProjectionPipeline pull request links",
692 (it) => {
693 it.effect("projects link, sync, unlink, legacy replay and delete into the link table", () =>
694 Effect.gen(function* () {
695 const projectionPipeline = yield* OrchestrationProjectionPipeline;
696 const eventStore = yield* OrchestrationEventStore;
697 const sql = yield* SqlClient.SqlClient;
698 const threadId = ThreadId.make("thread-pr");
699 const projectId = ProjectId.make("project-pr");
700 const t0 = "2026-01-01T00:00:00.000Z";
701 let counter = 0;
702 const base = (occurredAt: string) => {
703 counter += 1;
704 return {
705 eventId: EventId.make(`evt-pr-${counter}`),
706 aggregateKind: "thread",
707 aggregateId: threadId,
708 occurredAt,
709 commandId: CommandId.make(`cmd-pr-${counter}`),
710 causationEventId: null,
711 correlationId: CommandId.make(`cmd-pr-${counter}`),
712 metadata: {},
713 } as const;
714 };
715 const readLinks = () =>
716 sql<{
717 readonly host: string;
718 readonly repository: string;
719 readonly number: number;
720 readonly source: string;
721 readonly linkedAt: string;
722 readonly snapshotJson: string | null;
723 readonly stackJson: string | null;
724 }>`
725 SELECT
726 host,
727 repository,
728 number,
729 source,
730 linked_at AS "linkedAt",
731 snapshot_json AS "snapshotJson",
732 stack_json AS "stackJson"
733 FROM projection_thread_pull_requests
734 WHERE thread_id = ${threadId}
735 ORDER BY number ASC
736 `;
737 const readThreadUpdatedAt = () =>
738 sql<{ readonly updatedAt: string }>`
739 SELECT updated_at AS "updatedAt" FROM projection_threads WHERE thread_id = ${threadId}
740 `;
741
742 yield* eventStore.append({
743 ...base(t0),
744 type: "thread.created",
745 payload: {
746 threadId,
747 projectId,
748 title: "Thread PR",
749 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
750 runtimeMode: "full-access",
751 branch: null,
752 worktreePath: null,
753 createdAt: t0,
754 updatedAt: t0,
755 },
756 });
757
758 // Legacy single-link event replays into a manual row with the URL host.
759 yield* eventStore.append({
760 ...base("2026-01-01T00:00:01.000Z"),
761 type: "thread.meta-updated",
762 payload: {
763 threadId,
764 linkedPullRequest: {
765 projectId,
766 repository: "web",
767 number: 41,
768 url: "https://org-a.visualstudio.com/DefaultCollection/project/_git/web/pullrequest/41",
769 },
770 updatedAt: "2026-01-01T00:00:01.000Z",
771 },
772 });
773 yield* eventStore.append({
774 ...base("2026-01-01T00:00:02.000Z"),
775 type: "thread.pull-request-linked",
776 payload: {
777 threadId,
778 link: {
779 host: "github.com",
780 repository: "pingdotgg/t3code",
781 number: 42,
782 url: "https://github.com/pingdotgg/t3code/pull/42",
783 source: "created",
784 linkedAt: "2026-01-01T00:00:02.000Z",
785 snapshot: null,
786 stack: null,
787 },
788 updatedAt: "2026-01-01T00:00:02.000Z",
789 },
790 });
791 yield* projectionPipeline.bootstrap;
792
793 assert.deepEqual(yield* readLinks(), [
794 {
795 host: "dev.azure.com",
796 repository: "org-a/project/_git/web",
797 number: 41,
798 source: "manual",
799 linkedAt: "2026-01-01T00:00:01.000Z",
800 snapshotJson: null,
801 stackJson: null,
802 },
803 {
804 host: "github.com",
805 repository: "pingdotgg/t3code",
806 number: 42,
807 source: "created",
808 linkedAt: "2026-01-01T00:00:02.000Z",
809 snapshotJson: null,
810 stackJson: null,
811 },
812 ]);
813 assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:02.000Z" }]);
814
815 // Sync fills snapshot/stack on the matching row; a sync for an unknown
816 // link is ignored.
817 const snapshot: ThreadPullRequestSnapshot = {
818 state: "open",
819 title: "Add links",
820 headBranch: "feat/links",
821 baseBranch: "main",
822 isDraft: false,
823 updatedAt: "2026-01-01T00:00:02.500Z",
824 syncedAt: "2026-01-01T00:00:03.000Z",
825 };
826 yield* eventStore.append({
827 ...base("2026-01-01T00:00:03.000Z"),
828 type: "thread.pull-request-synced",
829 payload: {
830 threadId,
831 host: "github.com",
832 repository: "pingdotgg/t3code",
833 number: 42,
834 snapshot,
835 stack: null,
836 updatedAt: "2026-01-01T00:00:03.000Z",
837 },
838 });
839 yield* eventStore.append({
840 ...base("2026-01-01T00:00:03.500Z"),
841 type: "thread.pull-request-synced",
842 payload: {
843 threadId,
844 host: "github.com",
845 repository: "pingdotgg/t3code",
846 number: 99,
847 snapshot,
848 stack: null,
849 updatedAt: "2026-01-01T00:00:03.500Z",
850 },
851 });
852 yield* projectionPipeline.bootstrap;
853
854 const synced = yield* readLinks();
855 assert.equal(synced.length, 2);
856 assert.equal(synced[0]?.snapshotJson, null);
857 // @effect-diagnostics-next-line preferSchemaOverJson:off
858 assert.deepEqual(JSON.parse(synced[1]?.snapshotJson ?? "null"), snapshot);
859 assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:03.000Z" }]);
860
861 // A legacy null clears only the manual row; created/agent/stack rows stay.
862 yield* eventStore.append({
863 ...base("2026-01-01T00:00:04.000Z"),
864 type: "thread.meta-updated",
865 payload: {
866 threadId,
867 linkedPullRequest: null,
868 updatedAt: "2026-01-01T00:00:04.000Z",
869 },
870 });
871 yield* projectionPipeline.bootstrap;
872 assert.deepEqual(
873 (yield* readLinks()).map((row) => row.number),
874 [42],
875 );
876
877 yield* eventStore.append({
878 ...base("2026-01-01T00:00:05.000Z"),
879 type: "thread.pull-request-unlinked",
880 payload: {
881 threadId,
882 host: "GitHub.COM",
883 repository: "PingDotGG/T3Code",
884 number: 42,
885 updatedAt: "2026-01-01T00:00:05.000Z",
886 },
887 });
888 yield* projectionPipeline.bootstrap;
889 assert.deepEqual(yield* readLinks(), []);
890 assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]);
891
892 // Deleting the thread clears whatever links it still had.
893 yield* eventStore.append({
894 ...base("2026-01-01T00:00:06.000Z"),
895 type: "thread.pull-request-linked",
896 payload: {
897 threadId,
898 link: {
899 host: "github.com",
900 repository: "pingdotgg/t3code",
901 number: 43,
902 url: "https://github.com/pingdotgg/t3code/pull/43",
903 source: "agent",
904 linkedAt: "2026-01-01T00:00:06.000Z",
905 snapshot: null,
906 stack: null,
907 },
908 updatedAt: "2026-01-01T00:00:06.000Z",
909 },
910 });
911 yield* eventStore.append({
912 ...base("2026-01-01T00:00:07.000Z"),
913 type: "thread.deleted",
914 payload: {
915 threadId,
916 deletedAt: "2026-01-01T00:00:07.000Z",
917 },
918 });
919 yield* projectionPipeline.bootstrap;
920 assert.deepEqual(yield* readLinks(), []);
921 }),
922 );
923 },
924);
925
689it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))(926it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))(
690 "OrchestrationProjectionPipeline",927 "OrchestrationProjectionPipeline",
691 (it) => {928 (it) => {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+104
...@@ -16,6 +16,10 @@ import * as Path from "effect/Path";...@@ -16,6 +16,10 @@ import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
17import * as Stream from "effect/Stream";17import * as Stream from "effect/Stream";
18import * as SqlClient from "effect/unstable/sql/SqlClient";18import * as SqlClient from "effect/unstable/sql/SqlClient";
19import {
20 legacyThreadPullRequestKey,
21 threadPullRequestKeysEqual,
22} from "@t3tools/shared/threadPullRequests";
1923
20import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";24import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
21import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";25import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
...@@ -32,6 +36,7 @@ import {...@@ -32,6 +36,7 @@ import {
32 type ProjectionThreadProposedPlan,36 type ProjectionThreadProposedPlan,
33 ProjectionThreadProposedPlanRepository,37 ProjectionThreadProposedPlanRepository,
34} from "../../persistence/Services/ProjectionThreadProposedPlans.ts";38} from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
39import * as ProjectionThreadPullRequests from "../../persistence/ProjectionThreadPullRequests.ts";
35import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";40import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
36import {41import {
37 type ProjectionTurn,42 type ProjectionTurn,
...@@ -480,6 +485,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -480,6 +485,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
480 const projectionThreadRepository = yield* ProjectionThreadRepository;485 const projectionThreadRepository = yield* ProjectionThreadRepository;
481 const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository;486 const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository;
482 const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository;487 const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository;
488 const projectionThreadPullRequestRepository =
489 yield* ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository;
483 const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository;490 const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository;
484 const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository;491 const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository;
485 const projectionTurnRepository = yield* ProjectionTurnRepository;492 const projectionTurnRepository = yield* ProjectionTurnRepository;
...@@ -599,6 +606,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -599,6 +606,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
599 )(function* (event, attachmentSideEffects) {606 )(function* (event, attachmentSideEffects) {
600 switch (event.type) {607 switch (event.type) {
601 case "thread.created":608 case "thread.created":
609 // A draft retry can re-create this id; links belong to the old incarnation.
610 yield* projectionThreadPullRequestRepository.deleteByThreadId({
611 threadId: event.payload.threadId,
612 });
602 yield* projectionThreadRepository.upsert({613 yield* projectionThreadRepository.upsert({
603 threadId: event.payload.threadId,614 threadId: event.payload.threadId,
604 projectId: event.payload.projectId,615 projectId: event.payload.projectId,
...@@ -820,6 +831,94 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -820,6 +831,94 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
820 : {}),831 : {}),
821 updatedAt: event.payload.updatedAt,832 updatedAt: event.payload.updatedAt,
822 });833 });
834 // Legacy single-link events replay into the link table. The old
835 // field held one user-chosen link, so it only ever owns the manual
836 // rows; created/agent/stack links are left alone.
837 if (event.payload.linkedPullRequest !== undefined) {
838 yield* projectionThreadPullRequestRepository.deleteByThreadIdAndSource({
839 threadId: event.payload.threadId,
840 source: "manual",
841 });
842 if (event.payload.linkedPullRequest !== null) {
843 const linked = event.payload.linkedPullRequest;
844 yield* projectionThreadPullRequestRepository.upsert({
845 threadId: event.payload.threadId,
846 ...legacyThreadPullRequestKey(linked),
847 url: linked.url,
848 source: "manual",
849 linkedAt: event.payload.updatedAt,
850 snapshot: null,
851 stack: null,
852 });
853 }
854 }
855 return;
856 }
857
858 case "thread.pull-request-linked": {
859 const existingRow = yield* projectionThreadRepository.getById({
860 threadId: event.payload.threadId,
861 });
862 if (Option.isNone(existingRow)) {
863 return;
864 }
865 yield* projectionThreadPullRequestRepository.upsert({
866 threadId: event.payload.threadId,
867 ...event.payload.link,
868 });
869 yield* projectionThreadRepository.upsert({
870 ...existingRow.value,
871 updatedAt: event.payload.updatedAt,
872 });
873 return;
874 }
875
876 case "thread.pull-request-unlinked": {
877 const existingRow = yield* projectionThreadRepository.getById({
878 threadId: event.payload.threadId,
879 });
880 if (Option.isNone(existingRow)) {
881 return;
882 }
883 yield* projectionThreadPullRequestRepository.delete({
884 threadId: event.payload.threadId,
885 host: event.payload.host.toLowerCase(),
886 repository: event.payload.repository.toLowerCase(),
887 number: event.payload.number,
888 });
889 yield* projectionThreadRepository.upsert({
890 ...existingRow.value,
891 updatedAt: event.payload.updatedAt,
892 });
893 return;
894 }
895
896 case "thread.pull-request-synced": {
897 const existingRow = yield* projectionThreadRepository.getById({
898 threadId: event.payload.threadId,
899 });
900 if (Option.isNone(existingRow)) {
901 return;
902 }
903 // A sync for a link the user removed in the meantime is stale; drop it.
904 const links = yield* projectionThreadPullRequestRepository.listByThreadId({
905 threadId: event.payload.threadId,
906 });
907 const link = links.find((candidate) =>
908 threadPullRequestKeysEqual(candidate, event.payload),
909 );
910 if (link === undefined) {
911 return;
912 }
913 yield* projectionThreadPullRequestRepository.upsert({
914 ...link,
915 snapshot: event.payload.snapshot,
916 stack: event.payload.stack,
917 });
918 yield* projectionThreadRepository.upsert({
919 ...existingRow.value,
920 updatedAt: event.payload.updatedAt,
921 });
823 return;922 return;
824 }923 }
825924
...@@ -866,6 +965,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -866,6 +965,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
866 if (!recreatedLater) {965 if (!recreatedLater) {
867 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);966 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
868 }967 }
968 // A tombstoned thread must not show up as linked to a pull request.
969 yield* projectionThreadPullRequestRepository.deleteByThreadId({
970 threadId: event.payload.threadId,
971 });
869 const existingRow = yield* projectionThreadRepository.getById({972 const existingRow = yield* projectionThreadRepository.getById({
870 threadId: event.payload.threadId,973 threadId: event.payload.threadId,
871 });974 });
...@@ -2068,6 +2171,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(...@@ -2068,6 +2171,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(
2068 Layer.provideMerge(ProjectionThreadRepositoryLive),2171 Layer.provideMerge(ProjectionThreadRepositoryLive),
2069 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),2172 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
2070 Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive),2173 Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive),
2174 Layer.provideMerge(ProjectionThreadPullRequests.layer),
2071 Layer.provideMerge(ProjectionThreadActivityRepositoryLive),2175 Layer.provideMerge(ProjectionThreadActivityRepositoryLive),
2072 Layer.provideMerge(ProjectionThreadSessionRepositoryLive),2176 Layer.provideMerge(ProjectionThreadSessionRepositoryLive),
2073 Layer.provideMerge(ProjectionTurnRepositoryLive),2177 Layer.provideMerge(ProjectionTurnRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+146-17
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 MessageId,6 MessageId,
7 ProjectId,7 ProjectId,
8 ThreadId,8 ThreadId,
9 type ThreadPullRequestLink,
9 ThreadLinkedPullRequest,10 ThreadLinkedPullRequest,
10 TurnId,11 TurnId,
11 ProviderInstanceId,12 ProviderInstanceId,
...@@ -66,6 +67,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -66,6 +67,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
66 yield* sql`DELETE FROM projection_projects`;67 yield* sql`DELETE FROM projection_projects`;
67 yield* sql`DELETE FROM projection_state`;68 yield* sql`DELETE FROM projection_state`;
68 yield* sql`DELETE FROM projection_thread_proposed_plans`;69 yield* sql`DELETE FROM projection_thread_proposed_plans`;
70 yield* sql`DELETE FROM projection_thread_pull_requests`;
69 yield* sql`DELETE FROM projection_turns`;71 yield* sql`DELETE FROM projection_turns`;
7072
71 yield* sql`73 yield* sql`
...@@ -91,6 +93,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -91,6 +93,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
91 )93 )
92 `;94 `;
9395
96 // A merged link plus a newer open one: the multi-link projection must
97 // resolve to the open pull request, not the stale JSON column below.
98 yield* sql`
99 INSERT INTO projection_thread_pull_requests (
100 thread_id,
101 host,
102 repository,
103 number,
104 url,
105 source,
106 linked_at,
107 snapshot_json,
108 stack_json
109 )
110 VALUES
111 (
112 'thread-1',
113 'github.com',
114 'pingdotgg/t3code',
115 41,
116 'https://github.com/pingdotgg/t3code/pull/41',
117 'created',
118 '2026-02-24T00:00:02.500Z',
119 '{"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"}',
120 NULL
121 ),
122 (
123 'thread-1',
124 'github.com',
125 'pingdotgg/t3code',
126 42,
127 'https://github.com/pingdotgg/t3code/pull/42',
128 'manual',
129 '2026-02-24T00:00:03.000Z',
130 NULL,
131 NULL
132 )
133 `;
134
94 yield* sql`135 yield* sql`
95 INSERT INTO projection_threads (136 INSERT INTO projection_threads (
96 thread_id,137 thread_id,
...@@ -124,7 +165,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -124,7 +165,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
124 'default',165 'default',
125 NULL,166 NULL,
126 NULL,167 NULL,
127 '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',168 '{"projectId":"project-1","repository":"pingdotgg/t3code","number":41,"url":"https://github.com/pingdotgg/t3code/pull/41"}',
128 ${encodeThreadLinkedPullRequest(branchPullRequest)},169 ${encodeThreadLinkedPullRequest(branchPullRequest)},
129 'turn-1',170 'turn-1',
130 '2026-02-24T00:00:04.000Z',171 '2026-02-24T00:00:04.000Z',
...@@ -286,6 +327,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -286,6 +327,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
286 sequence += 1;327 sequence += 1;
287 }328 }
288329
330 const expectedPullRequests: ReadonlyArray<ThreadPullRequestLink> = [
331 {
332 host: "github.com",
333 repository: "pingdotgg/t3code",
334 number: 41,
335 url: "https://github.com/pingdotgg/t3code/pull/41",
336 source: "created",
337 linkedAt: "2026-02-24T00:00:02.500Z",
338 snapshot: {
339 state: "merged",
340 title: "Groundwork",
341 headBranch: "feat/groundwork",
342 baseBranch: "main",
343 isDraft: false,
344 updatedAt: "2026-02-24T00:00:02.600Z",
345 syncedAt: "2026-02-24T00:00:02.700Z",
346 },
347 stack: null,
348 },
349 {
350 host: "github.com",
351 repository: "pingdotgg/t3code",
352 number: 42,
353 url: "https://github.com/pingdotgg/t3code/pull/42",
354 source: "manual",
355 linkedAt: "2026-02-24T00:00:03.000Z",
356 snapshot: null,
357 stack: null,
358 },
359 ];
360
289 const snapshot = yield* snapshotQuery.getSnapshot();361 const snapshot = yield* snapshotQuery.getSnapshot();
290362
291 assert.equal(snapshot.snapshotSequence, 5);363 assert.equal(snapshot.snapshotSequence, 5);
...@@ -331,12 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -331,12 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
331 runtimeMode: "full-access",403 runtimeMode: "full-access",
332 branch: null,404 branch: null,
333 worktreePath: null,405 worktreePath: null,
334 linkedPullRequest: {406 pullRequests: expectedPullRequests,
335 projectId: asProjectId("project-1"),
336 repository: "pingdotgg/t3code",
337 number: 42,
338 url: "https://github.com/pingdotgg/t3code/pull/42",
339 },
340 branchPullRequest,407 branchPullRequest,
341 latestTurn: {408 latestTurn: {
342 turnId: asTurnId("turn-1"),409 turnId: asTurnId("turn-1"),
...@@ -461,12 +528,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -461,12 +528,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
461 runtimeMode: "full-access",528 runtimeMode: "full-access",
462 branch: null,529 branch: null,
463 worktreePath: null,530 worktreePath: null,
464 linkedPullRequest: {531 pullRequests: expectedPullRequests,
465 projectId: asProjectId("project-1"),
466 repository: "pingdotgg/t3code",
467 number: 42,
468 url: "https://github.com/pingdotgg/t3code/pull/42",
469 },
470 branchPullRequest,532 branchPullRequest,
471 latestTurn: {533 latestTurn: {
472 turnId: asTurnId("turn-1"),534 turnId: asTurnId("turn-1"),
...@@ -516,13 +578,27 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -516,13 +578,27 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
516 assert.deepEqual(threadDetail.value, snapshot.threads[0]);578 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
517 }579 }
518580
519 const commandSnapshot = yield* snapshotQuery.getCommandReadModel();
520 assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq");
521 assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
522 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));581 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
523 assert.equal(threadShell._tag, "Some");582 assert.equal(threadShell._tag, "Some");
524 if (threadShell._tag === "Some") {583 if (threadShell._tag === "Some") {
525 assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest);584 assert.deepEqual(threadShell.value, shellSnapshot.threads[0]);
585 }
586
587 const commandReadModel = yield* snapshotQuery.getCommandReadModel();
588 assert.deepEqual(commandReadModel.threads[0]?.pullRequests, expectedPullRequests);
589 assert.deepEqual(
590 commandReadModel.threads[0]?.linkedPullRequest,
591 snapshot.threads[0]?.linkedPullRequest,
592 );
593
594 // Without link rows the legacy field is omitted, whatever the old JSON
595 // column still holds.
596 yield* sql`DELETE FROM projection_thread_pull_requests`;
597 const unlinkedShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
598 assert.equal(unlinkedShell._tag, "Some");
599 if (unlinkedShell._tag === "Some") {
600 assert.deepEqual(unlinkedShell.value.pullRequests, []);
601 assert.equal("linkedPullRequest" in unlinkedShell.value, false);
526 }602 }
527603
528 yield* sql`604 yield* sql`
...@@ -3259,3 +3335,56 @@ projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => {...@@ -3259,3 +3335,56 @@ projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => {
3259 }),3335 }),
3260 );3336 );
3261});3337});
3338
3339it.effect("omits foreign-host PRs from legacy snapshots while preserving native links", () => {
3340 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
3341 Layer.provide(ThreadBackgroundLiveness.layer),
3342 Layer.provide(ThreadPlanProgress.layer),
3343 Layer.provide(
3344 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
3345 resolve: () =>
3346 Effect.succeed({
3347 canonicalKey: "github.com/acme/web",
3348 provider: "github",
3349 displayName: "acme/web",
3350 locator: {
3351 source: "git-remote" as const,
3352 remoteName: "origin",
3353 remoteUrl: "https://github.com/acme/web.git",
3354 },
3355 }),
3356 }),
3357 ),
3358 Layer.provideMerge(SqlitePersistenceMemory),
3359 );
3360 return Effect.gen(function* () {
3361 const sql = yield* SqlClient.SqlClient;
3362 const query = yield* ProjectionSnapshotQuery;
3363 yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
3364 VALUES ('project-1', 'Project', '/repo', '[]', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`;
3365 yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at)
3366 VALUES ('thread-1', 'project-1', 'Thread', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`;
3367 yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
3368 VALUES ('thread-1', 'github.enterprise.test', 'acme/web', 42, 'https://github.enterprise.test/acme/web/pull/42', 'manual', '2026-09-09T00:00:00Z')`;
3369 const readThreads = Effect.gen(function* () {
3370 const full = yield* query.getSnapshot();
3371 const shell = yield* query.getShellSnapshot();
3372 const detail = yield* query.getThreadDetailById(ThreadId.make("thread-1"));
3373 const individual = yield* query.getThreadShellById(ThreadId.make("thread-1"));
3374 return [
3375 full.threads[0]!,
3376 shell.threads[0]!,
3377 Option.getOrThrow(detail),
3378 Option.getOrThrow(individual),
3379 ];
3380 });
3381 for (const thread of yield* readThreads) {
3382 assert.equal(thread.linkedPullRequest ?? null, null);
3383 assert.equal(thread.pullRequests[0]?.host, "github.enterprise.test");
3384 }
3385 yield* sql`UPDATE projection_thread_pull_requests SET host = 'github.com', url = 'https://github.com/acme/web/pull/42'`;
3386 for (const thread of yield* readThreads) {
3387 assert.equal(thread.linkedPullRequest?.url, "https://github.com/acme/web/pull/42");
3388 }
3389 }).pipe(Effect.provide(layer));
3390});
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+423-187
...@@ -29,7 +29,11 @@ import {...@@ -29,7 +29,11 @@ import {
29 ProjectId,29 ProjectId,
30 ThreadLinkedPullRequest,30 ThreadLinkedPullRequest,
31 ThreadId,31 ThreadId,
32 ThreadPullRequestSnapshot,
33 ThreadPullRequestStack,
34 type ThreadPullRequestLink,
32} from "@t3tools/contracts";35} from "@t3tools/contracts";
36import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests";
33import * as Arr from "effect/Array";37import * as Arr from "effect/Array";
34import * as Effect from "effect/Effect";38import * as Effect from "effect/Effect";
35import * as Layer from "effect/Layer";39import * as Layer from "effect/Layer";
...@@ -54,6 +58,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";...@@ -54,6 +58,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
54import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";58import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
55import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts";59import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts";
56import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";60import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
61import { ProjectionThreadPullRequest } from "../../persistence/ProjectionThreadPullRequests.ts";
57import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";62import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
58import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";63import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
59import {64import {
...@@ -112,6 +117,12 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema...@@ -112,6 +117,12 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema
112 Struct.assign({ hasOtherUserMessages: Schema.Number }),117 Struct.assign({ hasOtherUserMessages: Schema.Number }),
113);118);
114const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;119const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
120const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFields(
121 Struct.assign({
122 snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)),
123 stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)),
124 }),
125);
115const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(126const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
116 Struct.assign({127 Struct.assign({
117 modelSelection: Schema.fromJsonString(ModelSelection),128 modelSelection: Schema.fromJsonString(ModelSelection),
...@@ -403,6 +414,49 @@ function mapProposedPlanRow(...@@ -403,6 +414,49 @@ function mapProposedPlanRow(
403 };414 };
404}415}
405416
417function mapPullRequestRow(
418 row: Schema.Schema.Type<typeof ProjectionThreadPullRequestDbRowSchema>,
419): ThreadPullRequestLink {
420 return {
421 host: row.host,
422 repository: row.repository,
423 number: row.number,
424 url: row.url,
425 source: row.source,
426 linkedAt: row.linkedAt,
427 snapshot: row.snapshot,
428 stack: row.stack,
429 };
430}
431
432function groupPullRequestRowsByThread(
433 rows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadPullRequestDbRowSchema>>,
434): Map<string, Array<ThreadPullRequestLink>> {
435 const byThread = new Map<string, Array<ThreadPullRequestLink>>();
436 for (const row of rows) {
437 const links = byThread.get(row.threadId) ?? [];
438 links.push(mapPullRequestRow(row));
439 byThread.set(row.threadId, links);
440 }
441 return byThread;
442}
443
444/**
445 * The link array plus the legacy single-link field derived from it, so clients
446 * from before `pullRequests` keep seeing the thread's current pull request.
447 */
448function mapThreadPullRequests(
449 pullRequests: ReadonlyArray<ThreadPullRequestLink>,
450 projectId: ProjectId,
451 identity?: OrchestrationProject["repositoryIdentity"],
452): Pick<OrchestrationThread, "pullRequests" | "linkedPullRequest"> {
453 const linkedPullRequest = legacyLinkedPullRequestOf(pullRequests, projectId, identity);
454 return {
455 pullRequests,
456 ...(linkedPullRequest === null ? {} : { linkedPullRequest }),
457 };
458}
459
406function mapThreadActivityRow(460function mapThreadActivityRow(
407 row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,461 row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,
408): OrchestrationThreadActivity {462): OrchestrationThreadActivity {
...@@ -649,6 +703,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -649,6 +703,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
649 `,703 `,
650 });704 });
651705
706 const listThreadPullRequestRows = SqlSchema.findAll({
707 Request: Schema.Void,
708 Result: ProjectionThreadPullRequestDbRowSchema,
709 execute: () =>
710 sql`
711 SELECT
712 thread_id AS "threadId",
713 host,
714 repository,
715 number,
716 url,
717 source,
718 linked_at AS "linkedAt",
719 snapshot_json AS "snapshot",
720 stack_json AS "stack"
721 FROM projection_thread_pull_requests
722 ORDER BY thread_id ASC, linked_at ASC, number ASC
723 `,
724 });
725
726 const listActiveThreadPullRequestRows = SqlSchema.findAll({
727 Request: Schema.Void,
728 Result: ProjectionThreadPullRequestDbRowSchema,
729 execute: () =>
730 sql`
731 SELECT
732 links.thread_id AS "threadId",
733 links.host,
734 links.repository,
735 links.number,
736 links.url,
737 links.source,
738 links.linked_at AS "linkedAt",
739 links.snapshot_json AS "snapshot",
740 links.stack_json AS "stack"
741 FROM projection_thread_pull_requests links
742 INNER JOIN projection_threads threads
743 ON threads.thread_id = links.thread_id
744 WHERE threads.deleted_at IS NULL
745 AND threads.archived_at IS NULL
746 ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC
747 `,
748 });
749
750 const listArchivedThreadPullRequestRows = SqlSchema.findAll({
751 Request: Schema.Void,
752 Result: ProjectionThreadPullRequestDbRowSchema,
753 execute: () =>
754 sql`
755 SELECT
756 links.thread_id AS "threadId",
757 links.host,
758 links.repository,
759 links.number,
760 links.url,
761 links.source,
762 links.linked_at AS "linkedAt",
763 links.snapshot_json AS "snapshot",
764 links.stack_json AS "stack"
765 FROM projection_thread_pull_requests links
766 INNER JOIN projection_threads threads
767 ON threads.thread_id = links.thread_id
768 WHERE threads.deleted_at IS NULL
769 AND threads.archived_at IS NOT NULL
770 ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC
771 `,
772 });
773
652 const listThreadActivityRows = SqlSchema.findAll({774 const listThreadActivityRows = SqlSchema.findAll({
653 Request: Schema.Void,775 Request: Schema.Void,
654 Result: ProjectionThreadActivityDbRowSchema,776 Result: ProjectionThreadActivityDbRowSchema,
...@@ -1208,6 +1330,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1208,6 +1330,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1208 `,1330 `,
1209 });1331 });
12101332
1333 const listThreadPullRequestRowsByThread = SqlSchema.findAll({
1334 Request: ThreadIdLookupInput,
1335 Result: ProjectionThreadPullRequestDbRowSchema,
1336 execute: ({ threadId }) =>
1337 sql`
1338 SELECT
1339 thread_id AS "threadId",
1340 host,
1341 repository,
1342 number,
1343 url,
1344 source,
1345 linked_at AS "linkedAt",
1346 snapshot_json AS "snapshot",
1347 stack_json AS "stack"
1348 FROM projection_thread_pull_requests
1349 WHERE thread_id = ${threadId}
1350 ORDER BY linked_at ASC, number ASC
1351 `,
1352 });
1353
1211 const listThreadActivityRowsByThread = SqlSchema.findAll({1354 const listThreadActivityRowsByThread = SqlSchema.findAll({
1212 Request: ThreadIdLookupInput,1355 Request: ThreadIdLookupInput,
1213 Result: ProjectionThreadActivityDbRowSchema,1356 Result: ProjectionThreadActivityDbRowSchema,
...@@ -1852,6 +1995,14 @@ pending_approval_requests AS (...@@ -1852,6 +1995,14 @@ pending_approval_requests AS (
1852 ),1995 ),
1853 ),1996 ),
1854 ),1997 ),
1998 listThreadPullRequestRows(undefined).pipe(
1999 Effect.mapError(
2000 toPersistenceSqlOrDecodeError(
2001 "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:query",
2002 "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:decodeRows",
2003 ),
2004 ),
2005 ),
1855 listThreadActivityRows(undefined).pipe(2006 listThreadActivityRows(undefined).pipe(
1856 Effect.mapError(2007 Effect.mapError(
1857 toPersistenceSqlOrDecodeError(2008 toPersistenceSqlOrDecodeError(
...@@ -1901,6 +2052,7 @@ pending_approval_requests AS (...@@ -1901,6 +2052,7 @@ pending_approval_requests AS (
1901 threadRows,2052 threadRows,
1902 messageRows,2053 messageRows,
1903 proposedPlanRows,2054 proposedPlanRows,
2055 pullRequestRows,
1904 activityRows,2056 activityRows,
1905 sessionRows,2057 sessionRows,
1906 checkpointRows,2058 checkpointRows,
...@@ -1910,6 +2062,7 @@ pending_approval_requests AS (...@@ -1910,6 +2062,7 @@ pending_approval_requests AS (
1910 Effect.gen(function* () {2062 Effect.gen(function* () {
1911 const messagesByThread = new Map<string, Array<OrchestrationMessage>>();2063 const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
1912 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();2064 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
2065 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
1913 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();2066 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
1914 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();2067 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
1915 const sessionsByThread = new Map<string, OrchestrationSession>();2068 const sessionsByThread = new Map<string, OrchestrationSession>();
...@@ -2071,10 +2224,12 @@ pending_approval_requests AS (...@@ -2071,10 +2224,12 @@ pending_approval_requests AS (
2071 interactionMode: row.interactionMode,2224 interactionMode: row.interactionMode,
2072 branch: row.branch,2225 branch: row.branch,
2073 worktreePath: row.worktreePath,2226 worktreePath: row.worktreePath,
2227 ...mapThreadPullRequests(
2228 pullRequestsByThread.get(row.threadId) ?? [],
2229 row.projectId,
2230 repositoryIdentities.get(row.projectId),
2231 ),
2074 branchPullRequest: row.branchPullRequest,2232 branchPullRequest: row.branchPullRequest,
2075 ...(row.linkedPullRequest === null
2076 ? {}
2077 : { linkedPullRequest: row.linkedPullRequest }),
2078 latestTurn: latestTurnByThread.get(row.threadId) ?? null,2233 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2079 createdAt: row.createdAt,2234 createdAt: row.createdAt,
2080 updatedAt: row.updatedAt,2235 updatedAt: row.updatedAt,
...@@ -2146,6 +2301,14 @@ pending_approval_requests AS (...@@ -2146,6 +2301,14 @@ pending_approval_requests AS (
2146 ),2301 ),
2147 ),2302 ),
2148 ),2303 ),
2304 listThreadPullRequestRows(undefined).pipe(
2305 Effect.mapError(
2306 toPersistenceSqlOrDecodeError(
2307 "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:query",
2308 "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:decodeRows",
2309 ),
2310 ),
2311 ),
2149 listThreadSessionRows(undefined).pipe(2312 listThreadSessionRows(undefined).pipe(
2150 Effect.mapError(2313 Effect.mapError(
2151 toPersistenceSqlOrDecodeError(2314 toPersistenceSqlOrDecodeError(
...@@ -2174,8 +2337,25 @@ pending_approval_requests AS (...@@ -2174,8 +2337,25 @@ pending_approval_requests AS (
2174 )2337 )
2175 .pipe(2338 .pipe(
2176 Effect.flatMap(2339 Effect.flatMap(
2177 ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) =>2340 ([
2178 Effect.sync(() => {2341 projectRows,
2342 threadRows,
2343 proposedPlanRows,
2344 pullRequestRows,
2345 sessionRows,
2346 latestTurnRows,
2347 stateRows,
2348 ]) =>
2349 Effect.gen(function* () {
2350 const linkedThreadIds = new Set(pullRequestRows.map((row) => row.threadId));
2351 const linkedProjectIds = new Set(
2352 threadRows
2353 .filter((row) => linkedThreadIds.has(row.threadId))
2354 .map((row) => row.projectId),
2355 );
2356 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
2357 projectRows.filter((row) => linkedProjectIds.has(row.projectId)),
2358 );
2179 let updatedAt: string | null = null;2359 let updatedAt: string | null = null;
2180 const projects: OrchestrationProject[] = [];2360 const projects: OrchestrationProject[] = [];
2181 const threads: OrchestrationThread[] = [];2361 const threads: OrchestrationThread[] = [];
...@@ -2190,6 +2370,7 @@ pending_approval_requests AS (...@@ -2190,6 +2370,7 @@ pending_approval_requests AS (
2190 id: row.projectId,2370 id: row.projectId,
2191 title: row.title,2371 title: row.title,
2192 workspaceRoot: row.workspaceRoot,2372 workspaceRoot: row.workspaceRoot,
2373 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
2193 defaultModelSelection: row.defaultModelSelection,2374 defaultModelSelection: row.defaultModelSelection,
2194 defaultThreadEnvMode: row.defaultThreadEnvMode,2375 defaultThreadEnvMode: row.defaultThreadEnvMode,
2195 autoPull: row.autoPull === 1,2376 autoPull: row.autoPull === 1,
...@@ -2252,6 +2433,7 @@ pending_approval_requests AS (...@@ -2252,6 +2433,7 @@ pending_approval_requests AS (
2252 latestTurnByThread.set(row.threadId, mapLatestTurn(row));2433 latestTurnByThread.set(row.threadId, mapLatestTurn(row));
2253 }2434 }
2254 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();2435 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
2436 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
2255 const sessionByThread = new Map<string, OrchestrationSession>();2437 const sessionByThread = new Map<string, OrchestrationSession>();
22562438
2257 for (let index = 0; index < sessionRows.length; index += 1) {2439 for (let index = 0; index < sessionRows.length; index += 1) {
...@@ -2286,10 +2468,12 @@ pending_approval_requests AS (...@@ -2286,10 +2468,12 @@ pending_approval_requests AS (
2286 interactionMode: row.interactionMode,2468 interactionMode: row.interactionMode,
2287 branch: row.branch,2469 branch: row.branch,
2288 worktreePath: row.worktreePath,2470 worktreePath: row.worktreePath,
2471 ...mapThreadPullRequests(
2472 pullRequestsByThread.get(row.threadId) ?? [],
2473 row.projectId,
2474 repositoryIdentities.get(row.projectId),
2475 ),
2289 branchPullRequest: row.branchPullRequest,2476 branchPullRequest: row.branchPullRequest,
2290 ...(row.linkedPullRequest === null
2291 ? {}
2292 : { linkedPullRequest: row.linkedPullRequest }),
2293 latestTurn: latestTurnByThread.get(row.threadId) ?? null,2477 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2294 createdAt: row.createdAt,2478 createdAt: row.createdAt,
2295 updatedAt: row.updatedAt,2479 updatedAt: row.updatedAt,
...@@ -2356,6 +2540,14 @@ pending_approval_requests AS (...@@ -2356,6 +2540,14 @@ pending_approval_requests AS (
2356 ),2540 ),
2357 ),2541 ),
2358 ),2542 ),
2543 listActiveThreadPullRequestRows(undefined).pipe(
2544 Effect.mapError(
2545 toPersistenceSqlOrDecodeError(
2546 "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query",
2547 "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:decodeRows",
2548 ),
2549 ),
2550 ),
2359 listActiveLatestTurnRows(undefined).pipe(2551 listActiveLatestTurnRows(undefined).pipe(
2360 Effect.mapError(2552 Effect.mapError(
2361 toPersistenceSqlOrDecodeError(2553 toPersistenceSqlOrDecodeError(
...@@ -2375,99 +2567,104 @@ pending_approval_requests AS (...@@ -2375,99 +2567,104 @@ pending_approval_requests AS (
2375 ]),2567 ]),
2376 )2568 )
2377 .pipe(2569 .pipe(
2378 Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>2570 Effect.flatMap(
2379 Effect.gen(function* () {2571 ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) =>
2380 let updatedAt: string | null = null;2572 Effect.gen(function* () {
2381 for (const row of projectRows) {2573 let updatedAt: string | null = null;
2382 updatedAt = maxIso(updatedAt, row.updatedAt);2574 for (const row of projectRows) {
2383 }2575 updatedAt = maxIso(updatedAt, row.updatedAt);
2384 for (const row of threadRows) {
2385 updatedAt = maxIso(updatedAt, row.updatedAt);
2386 }
2387 for (const row of sessionRows) {
2388 updatedAt = maxIso(updatedAt, row.updatedAt);
2389 }
2390 for (const row of latestTurnRows) {
2391 updatedAt = maxIso(updatedAt, row.requestedAt);
2392 if (row.startedAt !== null) {
2393 updatedAt = maxIso(updatedAt, row.startedAt);
2394 }2576 }
2395 if (row.completedAt !== null) {2577 for (const row of threadRows) {
2396 updatedAt = maxIso(updatedAt, row.completedAt);2578 updatedAt = maxIso(updatedAt, row.updatedAt);
2579 }
2580 for (const row of sessionRows) {
2581 updatedAt = maxIso(updatedAt, row.updatedAt);
2582 }
2583 for (const row of latestTurnRows) {
2584 updatedAt = maxIso(updatedAt, row.requestedAt);
2585 if (row.startedAt !== null) {
2586 updatedAt = maxIso(updatedAt, row.startedAt);
2587 }
2588 if (row.completedAt !== null) {
2589 updatedAt = maxIso(updatedAt, row.completedAt);
2590 }
2591 }
2592 for (const row of stateRows) {
2593 updatedAt = maxIso(updatedAt, row.updatedAt);
2397 }2594 }
2398 }
2399 for (const row of stateRows) {
2400 updatedAt = maxIso(updatedAt, row.updatedAt);
2401 }
24022595
2403 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows);2596 const repositoryIdentities =
2404 const latestTurnByThread = new Map(2597 yield* resolveRepositoryIdentitiesForProjects(projectRows);
2405 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),2598 const latestTurnByThread = new Map(
2406 );2599 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2407 const sessionByThread = new Map(2600 );
2408 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),2601 const sessionByThread = new Map(
2409 );2602 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2603 );
2604 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
24102605
2411 const snapshot = {2606 const snapshot = {
2412 snapshotSequence: computeSnapshotSequence(stateRows),2607 snapshotSequence: computeSnapshotSequence(stateRows),
2413 projects: Arr.filterMap(projectRows, (row) =>2608 projects: Arr.filterMap(projectRows, (row) =>
2414 row.deletedAt === null2609 row.deletedAt === null
2415 ? Result.succeed(2610 ? Result.succeed(
2416 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),2611 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2417 )2612 )
2418 : Result.failVoid,2613 : Result.failVoid,
2419 ),2614 ),
2420 threads: Arr.filterMap(threadRows, (row) =>2615 threads: Arr.filterMap(threadRows, (row) =>
2421 row.deletedAt === null2616 row.deletedAt === null
2422 ? Result.succeed({2617 ? Result.succeed({
2423 id: row.threadId,2618 id: row.threadId,
2424 projectId: row.projectId,2619 projectId: row.projectId,
2425 title: row.title,2620 title: row.title,
2426 modelSelection: row.modelSelection,2621 modelSelection: row.modelSelection,
2427 runtimeMode: row.runtimeMode,2622 runtimeMode: row.runtimeMode,
2428 interactionMode: row.interactionMode,2623 interactionMode: row.interactionMode,
2429 branch: row.branch,2624 branch: row.branch,
2430 worktreePath: row.worktreePath,2625 worktreePath: row.worktreePath,
2431 branchPullRequest: row.branchPullRequest,2626 branchPullRequest: row.branchPullRequest,
2432 ...(row.linkedPullRequest === null2627 ...mapThreadPullRequests(
2433 ? {}2628 pullRequestsByThread.get(row.threadId) ?? [],
2434 : { linkedPullRequest: row.linkedPullRequest }),2629 row.projectId,
2435 latestTurn: latestTurnByThread.get(row.threadId) ?? null,2630 repositoryIdentities.get(row.projectId),
2436 createdAt: row.createdAt,2631 ),
2437 updatedAt: row.updatedAt,2632 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2438 archivedAt: row.archivedAt,2633 createdAt: row.createdAt,
2439 settledOverride: row.settledOverride,2634 updatedAt: row.updatedAt,
2440 settledAt: row.settledAt,2635 archivedAt: row.archivedAt,
2441 unsettledAt: row.unsettledAt,2636 settledOverride: row.settledOverride,
2442 snoozedUntil: row.snoozedUntil,2637 settledAt: row.settledAt,
2443 snoozedAt: row.snoozedAt,2638 unsettledAt: row.unsettledAt,
2444 pinnedAt: row.pinnedAt,2639 snoozedUntil: row.snoozedUntil,
2445 pinOrderKey: row.pinOrderKey ?? null,2640 snoozedAt: row.snoozedAt,
2446 activeOrderKey: row.activeOrderKey ?? null,2641 pinnedAt: row.pinnedAt,
2447 titleRegeneration: mapTitleRegeneration(row),2642 pinOrderKey: row.pinOrderKey ?? null,
2448 session: sessionByThread.get(row.threadId) ?? null,2643 activeOrderKey: row.activeOrderKey ?? null,
2449 latestUserMessageAt: row.latestUserMessageAt,2644 titleRegeneration: mapTitleRegeneration(row),
2450 hasPendingApprovals: row.pendingApprovalCount > 0,2645 session: sessionByThread.get(row.threadId) ?? null,
2451 hasPendingUserInput: row.pendingUserInputCount > 0,2646 latestUserMessageAt: row.latestUserMessageAt,
2452 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,2647 hasPendingApprovals: row.pendingApprovalCount > 0,
2453 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(2648 hasPendingUserInput: row.pendingUserInputCount > 0,
2454 row.threadId,2649 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2455 ),2650 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2456 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),2651 row.threadId,
2457 } satisfies OrchestrationThreadShell)2652 ),
2458 : Result.failVoid,2653 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2459 ),2654 } satisfies OrchestrationThreadShell)
2460 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",2655 : Result.failVoid,
2461 };2656 ),
2657 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2658 };
24622659
2463 return yield* decodeShellSnapshot(snapshot).pipe(2660 return yield* decodeShellSnapshot(snapshot).pipe(
2464 Effect.mapError(2661 Effect.mapError(
2465 toPersistenceDecodeError(2662 toPersistenceDecodeError(
2466 "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot",2663 "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot",
2664 ),
2467 ),2665 ),
2468 ),2666 );
2469 );2667 }),
2470 }),
2471 ),2668 ),
2472 Effect.mapError((error) => {2669 Effect.mapError((error) => {
2473 if (isPersistenceError(error)) {2670 if (isPersistenceError(error)) {
...@@ -2505,6 +2702,14 @@ pending_approval_requests AS (...@@ -2505,6 +2702,14 @@ pending_approval_requests AS (
2505 ),2702 ),
2506 ),2703 ),
2507 ),2704 ),
2705 listArchivedThreadPullRequestRows(undefined).pipe(
2706 Effect.mapError(
2707 toPersistenceSqlOrDecodeError(
2708 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:query",
2709 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:decodeRows",
2710 ),
2711 ),
2712 ),
2508 listArchivedLatestTurnRows(undefined).pipe(2713 listArchivedLatestTurnRows(undefined).pipe(
2509 Effect.mapError(2714 Effect.mapError(
2510 toPersistenceSqlOrDecodeError(2715 toPersistenceSqlOrDecodeError(
...@@ -2524,98 +2729,102 @@ pending_approval_requests AS (...@@ -2524,98 +2729,102 @@ pending_approval_requests AS (
2524 ]),2729 ]),
2525 )2730 )
2526 .pipe(2731 .pipe(
2527 Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>2732 Effect.flatMap(
2528 Effect.gen(function* () {2733 ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) =>
2529 let updatedAt: string | null = null;2734 Effect.gen(function* () {
2530 for (const row of projectRows) {2735 let updatedAt: string | null = null;
2531 updatedAt = maxIso(updatedAt, row.updatedAt);2736 for (const row of projectRows) {
2532 }2737 updatedAt = maxIso(updatedAt, row.updatedAt);
2533 for (const row of threadRows) {
2534 updatedAt = maxIso(updatedAt, row.updatedAt);
2535 }
2536 for (const row of sessionRows) {
2537 updatedAt = maxIso(updatedAt, row.updatedAt);
2538 }
2539 for (const row of latestTurnRows) {
2540 updatedAt = maxIso(updatedAt, row.requestedAt);
2541 if (row.startedAt !== null) {
2542 updatedAt = maxIso(updatedAt, row.startedAt);
2543 }2738 }
2544 if (row.completedAt !== null) {2739 for (const row of threadRows) {
2545 updatedAt = maxIso(updatedAt, row.completedAt);2740 updatedAt = maxIso(updatedAt, row.updatedAt);
2741 }
2742 for (const row of sessionRows) {
2743 updatedAt = maxIso(updatedAt, row.updatedAt);
2744 }
2745 for (const row of latestTurnRows) {
2746 updatedAt = maxIso(updatedAt, row.requestedAt);
2747 if (row.startedAt !== null) {
2748 updatedAt = maxIso(updatedAt, row.startedAt);
2749 }
2750 if (row.completedAt !== null) {
2751 updatedAt = maxIso(updatedAt, row.completedAt);
2752 }
2753 }
2754 for (const row of stateRows) {
2755 updatedAt = maxIso(updatedAt, row.updatedAt);
2546 }2756 }
2547 }
2548 for (const row of stateRows) {
2549 updatedAt = maxIso(updatedAt, row.updatedAt);
2550 }
25512757
2552 const activeProjectIds = new Set(threadRows.map((row) => row.projectId));2758 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
2553 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(2759 const activeProjectIds = new Set(threadRows.map((row) => row.projectId));
2554 projectRows.filter((row) => activeProjectIds.has(row.projectId)),2760 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
2555 );2761 projectRows.filter((row) => activeProjectIds.has(row.projectId)),
2556 const latestTurnByThread = new Map(2762 );
2557 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),2763 const latestTurnByThread = new Map(
2558 );2764 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2559 const sessionByThread = new Map(2765 );
2560 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),2766 const sessionByThread = new Map(
2561 );2767 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2768 );
25622769
2563 const snapshot = {2770 const snapshot = {
2564 snapshotSequence: computeSnapshotSequence(stateRows),2771 snapshotSequence: computeSnapshotSequence(stateRows),
2565 projects: Arr.filterMap(projectRows, (row) =>2772 projects: Arr.filterMap(projectRows, (row) =>
2566 row.deletedAt === null && activeProjectIds.has(row.projectId)2773 row.deletedAt === null && activeProjectIds.has(row.projectId)
2567 ? Result.succeed(2774 ? Result.succeed(
2568 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),2775 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2569 )2776 )
2570 : Result.failVoid,2777 : Result.failVoid,
2571 ),
2572 threads: threadRows.map((row): OrchestrationThreadShell => ({
2573 id: row.threadId,
2574 projectId: row.projectId,
2575 title: row.title,
2576 modelSelection: row.modelSelection,
2577 runtimeMode: row.runtimeMode,
2578 interactionMode: row.interactionMode,
2579 branch: row.branch,
2580 worktreePath: row.worktreePath,
2581 branchPullRequest: row.branchPullRequest,
2582 ...(row.linkedPullRequest === null
2583 ? {}
2584 : { linkedPullRequest: row.linkedPullRequest }),
2585 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2586 createdAt: row.createdAt,
2587 updatedAt: row.updatedAt,
2588 archivedAt: row.archivedAt,
2589 settledOverride: row.settledOverride,
2590 settledAt: row.settledAt,
2591 unsettledAt: row.unsettledAt,
2592 snoozedUntil: row.snoozedUntil,
2593 snoozedAt: row.snoozedAt,
2594 pinnedAt: row.pinnedAt,
2595 pinOrderKey: row.pinOrderKey ?? null,
2596 activeOrderKey: row.activeOrderKey ?? null,
2597 titleRegeneration: mapTitleRegeneration(row),
2598 session: sessionByThread.get(row.threadId) ?? null,
2599 latestUserMessageAt: row.latestUserMessageAt,
2600 hasPendingApprovals: row.pendingApprovalCount > 0,
2601 hasPendingUserInput: row.pendingUserInputCount > 0,
2602 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2603 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2604 row.threadId,
2605 ),2778 ),
2606 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),2779 threads: threadRows.map((row): OrchestrationThreadShell => ({
2607 })),2780 id: row.threadId,
2608 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",2781 projectId: row.projectId,
2609 };2782 title: row.title,
2783 modelSelection: row.modelSelection,
2784 runtimeMode: row.runtimeMode,
2785 interactionMode: row.interactionMode,
2786 branch: row.branch,
2787 worktreePath: row.worktreePath,
2788 branchPullRequest: row.branchPullRequest,
2789 ...mapThreadPullRequests(
2790 pullRequestsByThread.get(row.threadId) ?? [],
2791 row.projectId,
2792 repositoryIdentities.get(row.projectId),
2793 ),
2794 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2795 createdAt: row.createdAt,
2796 updatedAt: row.updatedAt,
2797 archivedAt: row.archivedAt,
2798 settledOverride: row.settledOverride,
2799 settledAt: row.settledAt,
2800 unsettledAt: row.unsettledAt,
2801 snoozedUntil: row.snoozedUntil,
2802 snoozedAt: row.snoozedAt,
2803 pinnedAt: row.pinnedAt,
2804 pinOrderKey: row.pinOrderKey ?? null,
2805 activeOrderKey: row.activeOrderKey ?? null,
2806 titleRegeneration: mapTitleRegeneration(row),
2807 session: sessionByThread.get(row.threadId) ?? null,
2808 latestUserMessageAt: row.latestUserMessageAt,
2809 hasPendingApprovals: row.pendingApprovalCount > 0,
2810 hasPendingUserInput: row.pendingUserInputCount > 0,
2811 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2812 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2813 row.threadId,
2814 ),
2815 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2816 })),
2817 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2818 };
26102819
2611 return yield* decodeShellSnapshot(snapshot).pipe(2820 return yield* decodeShellSnapshot(snapshot).pipe(
2612 Effect.mapError(2821 Effect.mapError(
2613 toPersistenceDecodeError(2822 toPersistenceDecodeError(
2614 "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot",2823 "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot",
2824 ),
2615 ),2825 ),
2616 ),2826 );
2617 );2827 }),
2618 }),
2619 ),2828 ),
2620 Effect.mapError((error) => {2829 Effect.mapError((error) => {
2621 if (isPersistenceError(error)) {2830 if (isPersistenceError(error)) {
...@@ -2861,7 +3070,7 @@ pending_approval_requests AS (...@@ -2861,7 +3070,7 @@ pending_approval_requests AS (
28613070
2862 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>3071 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
2863 Effect.gen(function* () {3072 Effect.gen(function* () {
2864 const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([3073 const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([
2865 getActiveThreadRowById({ threadId }).pipe(3074 getActiveThreadRowById({ threadId }).pipe(
2866 Effect.mapError(3075 Effect.mapError(
2867 toPersistenceSqlOrDecodeError(3076 toPersistenceSqlOrDecodeError(
...@@ -2886,6 +3095,14 @@ pending_approval_requests AS (...@@ -2886,6 +3095,14 @@ pending_approval_requests AS (
2886 ),3095 ),
2887 ),3096 ),
2888 ),3097 ),
3098 listThreadPullRequestRowsByThread({ threadId }).pipe(
3099 Effect.mapError(
3100 toPersistenceSqlOrDecodeError(
3101 "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query",
3102 "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows",
3103 ),
3104 ),
3105 ),
2889 ]);3106 ]);
28903107
2891 if (Option.isNone(threadRow)) {3108 if (Option.isNone(threadRow)) {
...@@ -2901,10 +3118,15 @@ pending_approval_requests AS (...@@ -2901,10 +3118,15 @@ pending_approval_requests AS (
2901 interactionMode: threadRow.value.interactionMode,3118 interactionMode: threadRow.value.interactionMode,
2902 branch: threadRow.value.branch,3119 branch: threadRow.value.branch,
2903 worktreePath: threadRow.value.worktreePath,3120 worktreePath: threadRow.value.worktreePath,
3121 ...mapThreadPullRequests(
3122 pullRequestRows.map(mapPullRequestRow),
3123 threadRow.value.projectId,
3124 pullRequestRows.length === 0
3125 ? null
3126 : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId))
3127 ?.repositoryIdentity,
3128 ),
2904 branchPullRequest: threadRow.value.branchPullRequest,3129 branchPullRequest: threadRow.value.branchPullRequest,
2905 ...(threadRow.value.linkedPullRequest === null
2906 ? {}
2907 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
2908 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,3130 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
2909 createdAt: threadRow.value.createdAt,3131 createdAt: threadRow.value.createdAt,
2910 updatedAt: threadRow.value.updatedAt,3132 updatedAt: threadRow.value.updatedAt,
...@@ -3112,6 +3334,7 @@ pending_approval_requests AS (...@@ -3112,6 +3334,7 @@ pending_approval_requests AS (
3112 threadRow,3334 threadRow,
3113 messageRows,3335 messageRows,
3114 proposedPlanRows,3336 proposedPlanRows,
3337 pullRequestRows,
3115 activities,3338 activities,
3116 checkpointRows,3339 checkpointRows,
3117 latestTurnRow,3340 latestTurnRow,
...@@ -3144,6 +3367,14 @@ pending_approval_requests AS (...@@ -3144,6 +3367,14 @@ pending_approval_requests AS (
3144 ),3367 ),
3145 ),3368 ),
3146 ),3369 ),
3370 listThreadPullRequestRowsByThread({ threadId }).pipe(
3371 Effect.mapError(
3372 toPersistenceSqlOrDecodeError(
3373 "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:query",
3374 "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:decodeRows",
3375 ),
3376 ),
3377 ),
3147 activitiesEffect,3378 activitiesEffect,
3148 listCheckpointRowsByThread({ threadId }).pipe(3379 listCheckpointRowsByThread({ threadId }).pipe(
3149 Effect.mapError(3380 Effect.mapError(
...@@ -3184,10 +3415,15 @@ pending_approval_requests AS (...@@ -3184,10 +3415,15 @@ pending_approval_requests AS (
3184 interactionMode: threadRow.value.interactionMode,3415 interactionMode: threadRow.value.interactionMode,
3185 branch: threadRow.value.branch,3416 branch: threadRow.value.branch,
3186 worktreePath: threadRow.value.worktreePath,3417 worktreePath: threadRow.value.worktreePath,
3418 ...mapThreadPullRequests(
3419 pullRequestRows.map(mapPullRequestRow),
3420 threadRow.value.projectId,
3421 pullRequestRows.length === 0
3422 ? null
3423 : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId))
3424 ?.repositoryIdentity,
3425 ),
3187 branchPullRequest: threadRow.value.branchPullRequest,3426 branchPullRequest: threadRow.value.branchPullRequest,
3188 ...(threadRow.value.linkedPullRequest === null
3189 ? {}
3190 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
3191 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,3427 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
3192 createdAt: threadRow.value.createdAt,3428 createdAt: threadRow.value.createdAt,
3193 updatedAt: threadRow.value.updatedAt,3429 updatedAt: threadRow.value.updatedAt,
apps/server/src/orchestration/PullRequestSyncReactor.test.ts created+704
...@@ -0,0 +1,704 @@
1import {
2 ProjectId,
3 ProviderInstanceId,
4 PullRequestOperationError,
5 ThreadId,
6 type OrchestrationCommand,
7 type OrchestrationProjectShell,
8 type OrchestrationShellSnapshot,
9 type OrchestrationThreadShell,
10 type PullRequestRef,
11 type PullRequestStack,
12 type PullRequestSummary,
13 type ThreadPullRequestLink,
14 type ThreadPullRequestSnapshot,
15} from "@t3tools/contracts";
16import { assert, describe, it } from "@effect/vitest";
17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Queue from "effect/Queue";
22import * as Ref from "effect/Ref";
23import * as Stream from "effect/Stream";
24import { TestClock } from "effect/testing";
25
26import { PullRequestService } from "../pullRequest/PullRequestService.ts";
27import { ServerActivation } from "../serverActivation.ts";
28import {
29 OrchestrationEngineService,
30 type OrchestrationEngineShape,
31} from "./Services/OrchestrationEngine.ts";
32import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
33import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts";
34
35const NOW = "2026-08-28T12:00:00.000Z";
36const PROJECT_ID = ProjectId.make("sync-project");
37
38type SyncCommand = Extract<
39 OrchestrationCommand,
40 { readonly type: "thread.pull-request-link.sync" }
41>;
42type LinkCommand = Extract<OrchestrationCommand, { readonly type: "thread.pull-request.link" }>;
43
44const testCrypto = Crypto.make({
45 randomBytes: (size) => new Uint8Array(size).fill(1),
46 digest: (_algorithm, data) => Effect.succeed(data),
47});
48
49function makeProject(id: ProjectId = PROJECT_ID): OrchestrationProjectShell {
50 return {
51 id,
52 title: `Project ${id}`,
53 workspaceRoot: "/workspace/project",
54 defaultModelSelection: null,
55 scripts: [],
56 createdAt: "2026-08-01T00:00:00.000Z",
57 updatedAt: NOW,
58 };
59}
60
61function makeThread(
62 id: string,
63 overrides: Partial<OrchestrationThreadShell> = {},
64): OrchestrationThreadShell {
65 return {
66 id: ThreadId.make(id),
67 projectId: PROJECT_ID,
68 title: id,
69 modelSelection: {
70 instanceId: ProviderInstanceId.make("codex"),
71 model: "gpt-5",
72 },
73 runtimeMode: "full-access",
74 interactionMode: "default",
75 branch: null,
76 worktreePath: null,
77 pullRequests: [],
78 latestTurn: null,
79 createdAt: "2026-08-01T00:00:00.000Z",
80 updatedAt: "2026-08-20T00:00:00.000Z",
81 archivedAt: null,
82 settledOverride: null,
83 settledAt: null,
84 session: null,
85 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
86 hasPendingApprovals: false,
87 hasPendingUserInput: false,
88 hasActionableProposedPlan: false,
89 ...overrides,
90 };
91}
92
93function makeLink(
94 number: number,
95 snapshot: Partial<ThreadPullRequestSnapshot> | null = null,
96 overrides: Partial<ThreadPullRequestLink> = {},
97): ThreadPullRequestLink {
98 return {
99 host: "github.com",
100 repository: "owner/repository",
101 number,
102 url: `https://github.com/owner/repository/pull/${number}`,
103 source: "manual",
104 linkedAt: "2026-08-10T00:00:00.000Z",
105 snapshot:
106 snapshot === null
107 ? null
108 : {
109 state: "open",
110 title: "Pull request",
111 headBranch: "feature",
112 baseBranch: "main",
113 isDraft: false,
114 updatedAt: "2026-08-27T00:00:00.000Z",
115 syncedAt: "2026-08-27T00:00:00.000Z",
116 ...snapshot,
117 },
118 stack: null,
119 ...overrides,
120 };
121}
122
123function makeSnapshot(
124 threads: ReadonlyArray<OrchestrationThreadShell>,
125 snapshotSequence = 1,
126): OrchestrationShellSnapshot {
127 return {
128 snapshotSequence,
129 projects: [makeProject()],
130 threads,
131 updatedAt: NOW,
132 };
133}
134
135function makeSummary(
136 input: PullRequestRef,
137 overrides: Partial<PullRequestSummary> = {},
138): PullRequestSummary {
139 return {
140 provider: "github",
141 projectId: input.projectId,
142 repository: input.repository,
143 number: input.number,
144 title: "Pull request",
145 url: `https://github.com/${input.repository}/pull/${input.number}`,
146 state: "open",
147 headBranch: "feature",
148 baseBranch: "main",
149 updatedAt: "2026-08-27T00:00:00.000Z",
150 ...overrides,
151 };
152}
153
154interface HarnessOptions {
155 readonly invalidate?: PullRequestService["Service"]["invalidate"];
156 readonly snapshot: OrchestrationShellSnapshot;
157 readonly summary?: (
158 input: PullRequestRef,
159 ) => Effect.Effect<PullRequestSummary, PullRequestOperationError>;
160 readonly stack?: (
161 input: PullRequestRef,
162 ) => Effect.Effect<PullRequestStack | null, PullRequestOperationError>;
163}
164
165const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: HarnessOptions) {
166 const activation = yield* Deferred.make<void>();
167 const snapshots = yield* Ref.make(options.snapshot);
168 const snapshotReads = yield* Queue.unbounded<void>();
169 const syncCommands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]);
170 const linkCommands = yield* Ref.make<ReadonlyArray<LinkCommand>>([]);
171 const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
172 const stackCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
173
174 const summary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
175 Effect.gen(function* () {
176 assert.strictEqual(readOptions?.recoverTransientFailure, false);
177 yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
178 return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input));
179 });
180
181 const stack: PullRequestService["Service"]["stack"] = (input) =>
182 Effect.gen(function* () {
183 yield* Ref.update(stackCalls, (calls) => [...calls, input]);
184 return yield* options.stack?.(input) ?? Effect.succeed(null);
185 });
186
187 const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
188 if (command.type === "thread.pull-request-link.sync") {
189 return Ref.update(syncCommands, (recorded) => [...recorded, command]).pipe(
190 Effect.as({ sequence: 1 }),
191 );
192 }
193 if (command.type === "thread.pull-request.link") {
194 return Ref.update(linkCommands, (recorded) => [...recorded, command]).pipe(
195 Effect.as({ sequence: 1 }),
196 );
197 }
198 return Effect.die(new Error(`Unexpected command: ${command.type}`));
199 };
200
201 const dependencies = Layer.mergeAll(
202 Layer.mock(ProjectionSnapshotQuery)({
203 getShellSnapshot: () =>
204 Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))),
205 }),
206 Layer.mock(PullRequestService)({
207 summary,
208 stack,
209 invalidate: options.invalidate ?? (() => Effect.void),
210 }),
211 Layer.mock(OrchestrationEngineService)({
212 readEvents: () => Stream.empty,
213 dispatch,
214 streamDomainEvents: Stream.empty,
215 latestSequence: Effect.succeed(0),
216 }),
217 Layer.succeed(ServerActivation, Deferred.await(activation)),
218 Layer.succeed(Crypto.Crypto, testCrypto),
219 );
220
221 return {
222 activation,
223 snapshots,
224 snapshotReads,
225 syncCommands,
226 linkCommands,
227 summaryCalls,
228 stackCalls,
229 layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)),
230 };
231});
232
233type Harness = Effect.Success<ReturnType<typeof makeHarness>>;
234
235const startAndSweep = Effect.fn("startPullRequestSyncHarness")(function* (fixture: Harness) {
236 const reactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
237 yield* reactor.start();
238 yield* Deferred.succeed(fixture.activation, undefined);
239 yield* Queue.take(fixture.snapshotReads);
240 yield* reactor.drain;
241 return reactor;
242});
243
244const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* (
245 fixture: Harness,
246 reactor: PullRequestSyncReactor.PullRequestSyncReactor["Service"],
247) {
248 yield* TestClock.adjust("1 minute");
249 yield* Queue.take(fixture.snapshotReads);
250 yield* reactor.drain;
251});
252
253/** What the reactor would have persisted, so the next sweep sees its own writes. */
254function applySync(
255 snapshot: OrchestrationShellSnapshot,
256 commands: ReadonlyArray<SyncCommand>,
257): OrchestrationShellSnapshot {
258 return {
259 ...snapshot,
260 snapshotSequence: snapshot.snapshotSequence + 1,
261 threads: snapshot.threads.map((thread) => ({
262 ...thread,
263 pullRequests: thread.pullRequests.map((link) => {
264 const command = commands.findLast(
265 (candidate) => candidate.threadId === thread.id && candidate.number === link.number,
266 );
267 return command === undefined
268 ? link
269 : { ...link, snapshot: command.snapshot, stack: command.stack };
270 }),
271 })),
272 };
273}
274
275describe("PullRequestSyncReactor", () => {
276 it.effect("retries a failed stack read after the summary becomes terminal", () =>
277 Effect.scoped(
278 Effect.gen(function* () {
279 yield* TestClock.setTime(Date.parse(NOW));
280 let attempts = 0;
281 const nativeStack: PullRequestStack = {
282 id: "stack",
283 number: 7,
284 url: "https://github.com/owner/repository/stacks/7",
285 base: "main",
286 layers: [{ number: 7, headBranch: "feature", state: "merged" }],
287 };
288 const fixture = yield* makeHarness({
289 snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7)] })]),
290 summary: (input) =>
291 Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })),
292 stack: () =>
293 ++attempts === 1
294 ? Effect.fail(
295 new PullRequestOperationError({
296 operation: "stack",
297 detail: "temporary failure",
298 }),
299 )
300 : Effect.succeed(nativeStack),
301 });
302 yield* Effect.gen(function* () {
303 const reactor = yield* startAndSweep(fixture);
304 const commands = yield* Ref.get(fixture.syncCommands);
305 yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands));
306 yield* sweepAgain(fixture, reactor);
307 assert.strictEqual(attempts, 2);
308 assert.deepStrictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.stack, {
309 kind: "native",
310 ...nativeStack,
311 });
312 }).pipe(Effect.provide(fixture.layer));
313 }),
314 ),
315 );
316
317 it.effect("explicit refresh reads a changed stack even when its PR summary is unchanged", () =>
318 Effect.scoped(
319 Effect.gen(function* () {
320 yield* TestClock.setTime(Date.parse(NOW));
321 const fixture = yield* makeHarness({
322 snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7, {})] })]),
323 });
324 yield* Effect.gen(function* () {
325 const reactor = yield* startAndSweep(fixture);
326 assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 0);
327 yield* reactor.requestSync({
328 host: "github.com",
329 repository: "owner/repository",
330 number: 7,
331 });
332 yield* Queue.take(fixture.snapshotReads);
333 yield* reactor.drain;
334 assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1);
335 }).pipe(Effect.provide(fixture.layer));
336 }),
337 ),
338 );
339 it.effect("snapshots an unsynced link once and writes it to the thread", () =>
340 Effect.scoped(
341 Effect.gen(function* () {
342 yield* TestClock.setTime(Date.parse(NOW));
343 const fixture = yield* makeHarness({
344 snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]),
345 summary: (input) =>
346 Effect.succeed(makeSummary(input, { title: "Ship it", isDraft: true })),
347 });
348
349 yield* Effect.gen(function* () {
350 yield* startAndSweep(fixture);
351
352 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
353 {
354 projectId: PROJECT_ID,
355 host: "github.com",
356 repository: "owner/repository",
357 number: 42,
358 },
359 ]);
360 assert.deepStrictEqual(
361 (yield* Ref.get(fixture.syncCommands)).map(({ commandId: _, ...rest }) => rest),
362 [
363 {
364 type: "thread.pull-request-link.sync",
365 threadId: ThreadId.make("one"),
366 host: "github.com",
367 repository: "owner/repository",
368 number: 42,
369 snapshot: {
370 state: "open",
371 title: "Ship it",
372 headBranch: "feature",
373 baseBranch: "main",
374 isDraft: true,
375 updatedAt: "2026-08-27T00:00:00.000Z",
376 syncedAt: NOW,
377 closedAt: null,
378 mergedAt: null,
379 },
380 stack: null,
381 },
382 ],
383 );
384 assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1);
385 }).pipe(Effect.provide(fixture.layer));
386 }),
387 ),
388 );
389
390 it.effect("asks the host once for a pull request shared by two threads", () =>
391 Effect.scoped(
392 Effect.gen(function* () {
393 yield* TestClock.setTime(Date.parse(NOW));
394 const fixture = yield* makeHarness({
395 snapshot: makeSnapshot([
396 makeThread("one", { pullRequests: [makeLink(42)] }),
397 makeThread("two", {
398 pullRequests: [makeLink(42, null, { repository: "Owner/Repository" })],
399 }),
400 ]),
401 });
402
403 yield* Effect.gen(function* () {
404 yield* startAndSweep(fixture);
405
406 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
407 const commands = yield* Ref.get(fixture.syncCommands);
408 assert.deepStrictEqual(
409 commands
410 .map((command) => [command.threadId, command.repository] as const)
411 .sort((left, right) => left[0].localeCompare(right[0])),
412 [
413 [ThreadId.make("one"), "owner/repository"],
414 [ThreadId.make("two"), "Owner/Repository"],
415 ],
416 );
417 }).pipe(Effect.provide(fixture.layer));
418 }),
419 ),
420 );
421
422 it.effect("dispatches nothing when the host snapshot is unchanged", () =>
423 Effect.scoped(
424 Effect.gen(function* () {
425 yield* TestClock.setTime(Date.parse(NOW));
426 const fixture = yield* makeHarness({
427 snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]),
428 });
429
430 yield* Effect.gen(function* () {
431 const reactor = yield* startAndSweep(fixture);
432 const firstSweep = yield* Ref.get(fixture.syncCommands);
433 assert.strictEqual(firstSweep.length, 1);
434 yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep));
435
436 yield* sweepAgain(fixture, reactor);
437
438 // Still open on an active thread, so the host was asked again, but nothing changed.
439 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
440 assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1);
441 assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 1);
442 }).pipe(Effect.provide(fixture.layer));
443 }),
444 ),
445 );
446
447 it.effect("refreshes closed links through the reactor's project after reopening elsewhere", () =>
448 Effect.scoped(
449 Effect.gen(function* () {
450 yield* TestClock.setTime(Date.parse(NOW));
451 const stale = yield* Ref.make(true);
452 const fixture = yield* makeHarness({
453 snapshot: makeSnapshot([
454 makeThread("first", { pullRequests: [makeLink(42, { state: "closed" })] }),
455 makeThread("second", {
456 projectId: ProjectId.make("second-project"),
457 pullRequests: [makeLink(42, { state: "closed" })],
458 }),
459 ]),
460 invalidate: ({ reference }) =>
461 reference?.projectId === makeProject().id && reference.host === "github.com"
462 ? Ref.set(stale, false)
463 : Effect.void,
464 summary: (input) =>
465 Ref.get(stale).pipe(
466 Effect.map((cached) => makeSummary(input, { state: cached ? "closed" : "open" })),
467 ),
468 });
469 yield* Effect.gen(function* () {
470 const reactor = yield* startAndSweep(fixture);
471 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
472 yield* reactor.requestSync({
473 host: "github.com",
474 repository: "owner/repository",
475 number: 42,
476 });
477 yield* Queue.take(fixture.snapshotReads);
478 yield* reactor.drain;
479 const commands = yield* Ref.get(fixture.syncCommands);
480 yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands));
481 const snapshot = yield* Ref.get(fixture.snapshots);
482 assert.deepStrictEqual(
483 snapshot.threads.map((thread) => thread.pullRequests[0]?.snapshot?.state),
484 ["open", "open"],
485 );
486 }).pipe(Effect.provide(fixture.layer));
487 }),
488 ),
489 );
490
491 it.effect("stops asking the host once a pull request is merged", () =>
492 Effect.scoped(
493 Effect.gen(function* () {
494 yield* TestClock.setTime(Date.parse(NOW));
495 const fixture = yield* makeHarness({
496 snapshot: makeSnapshot([
497 makeThread("merged", { pullRequests: [makeLink(1, { state: "merged" })] }),
498 ]),
499 });
500
501 yield* Effect.gen(function* () {
502 const reactor = yield* startAndSweep(fixture);
503 yield* sweepAgain(fixture, reactor);
504
505 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []);
506 assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []);
507
508 yield* reactor.requestSync({
509 host: "github.com",
510 repository: "owner/repository",
511 number: 1,
512 });
513 yield* Queue.take(fixture.snapshotReads);
514 yield* reactor.drain;
515
516 assert.deepStrictEqual(
517 (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number),
518 [1],
519 );
520 }).pipe(Effect.provide(fixture.layer));
521 }),
522 ),
523 );
524
525 it.effect("discovers externally reopened pull requests after fifteen minutes", () =>
526 Effect.scoped(
527 Effect.gen(function* () {
528 yield* TestClock.setTime(Date.parse(NOW));
529 const state = yield* Ref.make<"closed" | "open">("closed");
530 const fixture = yield* makeHarness({
531 snapshot: makeSnapshot([
532 makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }),
533 ]),
534 summary: (input) =>
535 Ref.get(state).pipe(Effect.map((state) => makeSummary(input, { state }))),
536 });
537 yield* Effect.gen(function* () {
538 const reactor = yield* startAndSweep(fixture);
539 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
540 yield* Ref.set(state, "open");
541 for (let index = 0; index < 14; index += 1) yield* sweepAgain(fixture, reactor);
542 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
543 yield* sweepAgain(fixture, reactor);
544 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
545 assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open");
546 }).pipe(Effect.provide(fixture.layer));
547 }),
548 ),
549 );
550
551 it.effect("preserves a refresh requested while an older host read is in flight", () =>
552 Effect.scoped(
553 Effect.gen(function* () {
554 yield* TestClock.setTime(Date.parse(NOW));
555 const reading = yield* Deferred.make<void>();
556 const release = yield* Deferred.make<void>();
557 let calls = 0;
558 const fixture = yield* makeHarness({
559 snapshot: makeSnapshot([]),
560 summary: (input) =>
561 Effect.gen(function* () {
562 calls += 1;
563 if (calls === 1) {
564 yield* Deferred.succeed(reading, undefined);
565 yield* Deferred.await(release);
566 }
567 return makeSummary(input, { state: calls === 1 ? "closed" : "open" });
568 }),
569 });
570 yield* Effect.gen(function* () {
571 const reactor = yield* startAndSweep(fixture);
572 yield* Ref.set(
573 fixture.snapshots,
574 makeSnapshot([
575 makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }),
576 ]),
577 );
578 const key = { host: "github.com", repository: "owner/repository", number: 2 };
579 yield* reactor.requestSync(key);
580 yield* Deferred.await(reading);
581 yield* reactor.requestSync(key);
582 yield* Deferred.succeed(release, undefined);
583 yield* reactor.drain;
584 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
585 assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open");
586 }).pipe(Effect.provide(fixture.layer));
587 }),
588 ),
589 );
590
591 it.effect("polls open pull requests on settled threads every fifteen minutes", () =>
592 Effect.scoped(
593 Effect.gen(function* () {
594 yield* TestClock.setTime(Date.parse(NOW));
595 const fixture = yield* makeHarness({
596 snapshot: makeSnapshot([
597 makeThread("settled", {
598 settledOverride: "settled",
599 settledAt: "2026-08-21T00:00:00.000Z",
600 pullRequests: [makeLink(5, { state: "open" })],
601 }),
602 ]),
603 });
604
605 yield* Effect.gen(function* () {
606 const reactor = yield* startAndSweep(fixture);
607 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
608
609 yield* sweepAgain(fixture, reactor);
610 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
611
612 for (let index = 0; index < 13; index += 1) yield* sweepAgain(fixture, reactor);
613 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
614
615 yield* sweepAgain(fixture, reactor);
616 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
617 }).pipe(Effect.provide(fixture.layer));
618 }),
619 ),
620 );
621
622 it.effect("auto-links missing native stack layers and leaves dismissed ones alone", () =>
623 Effect.scoped(
624 Effect.gen(function* () {
625 yield* TestClock.setTime(Date.parse(NOW));
626 const stack: PullRequestStack = {
627 id: "stack-1",
628 number: 42,
629 url: "https://github.com/owner/repository/stack/1",
630 base: "main",
631 layers: [
632 { number: 41, headBranch: "layer-1", state: "merged" },
633 { number: 42, headBranch: "layer-2", state: "open" },
634 { number: 43, headBranch: "layer-3", state: "open" },
635 ],
636 };
637 const fixture = yield* makeHarness({
638 snapshot: makeSnapshot([
639 makeThread("one", {
640 pullRequests: [
641 makeLink(42),
642 makeLink(41, { state: "merged" }, { source: "stack-dismissed" }),
643 ],
644 }),
645 ]),
646 stack: () => Effect.succeed(stack),
647 });
648
649 yield* Effect.gen(function* () {
650 yield* startAndSweep(fixture);
651
652 const syncCommands = yield* Ref.get(fixture.syncCommands);
653 assert.deepStrictEqual(
654 syncCommands.map((command) => [command.number, command.stack] as const),
655 [[42, { kind: "native", ...stack }]],
656 );
657 assert.deepStrictEqual(
658 (yield* Ref.get(fixture.linkCommands)).map(({ commandId: _, ...rest }) => rest),
659 [
660 {
661 type: "thread.pull-request.link",
662 threadId: ThreadId.make("one"),
663 host: "github.com",
664 repository: "owner/repository",
665 number: 43,
666 url: "https://github.com/owner/repository/pull/43",
667 source: "stack",
668 },
669 ],
670 );
671 }).pipe(Effect.provide(fixture.layer));
672 }),
673 ),
674 );
675
676 it.effect("keeps existing snapshots and continues when the host fails", () =>
677 Effect.scoped(
678 Effect.gen(function* () {
679 yield* TestClock.setTime(Date.parse(NOW));
680 const fixture = yield* makeHarness({
681 snapshot: makeSnapshot([
682 makeThread("failing", { pullRequests: [makeLink(7, { state: "open" })] }),
683 makeThread("fine", { pullRequests: [makeLink(8)] }),
684 ]),
685 summary: (input) =>
686 input.number === 7
687 ? Effect.fail(
688 new PullRequestOperationError({ operation: "summary", detail: "host down" }),
689 )
690 : Effect.succeed(makeSummary(input)),
691 });
692
693 yield* Effect.gen(function* () {
694 yield* startAndSweep(fixture);
695
696 assert.deepStrictEqual(
697 (yield* Ref.get(fixture.syncCommands)).map((command) => command.number),
698 [8],
699 );
700 }).pipe(Effect.provide(fixture.layer));
701 }),
702 ),
703 );
704});
apps/server/src/orchestration/PullRequestSyncReactor.ts created+328
...@@ -0,0 +1,328 @@
1import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl";
2import {
3 CommandId,
4 type OrchestrationThreadShell,
5 type PullRequestSummary,
6 type ThreadPullRequestKey,
7 type ThreadPullRequestLink,
8 type ThreadPullRequestSnapshot,
9 type ThreadPullRequestStack,
10} from "@t3tools/contracts";
11import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
12import {
13 threadPullRequestKeyOf,
14 threadPullRequestKeysEqual,
15 visibleThreadPullRequests,
16} from "@t3tools/shared/threadPullRequests";
17import * as Cause from "effect/Cause";
18import * as Context from "effect/Context";
19import * as Crypto from "effect/Crypto";
20import * as DateTime from "effect/DateTime";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Schedule from "effect/Schedule";
24import type * as Scope from "effect/Scope";
25
26import * as PullRequestService from "../pullRequest/PullRequestService.ts";
27import { forkParked } from "../serverActivation.ts";
28import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
29import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
30
31const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000;
32
33type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">;
34
35interface LinkEntry {
36 readonly thread: OrchestrationThreadShell;
37 readonly link: ThreadPullRequestLink;
38}
39
40function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields {
41 return {
42 state: summary.state,
43 title: summary.title,
44 headBranch: summary.headBranch,
45 baseBranch: summary.baseBranch,
46 isDraft: summary.isDraft ?? false,
47 updatedAt: summary.updatedAt,
48 closedAt: summary.closedAt ?? null,
49 mergedAt: summary.mergedAt ?? null,
50 ...(summary.author === undefined ? {} : { author: summary.author }),
51 ...(summary.additions === undefined ? {} : { additions: summary.additions }),
52 ...(summary.deletions === undefined ? {} : { deletions: summary.deletions }),
53 ...(summary.changedFiles === undefined ? {} : { changedFiles: summary.changedFiles }),
54 ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }),
55 ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }),
56 ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }),
57 };
58}
59
60function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boolean {
61 return (
62 left.state === right.state &&
63 left.title === right.title &&
64 left.headBranch === right.headBranch &&
65 left.baseBranch === right.baseBranch &&
66 left.isDraft === right.isDraft &&
67 left.updatedAt === right.updatedAt &&
68 (left.closedAt ?? null) === (right.closedAt ?? null) &&
69 (left.mergedAt ?? null) === (right.mergedAt ?? null) &&
70 (left.author?.login ?? null) === (right.author?.login ?? null) &&
71 (left.author?.avatarUrl ?? null) === (right.author?.avatarUrl ?? null) &&
72 left.additions === right.additions &&
73 left.deletions === right.deletions &&
74 left.changedFiles === right.changedFiles &&
75 (left.reviewDecision ?? null) === (right.reviewDecision ?? null) &&
76 (left.checksState ?? null) === (right.checksState ?? null) &&
77 left.mergeability === right.mergeability
78 );
79}
80
81function stacksEqual(
82 left: ThreadPullRequestStack | null,
83 right: ThreadPullRequestStack | null,
84): boolean {
85 if (left === null || right === null) return left === right;
86 return (
87 left.kind === right.kind &&
88 left.id === right.id &&
89 left.number === right.number &&
90 left.url === right.url &&
91 left.base === right.base &&
92 left.layers.length === right.layers.length &&
93 left.layers.every((layer, index) => {
94 const other = right.layers[index]!;
95 return (
96 layer.number === other.number &&
97 layer.headBranch === other.headBranch &&
98 layer.state === other.state
99 );
100 })
101 );
102}
103
104function isUnsettled(thread: OrchestrationThreadShell): boolean {
105 return thread.settledOverride !== "settled" && thread.settledAt === null;
106}
107
108/**
109 * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads
110 * the shell snapshot, groups visible links by pull request so the host is asked once per PR
111 * no matter how many threads share it, and writes back only what changed. Native stacks the
112 * host reports are auto-linked to the thread as `source: "stack"`.
113 */
114export class PullRequestSyncReactor extends Context.Service<
115 PullRequestSyncReactor,
116 {
117 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
118 readonly drain: Effect.Effect<void>;
119 /** Force the next sweep to re-read this pull request, even when its snapshot is terminal. */
120 readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect<void>;
121 }
122>()("t3/orchestration/PullRequestSyncReactor") {}
123
124/** @public Service construction is part of the canonical Effect module API. */
125export const make = Effect.gen(function* () {
126 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
127 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
128 const pullRequests = yield* PullRequestService.PullRequestService;
129 const crypto = yield* Crypto.Crypto;
130
131 const lastSyncedAt = new Map<string, number>();
132 const requested = new Map<string, number>();
133 let requestGeneration = 0;
134 const retryStacks = new Set<string>();
135
136 const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => {
137 if (requested.has(key) || retryStacks.has(key)) return true;
138 if (entries.some((entry) => entry.link.snapshot === null)) return true;
139 if (entries.every((entry) => entry.link.snapshot?.state === "merged")) return false;
140 if (entries.some((entry) => entry.link.snapshot?.state === "open" && isUnsettled(entry.thread)))
141 return true;
142 // Closed requests can reopen on the host, including after the thread settles.
143 const last = lastSyncedAt.get(key);
144 return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS;
145 };
146
147 const logSkipped =
148 (message: string, fields: Record<string, unknown>) =>
149 <E>(cause: Cause.Cause<E>): Effect.Effect<void, E> =>
150 Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields);
151
152 const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* () {
153 const snapshot = yield* snapshots.getShellSnapshot();
154 const now = yield* DateTime.now;
155 const nowMs = DateTime.toEpochMillis(now);
156 const nowIso = DateTime.formatIso(now);
157
158 const groups = new Map<string, Array<LinkEntry>>();
159 for (const thread of snapshot.threads) {
160 if (thread.archivedAt !== null) continue;
161 for (const link of visibleThreadPullRequests(thread.pullRequests)) {
162 const key = threadPullRequestKeyOf(link);
163 const entries = groups.get(key) ?? [];
164 entries.push({ thread, link });
165 groups.set(key, entries);
166 }
167 }
168
169 for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key);
170 for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key);
171 for (const key of requested.keys()) if (!groups.has(key)) requested.delete(key);
172
173 // Layers auto-linked this sweep, so two links of one thread that share a
174 // stack do not both try to add the same sibling.
175 const linkedThisSweep = new Set<string>();
176
177 const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* (
178 entry: LinkEntry,
179 fields: SnapshotFields,
180 fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null,
181 ) {
182 const { thread, link } = entry;
183 const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack;
184 const changed =
185 link.snapshot === null ||
186 !snapshotFieldsEqual(link.snapshot, fields) ||
187 !stacksEqual(link.stack, nextStack);
188 if (changed) {
189 const uuid = yield* crypto.randomUUIDv4;
190 yield* engine.dispatch({
191 type: "thread.pull-request-link.sync",
192 commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`),
193 threadId: thread.id,
194 host: link.host,
195 repository: link.repository,
196 number: link.number,
197 snapshot: { ...fields, syncedAt: nowIso },
198 stack: nextStack,
199 });
200 }
201 if (fetchedStack === null || fetchedStack.stack === null) return;
202 for (const layer of fetchedStack.stack.layers) {
203 const layerKey = { host: link.host, repository: link.repository, number: layer.number };
204 const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`;
205 if (linkedThisSweep.has(dedupeKey)) continue;
206 // Tombstones count as present: a dismissed layer is never re-added.
207 if (
208 thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, layerKey))
209 ) {
210 continue;
211 }
212 const url = siblingPullRequestUrl(link.url, layer.number);
213 if (url === null) continue;
214 linkedThisSweep.add(dedupeKey);
215 const uuid = yield* crypto.randomUUIDv4;
216 yield* engine
217 .dispatch({
218 type: "thread.pull-request.link",
219 commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`),
220 threadId: thread.id,
221 ...layerKey,
222 url,
223 source: "stack",
224 })
225 .pipe(
226 Effect.catchCause(
227 logSkipped("pull request stack layer link skipped", {
228 threadId: thread.id,
229 number: layer.number,
230 }),
231 ),
232 );
233 }
234 });
235
236 const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* (
237 key: string,
238 entries: ReadonlyArray<LinkEntry>,
239 ) {
240 const first = entries[0]!;
241 const ref = {
242 projectId: first.thread.projectId,
243 host: first.link.host,
244 repository: first.link.repository,
245 number: first.link.number,
246 };
247 const generation = requested.get(key);
248 if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref });
249 const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false });
250 const fields = snapshotFieldsOf(summary);
251 const needsStack =
252 generation !== undefined ||
253 retryStacks.has(key) ||
254 entries.some(
255 (entry) =>
256 entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields),
257 );
258 const fetchedStack = needsStack
259 ? yield* pullRequests.stack(ref).pipe(
260 Effect.map((stack) => ({
261 stack: stack === null ? null : ({ kind: "native", ...stack } as const),
262 })),
263 Effect.catchCause((cause) =>
264 Cause.hasInterruptsOnly(cause)
265 ? Effect.failCause(cause)
266 : Effect.logWarning("pull request stack lookup failed", {
267 key,
268 }).pipe(Effect.as(null)),
269 ),
270 )
271 : null;
272 if (needsStack) {
273 if (fetchedStack === null) retryStacks.add(key);
274 else retryStacks.delete(key);
275 }
276 // The host answered, so the cadence clock ticks even if a dispatch below is rejected.
277 lastSyncedAt.set(key, nowMs);
278 // A refresh requested while the host read was in flight belongs to the next sweep.
279 if (requested.get(key) === generation) requested.delete(key);
280 yield* Effect.forEach(
281 entries,
282 (entry) =>
283 syncEntry(entry, fields, fetchedStack).pipe(
284 Effect.catchCause(
285 logSkipped("pull request sync skipped", { threadId: entry.thread.id, key }),
286 ),
287 ),
288 { discard: true },
289 );
290 });
291
292 yield* Effect.forEach(
293 groups,
294 ([key, entries]) =>
295 isDue(key, entries, nowMs)
296 ? syncGroup(key, entries).pipe(
297 Effect.catchCause(logSkipped("pull request sync skipped", { key })),
298 )
299 : Effect.void,
300 { concurrency: 8, discard: true },
301 );
302 });
303
304 const worker = yield* makeDrainableWorker(() =>
305 sweep().pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))),
306 );
307
308 const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn(
309 "PullRequestSyncReactor.start",
310 )(function* () {
311 yield* forkParked(
312 Effect.gen(function* () {
313 yield* worker.enqueue(undefined);
314 yield* worker.drain;
315 }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
316 );
317 });
318
319 const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) =>
320 Effect.suspend(() => {
321 requested.set(threadPullRequestKeyOf(key), ++requestGeneration);
322 return worker.enqueue(undefined);
323 });
324
325 return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"];
326});
327
328export const layer = Layer.effect(PullRequestSyncReactor, make);
apps/server/src/orchestration/Schemas.ts+6
...@@ -16,6 +16,9 @@ import {...@@ -16,6 +16,9 @@ import {
16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
18 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,18 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
19 ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema,
20 ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema,
21 ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema,
19 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,22 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
20 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,23 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
21 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,24 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
...@@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;...@@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
48export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;51export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
49export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;52export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
50export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;53export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;
54export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema;
55export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema;
56export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema;
5157
52export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;58export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
53export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;59export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+1
...@@ -87,6 +87,7 @@ function thread(...@@ -87,6 +87,7 @@ function thread(
87 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },87 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
88 runtimeMode: "full-access",88 runtimeMode: "full-access",
89 interactionMode: "default",89 interactionMode: "default",
90 pullRequests: [],
90 branch: "feature",91 branch: "feature",
91 worktreePath: null,92 worktreePath: null,
92 latestTurn: null,93 latestTurn: null,
apps/server/src/orchestration/ThreadPullRequestReactor.ts+6-13
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import {
2 canonicalRepositoryKey,
3 sourceControlRepositorySelector,
4} from "@t3tools/shared/sourceControl";
1import {5import {
2 CommandId,6 CommandId,
3 type OrchestrationEvent,7 type OrchestrationEvent,
...@@ -53,18 +57,6 @@ interface RefreshRequest {...@@ -53,18 +57,6 @@ interface RefreshRequest {
53 readonly backfill?: boolean;57 readonly backfill?: boolean;
54}58}
5559
56function canonicalRepositoryKey(key: string): string {
57 return key
58 .replace(
59 /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u,
60 "dev.azure.com/$1/$2/_git/$3",
61 )
62 .replace(
63 /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u,
64 "dev.azure.com/$1/$2/_git/$3",
65 );
66}
67
68export function pullRequestMatchesProject(60export function pullRequestMatchesProject(
69 pullRequest: GitManager.GitBranchPullRequest,61 pullRequest: GitManager.GitBranchPullRequest,
70 project: OrchestrationProjectShell,62 project: OrchestrationProjectShell,
...@@ -138,7 +130,7 @@ export const make = Effect.gen(function* () {...@@ -138,7 +130,7 @@ export const make = Effect.gen(function* () {
138 const first = group[0]!;130 const first = group[0]!;
139 const project = projects.get(first.projectId);131 const project = projects.get(first.projectId);
140 if (project === undefined) return finishBackfill(group);132 if (project === undefined) return finishBackfill(group);
141 const repository = PullRequestService.repositoryIdentityOf(project);133 const repository = sourceControlRepositorySelector(project.repositoryIdentity);
142 if (first.branch !== null && repository === null) return finishBackfill(group);134 if (first.branch !== null && repository === null) return finishBackfill(group);
143 const worktreeExists =135 const worktreeExists =
144 first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath));136 first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath));
...@@ -189,6 +181,7 @@ export const make = Effect.gen(function* () {...@@ -189,6 +181,7 @@ export const make = Effect.gen(function* () {
189181
190 let replacement: ThreadLinkedPullRequest | undefined;182 let replacement: ThreadLinkedPullRequest | undefined;
191 if (183 if (
184 thread.pullRequests.length === 0 &&
192 thread.linkedPullRequest != null &&185 thread.linkedPullRequest != null &&
193 detected?.state === "open" &&186 detected?.state === "open" &&
194 detectedReference !== null &&187 detectedReference !== null &&
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+74
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 ProjectId,5 ProjectId,
6 TurnId,6 TurnId,
7 type OrchestrationThreadShell,7 type OrchestrationThreadShell,
8 type ThreadPullRequestLink,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";10import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
1011
...@@ -18,6 +19,7 @@ const makeThread = (...@@ -18,6 +19,7 @@ const makeThread = (
18 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },19 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
19 runtimeMode: "full-access",20 runtimeMode: "full-access",
20 interactionMode: "default",21 interactionMode: "default",
22 pullRequests: [],
21 branch: "feature",23 branch: "feature",
22 worktreePath: "/repo",24 worktreePath: "/repo",
23 latestTurn: null,25 latestTurn: null,
...@@ -213,3 +215,75 @@ describe("resolveAutoSettlementAt", () => {...@@ -213,3 +215,75 @@ describe("resolveAutoSettlementAt", () => {
213 ).toBe(true);215 ).toBe(true);
214 });216 });
215});217});
218
219function linkedRequest(
220 number: number,
221 snapshot: ThreadPullRequestLink["snapshot"],
222): ThreadPullRequestLink {
223 return {
224 host: "github.com",
225 repository: "org/repo",
226 number,
227 url: `https://github.com/org/repo/pull/${number}`,
228 source: "manual",
229 linkedAt: NOW,
230 stack: null,
231 snapshot,
232 };
233}
234
235const terminalSnapshot = (
236 state: "closed" | "merged",
237 terminalAt: string,
238 updatedAt = terminalAt,
239) => ({
240 state,
241 title: "Change",
242 headBranch: "feature",
243 baseBranch: "main",
244 isDraft: false,
245 closedAt: terminalAt,
246 mergedAt: state === "merged" ? terminalAt : null,
247 updatedAt,
248 syncedAt: NOW,
249});
250
251describe("linked request settlement", () => {
252 it.each(["closed", "merged"] as const)(
253 "uses the latest actual %s transition despite later comments on another PR",
254 (state) => {
255 const old = linkedRequest(1, terminalSnapshot(state, "2026-08-19T00:00:00.000Z", NOW));
256 const recent = linkedRequest(2, terminalSnapshot(state, "2026-08-21T00:00:00.000Z"));
257 expect(decide(makeThread({ pullRequests: [old, recent] }), null, { days: null })).toBe(true);
258 expect(decide(makeThread({ pullRequests: [recent, old] }), null, { days: null })).toBe(true);
259 expect(decide(makeThread({ pullRequests: [old] }), null, { days: null })).toBe(false);
260 },
261 );
262
263 it("keeps unknown and open links active even after the inactivity window", () => {
264 const merged = linkedRequest(1, terminalSnapshot("merged", NOW));
265 const unknown = linkedRequest(2, null);
266 const open = linkedRequest(3, {
267 ...terminalSnapshot("closed", NOW),
268 state: "open",
269 closedAt: null,
270 });
271 expect(decide(makeThread({ pullRequests: [merged, unknown] }))).toBe(false);
272 expect(decide(makeThread({ pullRequests: [merged, open] }))).toBe(false);
273 expect(
274 decide(makeThread({ pullRequests: [merged, { ...unknown, source: "stack-dismissed" }] })),
275 ).toBe(true);
276 });
277
278 it("honors merge settings and ignores missing terminal timestamps", () => {
279 const merged = linkedRequest(1, terminalSnapshot("merged", NOW));
280 expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null, merge: false })).toBe(
281 false,
282 );
283 const missing = linkedRequest(2, { ...terminalSnapshot("merged", NOW), mergedAt: null });
284 expect(decide(makeThread({ pullRequests: [missing] }), null, { days: null })).toBe(false);
285 expect(decide(makeThread({ pullRequests: [missing, merged] }), null, { days: null })).toBe(
286 true,
287 );
288 });
289});
apps/server/src/orchestration/ThreadSettlementPolicy.ts+24-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { OrchestrationThreadShell } from "@t3tools/contracts";1import type { OrchestrationThreadShell } from "@t3tools/contracts";
2import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
23
3export interface SettlementPullRequest {4export interface SettlementPullRequest {
4 readonly state: "open" | "closed" | "merged";5 readonly state: "open" | "closed" | "merged";
...@@ -72,7 +73,29 @@ export function resolveAutoSettlementAt(input: {...@@ -72,7 +73,29 @@ export function resolveAutoSettlementAt(input: {
72 readonly autoSettleAfterDays: number | null;73 readonly autoSettleAfterDays: number | null;
73 readonly autoSettleOnMerge: boolean;74 readonly autoSettleOnMerge: boolean;
74}): string | null {75}): string | null {
75 const { thread, pullRequest } = input;76 const { thread } = input;
77 let pullRequest = input.pullRequest;
78 const links = visibleThreadPullRequests(thread.pullRequests);
79 if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null;
80 if (links.length > 0) {
81 const terminalTimestamp = (link: (typeof links)[number]) => {
82 const snapshot = link.snapshot;
83 const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt;
84 const timestamp = Date.parse(value ?? "");
85 return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
86 };
87 const latest = links.reduce((current, candidate) =>
88 terminalTimestamp(candidate) > terminalTimestamp(current) ? candidate : current,
89 );
90 pullRequest =
91 latest.snapshot === null
92 ? null
93 : {
94 state: latest.snapshot.state,
95 mergedAt: latest.snapshot.mergedAt ?? null,
96 closedAt: latest.snapshot.closedAt ?? null,
97 };
98 }
76 if (!isAutoSettlementCandidate(thread, input.now)) return null;99 if (!isAutoSettlementCandidate(thread, input.now)) return null;
77 const activityAt = latestTimestamp([100 const activityAt = latestTimestamp([
78 thread.latestUserMessageAt,101 thread.latestUserMessageAt,
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+52
...@@ -80,6 +80,7 @@ function makeThread(...@@ -80,6 +80,7 @@ function makeThread(
80 },80 },
81 runtimeMode: "full-access",81 runtimeMode: "full-access",
82 interactionMode: "default",82 interactionMode: "default",
83 pullRequests: [],
83 branch: null,84 branch: null,
84 worktreePath: null,85 worktreePath: null,
85 latestTurn: null,86 latestTurn: null,
...@@ -301,6 +302,57 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (...@@ -301,6 +302,57 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
301});302});
302303
303describe("ThreadSettlementReactor", () => {304describe("ThreadSettlementReactor", () => {
305 it.effect(
306 "settles all-terminal links from snapshots and keeps open or unsynced links active",
307 () =>
308 Effect.scoped(
309 Effect.gen(function* () {
310 yield* TestClock.setTime(Date.parse(NOW));
311 const link = (number: number, state: "open" | "merged" | null) => ({
312 host: "example.test",
313 repository: "owner/repository",
314 number,
315 url: `https://example.test/owner/repository/pull/${number}`,
316 source: "manual" as const,
317 linkedAt: NOW,
318 stack: null,
319 snapshot:
320 state === null
321 ? null
322 : {
323 state,
324 title: "Review",
325 headBranch: "feature",
326 baseBranch: "main",
327 isDraft: false,
328 updatedAt: NOW,
329 syncedAt: NOW,
330 mergedAt: state === "merged" ? NOW : null,
331 },
332 });
333 const fixture = yield* makeHarness({
334 snapshot: makeSnapshot([
335 makeThread("merged", { pullRequests: [link(1, "merged"), link(2, "merged")] }),
336 makeThread("open", { pullRequests: [link(1, "merged"), link(2, "open")] }),
337 makeThread("unsynced", { pullRequests: [link(1, "merged"), link(2, null)] }),
338 ]),
339 settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleOnMerge: true },
340 branchPullRequest: () => Effect.die("linked threads must not query the branch"),
341 pullRequestSummary: () => Effect.die("linked threads must use their snapshots"),
342 });
343 yield* Effect.gen(function* () {
344 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
345 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
346 assert.deepStrictEqual(
347 (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId),
348 [ThreadId.make("merged")],
349 );
350 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
351 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []);
352 }).pipe(Effect.provide(fixture.layer));
353 }),
354 ),
355 );
304 it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>356 it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>
305 Effect.scoped(357 Effect.scoped(
306 Effect.gen(function* () {358 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+3-1
...@@ -103,7 +103,9 @@ export const make = Effect.gen(function* () {...@@ -103,7 +103,9 @@ export const make = Effect.gen(function* () {
103 {103 {
104 concurrency: 8,104 concurrency: 8,
105 },105 },
106 )).filter((thread) => thread !== null);106 ))
107 .filter((thread) => thread !== null)
108 .filter((thread) => !thread.pullRequests.some((link) => link.source !== "stack-dismissed"));
107109
108 // Use the same cwd as PR discovery so both paths share GitManager's cache.110 // Use the same cwd as PR discovery so both paths share GitManager's cache.
109 const lookupCwdByThreadId = new Map<string, string>();111 const lookupCwdByThreadId = new Map<string, string>();
apps/server/src/orchestration/commandInvariants.test.ts+2
...@@ -59,6 +59,7 @@ const readModel: OrchestrationReadModel = {...@@ -59,6 +59,7 @@ const readModel: OrchestrationReadModel = {
59 runtimeMode: "full-access",59 runtimeMode: "full-access",
60 branch: null,60 branch: null,
61 worktreePath: null,61 worktreePath: null,
62 pullRequests: [],
62 createdAt: now,63 createdAt: now,
63 updatedAt: now,64 updatedAt: now,
64 archivedAt: null,65 archivedAt: null,
...@@ -84,6 +85,7 @@ const readModel: OrchestrationReadModel = {...@@ -84,6 +85,7 @@ const readModel: OrchestrationReadModel = {
84 runtimeMode: "full-access",85 runtimeMode: "full-access",
85 branch: null,86 branch: null,
86 worktreePath: null,87 worktreePath: null,
88 pullRequests: [],
87 createdAt: now,89 createdAt: now,
88 updatedAt: now,90 updatedAt: now,
89 archivedAt: null,91 archivedAt: null,
apps/server/src/orchestration/decider.active-order.test.ts+1
...@@ -33,6 +33,7 @@ function makeReadModel(overrides: Partial<OrchestrationThread> = {}): Orchestrat...@@ -33,6 +33,7 @@ function makeReadModel(overrides: Partial<OrchestrationThread> = {}): Orchestrat
33 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },33 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
34 runtimeMode: "full-access",34 runtimeMode: "full-access",
35 interactionMode: "default",35 interactionMode: "default",
36 pullRequests: [],
36 branch: null,37 branch: null,
37 worktreePath: null,38 worktreePath: null,
38 latestTurn: null,39 latestTurn: null,
apps/server/src/orchestration/decider.pinned.test.ts+1
...@@ -36,6 +36,7 @@ function makeReadModel(input: {...@@ -36,6 +36,7 @@ function makeReadModel(input: {
36 interactionMode: "default",36 interactionMode: "default",
37 branch: null,37 branch: null,
38 worktreePath: null,38 worktreePath: null,
39 pullRequests: [],
39 latestTurn: null,40 latestTurn: null,
40 createdAt: NOW,41 createdAt: NOW,
41 updatedAt: NOW,42 updatedAt: NOW,
apps/server/src/orchestration/decider.pullRequests.test.ts created+532
...@@ -0,0 +1,532 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 OrchestrationEvent,
7 OrchestrationCommand,
8 type OrchestrationReadModel,
9 type ThreadPullRequestLink,
10 type ThreadPullRequestSnapshot,
11} from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { expect, it } from "@effect/vitest";
14import * as Effect from "effect/Effect";
15import * as Schema from "effect/Schema";
16
17import { decideOrchestrationCommand } from "./decider.ts";
18import { projectEvent } from "./projector.ts";
19import { isThreadDetailEvent } from "../ws.ts";
20
21const decodeCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
22
23type PlannedEvent = Omit<OrchestrationEvent, "sequence">;
24
25function expectSingleEvent<Type extends OrchestrationEvent["type"]>(
26 decided: PlannedEvent | ReadonlyArray<PlannedEvent>,
27 type: Type,
28): Omit<Extract<OrchestrationEvent, { type: Type }>, "sequence"> {
29 const event = Array.isArray(decided) ? decided[0] : (decided as PlannedEvent);
30 if (event === undefined || event.type !== type) {
31 throw new Error(`expected ${type}, got ${String(event?.type)}`);
32 }
33 return event as Omit<Extract<OrchestrationEvent, { type: Type }>, "sequence">;
34}
35
36const NOW = "2026-01-01T00:00:00.000Z";
37const THREAD_ID = ThreadId.make("thread-1");
38
39function makeLink(overrides: Partial<ThreadPullRequestLink> = {}): ThreadPullRequestLink {
40 return {
41 host: "github.com",
42 repository: "t3tools/t3code",
43 number: 42,
44 url: "https://github.com/t3tools/t3code/pull/42",
45 source: "manual",
46 linkedAt: NOW,
47 snapshot: null,
48 stack: null,
49 ...overrides,
50 };
51}
52
53function makeReadModel(pullRequests: ReadonlyArray<ThreadPullRequestLink>): OrchestrationReadModel {
54 return {
55 snapshotSequence: 0,
56 projects: [
57 {
58 id: ProjectId.make("project-1"),
59 title: "Project",
60 workspaceRoot: "/repo",
61 defaultModelSelection: null,
62 scripts: [],
63 createdAt: NOW,
64 updatedAt: NOW,
65 deletedAt: null,
66 repositoryIdentity: {
67 canonicalKey: "github.com/t3tools/t3code",
68 provider: "github",
69 displayName: "t3tools/t3code",
70 locator: {
71 source: "git-remote",
72 remoteName: "origin",
73 remoteUrl: "https://github.com/t3tools/t3code.git",
74 },
75 },
76 },
77 ],
78 threads: [
79 {
80 id: THREAD_ID,
81 projectId: ProjectId.make("project-1"),
82 title: "Thread",
83 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
84 runtimeMode: "full-access",
85 interactionMode: "default",
86 branch: null,
87 worktreePath: null,
88 pullRequests,
89 latestTurn: null,
90 createdAt: NOW,
91 updatedAt: NOW,
92 archivedAt: null,
93 settledOverride: null,
94 settledAt: null,
95 deletedAt: null,
96 messages: [],
97 proposedPlans: [],
98 activities: [],
99 checkpoints: [],
100 session: null,
101 },
102 ],
103 updatedAt: NOW,
104 };
105}
106
107const snapshot: ThreadPullRequestSnapshot = {
108 state: "open",
109 title: "Add links",
110 headBranch: "feat/links",
111 baseBranch: "main",
112 isDraft: false,
113 updatedAt: NOW,
114 syncedAt: NOW,
115};
116
117it.layer(NodeServices.layer)("pull request link decider", (it) => {
118 it.effect("legacy unlink cannot remove a newer cross-host link", () =>
119 Effect.gen(function* () {
120 const own = makeLink();
121 const foreign = makeLink({
122 host: "github.enterprise.test",
123 url: "https://github.enterprise.test/t3tools/t3code/pull/42",
124 linkedAt: "2026-01-02T00:00:00Z",
125 });
126 const command = yield* decodeCommand({
127 type: "thread.meta.update",
128 commandId: "unlink",
129 threadId: THREAD_ID,
130 linkedPullRequest: null,
131 });
132 const decided = yield* decideOrchestrationCommand({
133 readModel: makeReadModel([own, foreign]),
134 command,
135 });
136 const event = expectSingleEvent(decided, "thread.pull-request-unlinked");
137 expect(event.payload.host).toBe("github.com");
138 }),
139 );
140
141 it.effect("legacy replacement preserves unrelated manual links", () =>
142 Effect.gen(function* () {
143 const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } });
144 const current = makeLink({ linkedAt: "2026-01-02T00:00:00Z" });
145 let model = makeReadModel([other, current]);
146 const command = yield* decodeCommand({
147 type: "thread.meta.update",
148 commandId: "replace",
149 threadId: THREAD_ID,
150 linkedPullRequest: {
151 projectId: "project-1",
152 repository: "t3tools/t3code",
153 number: 99,
154 url: "https://github.com/t3tools/t3code/pull/99",
155 },
156 });
157 const decided = yield* decideOrchestrationCommand({ readModel: model, command });
158 const events = Array.isArray(decided) ? decided : [decided];
159 expect(events.map((event) => event.type)).toEqual([
160 "thread.pull-request-unlinked",
161 "thread.pull-request-linked",
162 ]);
163 for (const event of events)
164 model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 });
165 expect(model.threads[0]!.pullRequests.map((link) => link.number)).toEqual([7, 99]);
166 }),
167 );
168 it.effect("round-trips an Azure legacy link and unlinks only its organization", () =>
169 Effect.gen(function* () {
170 const foreign = makeLink({
171 host: "dev.azure.com",
172 repository: "org-b/project/_git/web",
173 number: 7,
174 url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7",
175 });
176 let model = makeReadModel([foreign]);
177 model = {
178 ...model,
179 projects: model.projects.map((project) => ({
180 ...project,
181 repositoryIdentity: {
182 ...project.repositoryIdentity!,
183 provider: "azure-devops",
184 canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web",
185 displayName: "v3/org-a/project/web",
186 name: "web",
187 },
188 })),
189 };
190 const legacy = {
191 projectId: "project-1",
192 repository: "web",
193 number: 7,
194 url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
195 };
196 for (const linkedPullRequest of [legacy, null]) {
197 const command = yield* decodeCommand({
198 type: "thread.meta.update",
199 commandId: linkedPullRequest === null ? "unlink-azure" : "link-azure",
200 threadId: THREAD_ID,
201 linkedPullRequest,
202 });
203 const decided = yield* decideOrchestrationCommand({ readModel: model, command });
204 for (const event of Array.isArray(decided) ? decided : [decided]) {
205 model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 });
206 }
207 if (linkedPullRequest !== null) {
208 expect(model.threads[0]!.linkedPullRequest).toEqual(legacy);
209 expect(model.threads[0]!.pullRequests.map((link) => link.repository)).toEqual([
210 "org-b/project/_git/web",
211 "org-a/project/_git/web",
212 ]);
213 }
214 }
215 expect(model.threads[0]!.pullRequests).toEqual([foreign]);
216 expect(model.threads[0]!.linkedPullRequest).toBeNull();
217 }),
218 );
219 it.effect("legacy unlink alone does not emit an empty metadata event", () =>
220 Effect.gen(function* () {
221 const command = yield* decodeCommand({
222 type: "thread.meta.update",
223 commandId: "unlink",
224 threadId: THREAD_ID,
225 linkedPullRequest: null,
226 });
227 const decided = yield* decideOrchestrationCommand({
228 readModel: makeReadModel([makeLink()]),
229 command,
230 });
231 const events = Array.isArray(decided) ? decided : [decided];
232 expect(events.map((event) => event.type)).toEqual(["thread.pull-request-unlinked"]);
233 }),
234 );
235
236 for (const source of ["manual", "agent", "created", "stack"] as const) {
237 it.effect(`legacy unlink removes the visible ${source} link and preserves other requests`, () =>
238 Effect.gen(function* () {
239 const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } });
240 const current = makeLink({ source, linkedAt: "2026-01-02T00:00:00.000Z" });
241 let model = makeReadModel([other, current]);
242 // This is the pre-array command shape sent by older clients.
243 const command = yield* decodeCommand({
244 type: "thread.meta.update",
245 commandId: "legacy-unlink",
246 threadId: THREAD_ID,
247 linkedPullRequest: null,
248 title: "Renamed by old client",
249 });
250 const decided = yield* decideOrchestrationCommand({ readModel: model, command });
251 const events = Array.isArray(decided) ? decided : [decided];
252 for (const planned of events) {
253 const event = { ...planned, sequence: model.snapshotSequence + 1 };
254 const encoded = yield* Schema.encodeEffect(OrchestrationEvent)(event);
255 const decoded = yield* Schema.decodeUnknownEffect(OrchestrationEvent)(encoded);
256 // Older detail-event unions must never receive the new PR discriminants.
257 expect(isThreadDetailEvent(decoded)).toBe(false);
258 model = yield* projectEvent(model, decoded);
259 }
260 const thread = model.threads[0]!;
261 expect(thread.title).toBe("Renamed by old client");
262 expect(thread.pullRequests).toEqual(
263 source === "stack" ? [other, { ...current, source: "stack-dismissed" }] : [other],
264 );
265 // The old single-link field continues to track the remaining visible request.
266 expect(thread.linkedPullRequest?.number).toBe(7);
267 }),
268 );
269 }
270
271 it.effect("links a pull request with a normalized key and empty host state", () =>
272 Effect.gen(function* () {
273 const decided = yield* decideOrchestrationCommand({
274 command: {
275 type: "thread.pull-request.link",
276 commandId: CommandId.make("cmd-link"),
277 threadId: THREAD_ID,
278 host: " GitHub.com ",
279 repository: "T3Tools/T3Code",
280 number: 42,
281 url: "https://github.com/t3tools/t3code/pull/42",
282 source: "manual",
283 },
284 readModel: makeReadModel([]),
285 });
286 expect(Array.isArray(decided)).toBe(false);
287 const event = expectSingleEvent(decided, "thread.pull-request-linked");
288 expect(event.payload.link).toEqual({
289 host: "github.com",
290 repository: "t3tools/t3code",
291 number: 42,
292 url: "https://github.com/t3tools/t3code/pull/42",
293 source: "manual",
294 linkedAt: event.payload.updatedAt,
295 snapshot: null,
296 stack: null,
297 });
298 expect(event.payload.updatedAt).not.toBe(NOW);
299 }),
300 );
301
302 it.effect("rejects linking a pull request that is already linked", () =>
303 Effect.gen(function* () {
304 const error = yield* decideOrchestrationCommand({
305 command: {
306 type: "thread.pull-request.link",
307 commandId: CommandId.make("cmd-link-dup"),
308 threadId: THREAD_ID,
309 host: "GITHUB.COM",
310 repository: "t3tools/t3code",
311 number: 42,
312 url: "https://github.com/t3tools/t3code/pull/42",
313 source: "agent",
314 },
315 readModel: makeReadModel([makeLink()]),
316 }).pipe(Effect.flip);
317 expect(error._tag).toBe("OrchestrationCommandInvariantError");
318 }),
319 );
320
321 it.effect("re-linking a dismissed stack member un-dismisses it", () =>
322 Effect.gen(function* () {
323 const dismissed = makeLink({
324 source: "stack-dismissed",
325 snapshot,
326 stack: {
327 kind: "native",
328 id: "stack-1",
329 number: 1,
330 url: "https://github.com/t3tools/t3code/stack/1",
331 base: "main",
332 layers: [{ number: 42, headBranch: "feat/links", state: "open" }],
333 },
334 });
335 const decided = yield* decideOrchestrationCommand({
336 command: {
337 type: "thread.pull-request.link",
338 commandId: CommandId.make("cmd-relink"),
339 threadId: THREAD_ID,
340 host: "github.com",
341 repository: "t3tools/t3code",
342 number: 42,
343 url: "https://github.com/t3tools/t3code/pull/42",
344 source: "manual",
345 },
346 readModel: makeReadModel([dismissed]),
347 });
348 const event = expectSingleEvent(decided, "thread.pull-request-linked");
349 // Host state survives the flip; only the source changes.
350 expect(event.payload.link).toEqual({ ...dismissed, source: "manual" });
351 }),
352 );
353
354 it.effect("rejects a stack sync re-adding a dismissed stack member", () =>
355 Effect.gen(function* () {
356 const error = yield* decideOrchestrationCommand({
357 command: {
358 type: "thread.pull-request.link",
359 commandId: CommandId.make("cmd-stack-readd"),
360 threadId: THREAD_ID,
361 host: "github.com",
362 repository: "t3tools/t3code",
363 number: 42,
364 url: "https://github.com/t3tools/t3code/pull/42",
365 source: "stack",
366 },
367 readModel: makeReadModel([makeLink({ source: "stack-dismissed" })]),
368 }).pipe(Effect.flip);
369 expect(error._tag).toBe("OrchestrationCommandInvariantError");
370 }),
371 );
372
373 it.effect("unlinks a manual pull request", () =>
374 Effect.gen(function* () {
375 const decided = yield* decideOrchestrationCommand({
376 command: {
377 type: "thread.pull-request.unlink",
378 commandId: CommandId.make("cmd-unlink"),
379 threadId: THREAD_ID,
380 host: "GitHub.com",
381 repository: "t3tools/t3code",
382 number: 42,
383 },
384 readModel: makeReadModel([makeLink()]),
385 });
386 const event = expectSingleEvent(decided, "thread.pull-request-unlinked");
387 expect(event.payload).toMatchObject({
388 threadId: THREAD_ID,
389 host: "github.com",
390 repository: "t3tools/t3code",
391 number: 42,
392 });
393 }),
394 );
395
396 it.effect("unlinking a stack member leaves a stack-dismissed tombstone", () =>
397 Effect.gen(function* () {
398 const member = makeLink({ source: "stack", snapshot });
399 const decided = yield* decideOrchestrationCommand({
400 command: {
401 type: "thread.pull-request.unlink",
402 commandId: CommandId.make("cmd-unlink-stack"),
403 threadId: THREAD_ID,
404 host: "github.com",
405 repository: "t3tools/t3code",
406 number: 42,
407 },
408 readModel: makeReadModel([member]),
409 });
410 const event = expectSingleEvent(decided, "thread.pull-request-linked");
411 expect(event.payload.link).toEqual({ ...member, source: "stack-dismissed" });
412 }),
413 );
414
415 for (const source of ["manual", "agent", "created"] as const) {
416 it.effect(`unlinking a ${source} member prevents its sibling rediscovering it`, () =>
417 Effect.gen(function* () {
418 const member = makeLink({ source });
419 const sibling = makeLink({
420 number: 43,
421 source: "stack",
422 stack: {
423 kind: "native",
424 id: "stack-1",
425 number: 1,
426 url: "https://github.com/t3tools/t3code/stack/1",
427 base: "main",
428 layers: [
429 { number: 42, headBranch: "first", state: "open" },
430 { number: 43, headBranch: "second", state: "open" },
431 ],
432 },
433 });
434 let model = makeReadModel([member, sibling]);
435 const decided = yield* decideOrchestrationCommand({
436 readModel: model,
437 command: {
438 type: "thread.pull-request.unlink",
439 commandId: CommandId.make("remove"),
440 threadId: THREAD_ID,
441 host: member.host,
442 repository: member.repository,
443 number: member.number,
444 },
445 });
446 const event = expectSingleEvent(decided, "thread.pull-request-linked");
447 model = yield* projectEvent(model, { ...event, sequence: 1 });
448 expect(model.threads[0]!.pullRequests).toEqual([
449 { ...member, source: "stack-dismissed" },
450 sibling,
451 ]);
452 const rediscovered = yield* decideOrchestrationCommand({
453 readModel: model,
454 command: {
455 type: "thread.pull-request.link",
456 commandId: CommandId.make("rediscovered"),
457 threadId: THREAD_ID,
458 host: member.host,
459 repository: member.repository,
460 number: member.number,
461 url: member.url,
462 source: "stack",
463 },
464 }).pipe(Effect.result);
465 expect(rediscovered._tag).toBe("Failure");
466 }),
467 );
468 }
469
470 it.effect("rejects unlinking a pull request that is not linked", () =>
471 Effect.gen(function* () {
472 const error = yield* decideOrchestrationCommand({
473 command: {
474 type: "thread.pull-request.unlink",
475 commandId: CommandId.make("cmd-unlink-missing"),
476 threadId: THREAD_ID,
477 host: "github.com",
478 repository: "t3tools/t3code",
479 number: 7,
480 },
481 readModel: makeReadModel([makeLink()]),
482 }).pipe(Effect.flip);
483 expect(error._tag).toBe("OrchestrationCommandInvariantError");
484 }),
485 );
486
487 it.effect("rejects syncing a pull request that is not linked", () =>
488 Effect.gen(function* () {
489 const error = yield* decideOrchestrationCommand({
490 command: {
491 type: "thread.pull-request-link.sync",
492 commandId: CommandId.make("cmd-sync-missing"),
493 threadId: THREAD_ID,
494 host: "github.com",
495 repository: "t3tools/t3code",
496 number: 42,
497 snapshot,
498 stack: null,
499 },
500 readModel: makeReadModel([]),
501 }).pipe(Effect.flip);
502 expect(error._tag).toBe("OrchestrationCommandInvariantError");
503 }),
504 );
505
506 it.effect("sync emits the host snapshot for a linked pull request", () =>
507 Effect.gen(function* () {
508 const decided = yield* decideOrchestrationCommand({
509 command: {
510 type: "thread.pull-request-link.sync",
511 commandId: CommandId.make("cmd-sync"),
512 threadId: THREAD_ID,
513 host: "GitHub.com",
514 repository: "t3tools/t3code",
515 number: 42,
516 snapshot,
517 stack: null,
518 },
519 readModel: makeReadModel([makeLink()]),
520 });
521 const event = expectSingleEvent(decided, "thread.pull-request-synced");
522 expect(event.payload).toMatchObject({
523 threadId: THREAD_ID,
524 host: "github.com",
525 repository: "t3tools/t3code",
526 number: 42,
527 snapshot,
528 stack: null,
529 });
530 }),
531 );
532});
apps/server/src/orchestration/decider.questionAttachments.test.ts+1
...@@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = {...@@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = {
26 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },26 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
27 runtimeMode: "full-access",27 runtimeMode: "full-access",
28 interactionMode: "default",28 interactionMode: "default",
29 pullRequests: [],
29 branch: null,30 branch: null,
30 worktreePath: null,31 worktreePath: null,
31 latestTurn: null,32 latestTurn: null,
apps/server/src/orchestration/decider.settled.test.ts+1
...@@ -47,6 +47,7 @@ function makeReadModel(...@@ -47,6 +47,7 @@ function makeReadModel(
47 interactionMode: "default",47 interactionMode: "default",
48 branch: null,48 branch: null,
49 worktreePath: null,49 worktreePath: null,
50 pullRequests: [],
50 latestTurn: null,51 latestTurn: null,
51 createdAt: NOW,52 createdAt: NOW,
52 updatedAt: NOW,53 updatedAt: NOW,
apps/server/src/orchestration/decider.snoozed.test.ts+1
...@@ -41,6 +41,7 @@ function makeReadModel(input: {...@@ -41,6 +41,7 @@ function makeReadModel(input: {
41 interactionMode: "default",41 interactionMode: "default",
42 branch: null,42 branch: null,
43 worktreePath: null,43 worktreePath: null,
44 pullRequests: [],
44 latestTurn: null,45 latestTurn: null,
45 createdAt: NOW,46 createdAt: NOW,
46 updatedAt: NOW,47 updatedAt: NOW,
apps/server/src/orchestration/decider.titleRegeneration.test.ts+1
...@@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = {...@@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = {
26 interactionMode: "default",26 interactionMode: "default",
27 branch: null,27 branch: null,
28 worktreePath: null,28 worktreePath: null,
29 pullRequests: [],
29 latestTurn: null,30 latestTurn: null,
30 createdAt: UPDATED_AT,31 createdAt: UPDATED_AT,
31 updatedAt: UPDATED_AT,32 updatedAt: UPDATED_AT,
apps/server/src/orchestration/decider.ts+221
...@@ -10,8 +10,16 @@ import {...@@ -10,8 +10,16 @@ import {
10 type OrchestrationEvent,10 type OrchestrationEvent,
11 type OrchestrationReadModel,11 type OrchestrationReadModel,
12 type OrchestrationThread,12 type OrchestrationThread,
13 type ThreadPullRequestKey,
14 type ThreadPullRequestLink,
13 type OrchestrationThreadActivity,15 type OrchestrationThreadActivity,
14} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import {
18 legacyLinkedPullRequestOf,
19 legacyThreadPullRequestKey,
20 normalizeThreadPullRequestKey,
21 threadPullRequestKeysEqual,
22} from "@t3tools/shared/threadPullRequests";
15import { compareDateTimeStrings } from "@t3tools/shared/dateTime";23import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
16import * as DateTime from "effect/DateTime";24import * as DateTime from "effect/DateTime";
17import * as Crypto from "effect/Crypto";25import * as Crypto from "effect/Crypto";
...@@ -121,6 +129,13 @@ function hasQueuedTurnStartForThread(...@@ -121,6 +129,13 @@ function hasQueuedTurnStartForThread(
121 );129 );
122}130}
123131
132function findPullRequestLink(
133 thread: Pick<OrchestrationThread, "pullRequests">,
134 key: ThreadPullRequestKey,
135): ThreadPullRequestLink | undefined {
136 return thread.pullRequests.find((link) => threadPullRequestKeysEqual(link, key));
137}
138
124function withEventBase(139function withEventBase(
125 input: Pick<OrchestrationCommand, "commandId"> & {140 input: Pick<OrchestrationCommand, "commandId"> & {
126 readonly aggregateKind: OrchestrationEvent["aggregateKind"];141 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
...@@ -876,6 +891,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -876,6 +891,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
876 command,891 command,
877 threadId: command.threadId,892 threadId: command.threadId,
878 });893 });
894 // Old clients only see the derived single link. Unlink that request through
895 // the same command path as modern clients, including stack dismissal, while
896 // retaining other links they cannot see. Historical metadata events still replay unchanged.
897 const legacy = legacyLinkedPullRequestOf(
898 thread.pullRequests,
899 thread.projectId,
900 readModel.projects.find((project) => project.id === thread.projectId)?.repositoryIdentity,
901 );
902 const currentPullRequest =
903 legacy === null
904 ? null
905 : (thread.pullRequests.find(
906 (link) => link.url === legacy.url && link.number === legacy.number,
907 ) ?? null);
908 if (command.linkedPullRequest != null) {
909 const { linkedPullRequest: linked, ...metadata } = command;
910 const project = readModel.projects.find((project) => project.id === thread.projectId);
911 let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown";
912 try {
913 host = new URL(linked.url).hostname;
914 } catch {
915 // Historical clients can send links without a parseable URL.
916 }
917 const hasMetadata = Object.entries(metadata).some(
918 ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined,
919 );
920 return yield* decideCommandSequence({
921 readModel,
922 commands: [
923 ...(hasMetadata ? [metadata] : []),
924 ...(currentPullRequest?.source === "manual"
925 ? [
926 {
927 type: "thread.pull-request.unlink" as const,
928 commandId: command.commandId,
929 threadId: command.threadId,
930 host: currentPullRequest.host,
931 repository: currentPullRequest.repository,
932 number: currentPullRequest.number,
933 },
934 ]
935 : []),
936 {
937 type: "thread.pull-request.link",
938 commandId: command.commandId,
939 threadId: command.threadId,
940 ...legacyThreadPullRequestKey(linked, host),
941 url: linked.url,
942 source: "manual",
943 },
944 ],
945 });
946 }
947
948 if (command.linkedPullRequest === null && currentPullRequest !== null) {
949 const { linkedPullRequest: _linkedPullRequest, ...metadata } = command;
950 const hasMetadata = Object.entries(metadata).some(
951 ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined,
952 );
953 return yield* decideCommandSequence({
954 readModel,
955 commands: [
956 ...(hasMetadata ? [metadata] : []),
957 {
958 type: "thread.pull-request.unlink",
959 commandId: command.commandId,
960 threadId: command.threadId,
961 host: currentPullRequest.host,
962 repository: currentPullRequest.repository,
963 number: currentPullRequest.number,
964 },
965 ],
966 });
967 }
879 const branch =968 const branch =
880 command.branch !== undefined &&969 command.branch !== undefined &&
881 command.expectedBranch !== undefined &&970 command.expectedBranch !== undefined &&
...@@ -920,6 +1009,138 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -920,6 +1009,138 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
920 };1009 };
921 }1010 }
9221011
1012 case "thread.pull-request.link": {
1013 const thread = yield* requireThread({
1014 readModel,
1015 command,
1016 threadId: command.threadId,
1017 });
1018 const key = normalizeThreadPullRequestKey(command);
1019 const existing = findPullRequestLink(thread, key);
1020 // An explicit link on a dismissed stack member un-dismisses it; any
1021 // other duplicate is a no-op the engine would reject as zero-event.
1022 const undismisses =
1023 existing?.source === "stack-dismissed" &&
1024 (command.source === "manual" || command.source === "agent" || command.source === "created");
1025 if (existing !== undefined && !undismisses) {
1026 return yield* new OrchestrationCommandInvariantError({
1027 commandType: command.type,
1028 detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`,
1029 });
1030 }
1031 const occurredAt = yield* nowIso;
1032 return {
1033 ...(yield* withEventBase({
1034 aggregateKind: "thread",
1035 aggregateId: command.threadId,
1036 occurredAt,
1037 commandId: command.commandId,
1038 })),
1039 type: "thread.pull-request-linked",
1040 payload: {
1041 threadId: command.threadId,
1042 link:
1043 existing !== undefined
1044 ? { ...existing, url: command.url, source: command.source }
1045 : {
1046 ...key,
1047 url: command.url,
1048 source: command.source,
1049 linkedAt: occurredAt,
1050 snapshot: null,
1051 stack: null,
1052 },
1053 updatedAt: occurredAt,
1054 },
1055 };
1056 }
1057
1058 case "thread.pull-request.unlink": {
1059 const thread = yield* requireThread({
1060 readModel,
1061 command,
1062 threadId: command.threadId,
1063 });
1064 const key = normalizeThreadPullRequestKey(command);
1065 const existing = findPullRequestLink(thread, key);
1066 if (existing === undefined) {
1067 return yield* new OrchestrationCommandInvariantError({
1068 commandType: command.type,
1069 detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`,
1070 });
1071 }
1072 const occurredAt = yield* nowIso;
1073 const eventBase = yield* withEventBase({
1074 aggregateKind: "thread",
1075 aggregateId: command.threadId,
1076 occurredAt,
1077 commandId: command.commandId,
1078 });
1079 // Any known native-stack member needs a tombstone, regardless of who linked it.
1080 // A sibling can rediscover it even before this link has its own stack snapshot.
1081 const belongsToStack =
1082 existing.source === "stack" ||
1083 existing.stack !== null ||
1084 thread.pullRequests.some(
1085 (link) =>
1086 link.host.toLowerCase() === key.host &&
1087 link.repository.toLowerCase() === key.repository &&
1088 link.stack?.layers.some((layer) => layer.number === key.number),
1089 );
1090 if (belongsToStack) {
1091 return {
1092 ...eventBase,
1093 type: "thread.pull-request-linked",
1094 payload: {
1095 threadId: command.threadId,
1096 link: { ...existing, source: "stack-dismissed" },
1097 updatedAt: occurredAt,
1098 },
1099 };
1100 }
1101 return {
1102 ...eventBase,
1103 type: "thread.pull-request-unlinked",
1104 payload: {
1105 threadId: command.threadId,
1106 ...key,
1107 updatedAt: occurredAt,
1108 },
1109 };
1110 }
1111
1112 case "thread.pull-request-link.sync": {
1113 const thread = yield* requireThread({
1114 readModel,
1115 command,
1116 threadId: command.threadId,
1117 });
1118 const key = normalizeThreadPullRequestKey(command);
1119 if (findPullRequestLink(thread, key) === undefined) {
1120 return yield* new OrchestrationCommandInvariantError({
1121 commandType: command.type,
1122 detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`,
1123 });
1124 }
1125 const occurredAt = yield* nowIso;
1126 return {
1127 ...(yield* withEventBase({
1128 aggregateKind: "thread",
1129 aggregateId: command.threadId,
1130 occurredAt,
1131 commandId: command.commandId,
1132 })),
1133 type: "thread.pull-request-synced",
1134 payload: {
1135 threadId: command.threadId,
1136 ...key,
1137 snapshot: command.snapshot,
1138 stack: command.stack,
1139 updatedAt: occurredAt,
1140 },
1141 };
1142 }
1143
923 case "thread.pull-request.sync": {1144 case "thread.pull-request.sync": {
924 const thread = yield* requireThreadNotArchived({1145 const thread = yield* requireThreadNotArchived({
925 readModel,1146 readModel,
apps/server/src/orchestration/decider.userInputDismiss.test.ts+1
...@@ -49,6 +49,7 @@ function makeReadModel(...@@ -49,6 +49,7 @@ function makeReadModel(
49 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },49 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
50 runtimeMode: "full-access",50 runtimeMode: "full-access",
51 interactionMode: "default",51 interactionMode: "default",
52 pullRequests: [],
52 branch: null,53 branch: null,
53 worktreePath: null,54 worktreePath: null,
54 latestTurn: null,55 latestTurn: null,
apps/server/src/orchestration/projector.pullRequests.test.ts created+417
...@@ -0,0 +1,417 @@
1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ThreadId,
6 type OrchestrationEvent,
7 type OrchestrationReadModel,
8 type RepositoryIdentity,
9 type ThreadPullRequestLink,
10 type ThreadPullRequestSnapshot,
11} from "@t3tools/contracts";
12import { expect, it } from "@effect/vitest";
13import * as Effect from "effect/Effect";
14
15import { createEmptyReadModel, projectEvent } from "./projector.ts";
16
17const NOW = "2026-01-01T00:00:00.000Z";
18const LATER = "2026-01-02T00:00:00.000Z";
19const THREAD_ID = ThreadId.make("thread-1");
20const PROJECT_ID = ProjectId.make("project-1");
21
22function makeEvent(input: {
23 readonly sequence: number;
24 readonly type: OrchestrationEvent["type"];
25 readonly payload: unknown;
26}): OrchestrationEvent {
27 return {
28 sequence: input.sequence,
29 eventId: EventId.make(`event-${input.sequence}`),
30 type: input.type,
31 aggregateKind: "thread",
32 aggregateId: THREAD_ID,
33 occurredAt: NOW,
34 commandId: CommandId.make(`command-${input.sequence}`),
35 causationEventId: null,
36 correlationId: null,
37 metadata: {},
38 payload: input.payload as never,
39 } as OrchestrationEvent;
40}
41
42function makeLink(overrides: Partial<ThreadPullRequestLink> = {}): ThreadPullRequestLink {
43 return {
44 host: "github.com",
45 repository: "t3tools/t3code",
46 number: 42,
47 url: "https://github.com/t3tools/t3code/pull/42",
48 source: "manual",
49 linkedAt: NOW,
50 snapshot: null,
51 stack: null,
52 ...overrides,
53 };
54}
55
56const snapshot: ThreadPullRequestSnapshot = {
57 state: "merged",
58 title: "Add links",
59 headBranch: "feat/links",
60 baseBranch: "main",
61 isDraft: false,
62 updatedAt: LATER,
63 syncedAt: LATER,
64};
65
66const createThread = (model: OrchestrationReadModel) =>
67 projectEvent(
68 model,
69 makeEvent({
70 sequence: model.snapshotSequence + 1,
71 type: "thread.created",
72 payload: {
73 threadId: THREAD_ID,
74 projectId: PROJECT_ID,
75 title: "Thread",
76 modelSelection: { provider: "codex", model: "gpt-5.4" },
77 runtimeMode: "full-access",
78 interactionMode: "default",
79 branch: null,
80 worktreePath: null,
81 createdAt: NOW,
82 updatedAt: NOW,
83 },
84 }),
85 );
86
87const createProject = (model: OrchestrationReadModel, repositoryIdentity: RepositoryIdentity) =>
88 projectEvent(model, {
89 ...makeEvent({
90 sequence: model.snapshotSequence + 1,
91 type: "project.created",
92 payload: {
93 projectId: PROJECT_ID,
94 title: "Project",
95 workspaceRoot: "/workspace/project",
96 defaultModelSelection: null,
97 scripts: [],
98 createdAt: NOW,
99 updatedAt: NOW,
100 },
101 }),
102 aggregateKind: "project",
103 aggregateId: PROJECT_ID,
104 }).pipe(
105 Effect.map((next) => ({
106 ...next,
107 projects: next.projects.map((project) =>
108 project.id === PROJECT_ID ? { ...project, repositoryIdentity } : project,
109 ),
110 })),
111 );
112
113it.effect("seeds threads with no pull requests", () =>
114 Effect.gen(function* () {
115 const created = yield* createThread(createEmptyReadModel(NOW));
116 expect(created.threads[0]?.pullRequests).toEqual([]);
117 expect(created.threads[0]?.linkedPullRequest ?? null).toBeNull();
118 }),
119);
120
121it.effect("projects link, sync, and unlink onto the thread", () =>
122 Effect.gen(function* () {
123 const created = yield* createThread(
124 yield* createProject(createEmptyReadModel(NOW), {
125 canonicalKey: "github.com/t3tools/t3code",
126 provider: "github",
127 displayName: "t3tools/t3code",
128 locator: {
129 source: "git-remote",
130 remoteName: "origin",
131 remoteUrl: "https://github.com/t3tools/t3code.git",
132 },
133 }),
134 );
135 const link = makeLink();
136
137 const linked = yield* projectEvent(
138 created,
139 makeEvent({
140 sequence: 2,
141 type: "thread.pull-request-linked",
142 payload: { threadId: THREAD_ID, link, updatedAt: LATER },
143 }),
144 );
145 expect(linked.threads[0]?.pullRequests).toEqual([link]);
146 expect(linked.threads[0]?.updatedAt).toBe(LATER);
147 // The legacy field is derived from the array so old clients keep working.
148 expect(linked.threads[0]?.linkedPullRequest).toEqual({
149 projectId: PROJECT_ID,
150 repository: "t3tools/t3code",
151 number: 42,
152 url: "https://github.com/t3tools/t3code/pull/42",
153 });
154
155 // A second link for the same key replaces in place (used for un-dismiss
156 // and stack tombstones), never duplicates.
157 const relinked = yield* projectEvent(
158 linked,
159 makeEvent({
160 sequence: 3,
161 type: "thread.pull-request-linked",
162 payload: {
163 threadId: THREAD_ID,
164 link: { ...link, host: "GITHUB.COM", source: "agent" },
165 updatedAt: LATER,
166 },
167 }),
168 );
169 expect(relinked.threads[0]?.pullRequests).toHaveLength(1);
170 expect(relinked.threads[0]?.pullRequests[0]?.source).toBe("agent");
171
172 const synced = yield* projectEvent(
173 relinked,
174 makeEvent({
175 sequence: 4,
176 type: "thread.pull-request-synced",
177 payload: {
178 threadId: THREAD_ID,
179 host: "github.com",
180 repository: "t3tools/t3code",
181 number: 42,
182 snapshot,
183 stack: null,
184 updatedAt: LATER,
185 },
186 }),
187 );
188 expect(synced.threads[0]?.pullRequests[0]?.snapshot).toEqual(snapshot);
189
190 const unlinked = yield* projectEvent(
191 synced,
192 makeEvent({
193 sequence: 5,
194 type: "thread.pull-request-unlinked",
195 payload: {
196 threadId: THREAD_ID,
197 host: "github.com",
198 repository: "t3tools/t3code",
199 number: 42,
200 updatedAt: LATER,
201 },
202 }),
203 );
204 expect(unlinked.threads[0]?.pullRequests).toEqual([]);
205 expect(unlinked.threads[0]?.linkedPullRequest).toBeNull();
206 }),
207);
208
209it.effect("ignores a sync for a pull request that is no longer linked", () =>
210 Effect.gen(function* () {
211 const created = yield* createThread(createEmptyReadModel(NOW));
212 const other = makeLink({ number: 7, url: "https://github.com/t3tools/t3code/pull/7" });
213 const linked = yield* projectEvent(
214 created,
215 makeEvent({
216 sequence: 2,
217 type: "thread.pull-request-linked",
218 payload: { threadId: THREAD_ID, link: other, updatedAt: NOW },
219 }),
220 );
221 const synced = yield* projectEvent(
222 linked,
223 makeEvent({
224 sequence: 3,
225 type: "thread.pull-request-synced",
226 payload: {
227 threadId: THREAD_ID,
228 host: "github.com",
229 repository: "t3tools/t3code",
230 number: 42,
231 snapshot,
232 stack: null,
233 updatedAt: LATER,
234 },
235 }),
236 );
237 expect(synced.threads[0]?.pullRequests).toEqual([other]);
238 expect(synced.threads[0]?.updatedAt).toBe(NOW);
239 }),
240);
241
242it.effect("mirrors legacy meta-updated links into pullRequests using the project host", () =>
243 Effect.gen(function* () {
244 const withProject = yield* createProject(createEmptyReadModel(NOW), {
245 canonicalKey: "GitHub.com/t3tools/t3code",
246 provider: "github",
247 displayName: "t3tools/t3code",
248 locator: {
249 source: "git-remote",
250 remoteName: "origin",
251 remoteUrl: "git@github.com:t3tools/t3code.git",
252 },
253 });
254 const created = yield* createThread(withProject);
255 const agentLink = makeLink({
256 number: 7,
257 url: "https://github.com/t3tools/t3code/pull/7",
258 source: "agent",
259 });
260 const withAgentLink = yield* projectEvent(
261 created,
262 makeEvent({
263 sequence: 3,
264 type: "thread.pull-request-linked",
265 payload: { threadId: THREAD_ID, link: agentLink, updatedAt: NOW },
266 }),
267 );
268
269 const legacyLinked = yield* projectEvent(
270 withAgentLink,
271 makeEvent({
272 sequence: 4,
273 type: "thread.meta-updated",
274 payload: {
275 threadId: THREAD_ID,
276 linkedPullRequest: {
277 projectId: PROJECT_ID,
278 repository: "T3Tools/T3Code",
279 number: 42,
280 url: "https://github.com/t3tools/t3code/pull/42",
281 },
282 updatedAt: LATER,
283 },
284 }),
285 );
286 expect(legacyLinked.threads[0]?.pullRequests).toEqual([
287 agentLink,
288 {
289 host: "github.com",
290 repository: "t3tools/t3code",
291 number: 42,
292 url: "https://github.com/t3tools/t3code/pull/42",
293 source: "manual",
294 linkedAt: LATER,
295 snapshot: null,
296 stack: null,
297 },
298 ]);
299 // Two open links read as a stack; the derived field points at the top.
300 expect(legacyLinked.threads[0]?.linkedPullRequest).toEqual({
301 projectId: PROJECT_ID,
302 repository: "t3tools/t3code",
303 number: 42,
304 url: "https://github.com/t3tools/t3code/pull/42",
305 });
306
307 // Null clears only the manual link; the agent's stays.
308 const legacyCleared = yield* projectEvent(
309 legacyLinked,
310 makeEvent({
311 sequence: 5,
312 type: "thread.meta-updated",
313 payload: { threadId: THREAD_ID, linkedPullRequest: null, updatedAt: LATER },
314 }),
315 );
316 expect(legacyCleared.threads[0]?.pullRequests).toEqual([agentLink]);
317 expect(legacyCleared.threads[0]?.linkedPullRequest).toEqual({
318 projectId: PROJECT_ID,
319 repository: "t3tools/t3code",
320 number: 7,
321 url: "https://github.com/t3tools/t3code/pull/7",
322 });
323 }),
324);
325
326it.effect("falls back to the link URL host when the project has no repository identity", () =>
327 Effect.gen(function* () {
328 const created = yield* createThread(createEmptyReadModel(NOW));
329 const legacyLinked = yield* projectEvent(
330 created,
331 makeEvent({
332 sequence: 2,
333 type: "thread.meta-updated",
334 payload: {
335 threadId: THREAD_ID,
336 linkedPullRequest: {
337 projectId: PROJECT_ID,
338 repository: "t3tools/t3code",
339 number: 42,
340 url: "https://GitLab.example.com/t3tools/t3code/-/merge_requests/42",
341 },
342 updatedAt: LATER,
343 },
344 }),
345 );
346 expect(legacyLinked.threads[0]?.pullRequests[0]?.host).toBe("gitlab.example.com");
347 }),
348);
349
350it.effect("leaves pullRequests alone when meta-updated carries no legacy link", () =>
351 Effect.gen(function* () {
352 const created = yield* createThread(createEmptyReadModel(NOW));
353 const link = makeLink();
354 const linked = yield* projectEvent(
355 created,
356 makeEvent({
357 sequence: 2,
358 type: "thread.pull-request-linked",
359 payload: { threadId: THREAD_ID, link, updatedAt: NOW },
360 }),
361 );
362 const retitled = yield* projectEvent(
363 linked,
364 makeEvent({
365 sequence: 3,
366 type: "thread.meta-updated",
367 payload: { threadId: THREAD_ID, title: "Renamed", updatedAt: LATER },
368 }),
369 );
370 expect(retitled.threads[0]?.title).toBe("Renamed");
371 expect(retitled.threads[0]?.pullRequests).toEqual([link]);
372 }),
373);
374
375it.effect("replays Azure legacy selectors as full repository keys", () =>
376 Effect.gen(function* () {
377 const withProject = yield* createProject(createEmptyReadModel(NOW), {
378 canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web",
379 provider: "azure-devops",
380 displayName: "v3/org-a/project/web",
381 name: "web",
382 locator: {
383 source: "git-remote",
384 remoteName: "origin",
385 remoteUrl: "git@ssh.dev.azure.com:v3/org-a/project/web",
386 },
387 });
388 const created = yield* createThread(withProject);
389 const legacy = {
390 projectId: PROJECT_ID,
391 repository: "web",
392 number: 7,
393 url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
394 };
395 const model = yield* projectEvent(
396 created,
397 makeEvent({
398 sequence: 3,
399 type: "thread.meta-updated",
400 payload: { threadId: THREAD_ID, linkedPullRequest: legacy, updatedAt: LATER },
401 }),
402 );
403 expect(model.threads[0]?.pullRequests).toEqual([
404 {
405 host: "dev.azure.com",
406 repository: "org-a/project/_git/web",
407 number: 7,
408 url: legacy.url,
409 source: "manual",
410 linkedAt: LATER,
411 snapshot: null,
412 stack: null,
413 },
414 ]);
415 expect(model.threads[0]?.linkedPullRequest).toEqual(legacy);
416 }),
417);
apps/server/src/orchestration/projector.test.ts+26-1
...@@ -86,6 +86,7 @@ describe("orchestration projector", () => {...@@ -86,6 +86,7 @@ describe("orchestration projector", () => {
86 interactionMode: "default",86 interactionMode: "default",
87 branch: null,87 branch: null,
88 worktreePath: null,88 worktreePath: null,
89 pullRequests: [],
89 branchPullRequest: null,90 branchPullRequest: null,
90 latestTurn: null,91 latestTurn: null,
91 createdAt: now,92 createdAt: now,
...@@ -117,7 +118,31 @@ describe("orchestration projector", () => {...@@ -117,7 +118,31 @@ describe("orchestration projector", () => {
117 commandId: null,118 commandId: null,
118 };119 };
119 let model = yield* projectEvent(120 let model = yield* projectEvent(
120 createEmptyReadModel(now),121 {
122 ...createEmptyReadModel(now),
123 projects: [
124 {
125 id: ProjectId.make("project-1"),
126 title: "T3 Code",
127 workspaceRoot: "/repo",
128 defaultModelSelection: null,
129 scripts: [],
130 createdAt: now,
131 updatedAt: now,
132 deletedAt: null,
133 repositoryIdentity: {
134 canonicalKey: "github.com/pingdotgg/t3code",
135 provider: "github",
136 displayName: "pingdotgg/t3code",
137 locator: {
138 source: "git-remote",
139 remoteName: "origin",
140 remoteUrl: "https://github.com/pingdotgg/t3code.git",
141 },
142 },
143 },
144 ],
145 },
121 makeEvent({146 makeEvent({
122 ...eventFields,147 ...eventFields,
123 sequence: 1,148 sequence: 1,
apps/server/src/orchestration/projector.ts+212-25
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts";1import type {
2 OrchestrationEvent,
3 OrchestrationProject,
4 OrchestrationReadModel,
5 ThreadId,
6 ThreadLinkedPullRequest,
7 ThreadPullRequestKey,
8 ThreadPullRequestLink,
9} from "@t3tools/contracts";
2import {10import {
3 isImportedAgentSessionMessageId,11 isImportedAgentSessionMessageId,
4 OrchestrationCheckpointSummary,12 OrchestrationCheckpointSummary,
...@@ -6,6 +14,11 @@ import {...@@ -6,6 +14,11 @@ import {
6 OrchestrationSession,14 OrchestrationSession,
7 OrchestrationThread,15 OrchestrationThread,
8} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import {
18 legacyLinkedPullRequestOf,
19 legacyThreadPullRequestKey,
20 threadPullRequestKeysEqual,
21} from "@t3tools/shared/threadPullRequests";
9import { compareDateTimeStrings } from "@t3tools/shared/dateTime";22import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
10import * as Effect from "effect/Effect";23import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";24import * as Schema from "effect/Schema";
...@@ -28,6 +41,9 @@ import {...@@ -28,6 +41,9 @@ import {
28 ThreadSettledPayload,41 ThreadSettledPayload,
29 ThreadPinnedPayload,42 ThreadPinnedPayload,
30 ThreadPinReorderedPayload,43 ThreadPinReorderedPayload,
44 ThreadPullRequestLinkedPayload,
45 ThreadPullRequestSyncedPayload,
46 ThreadPullRequestUnlinkedPayload,
31 ThreadSnoozedPayload,47 ThreadSnoozedPayload,
32 ThreadUnpinnedPayload,48 ThreadUnpinnedPayload,
33 ThreadUnarchivedPayload,49 ThreadUnarchivedPayload,
...@@ -101,6 +117,77 @@ function updateThread(...@@ -101,6 +117,77 @@ function updateThread(
101 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));117 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
102}118}
103119
120/** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */
121function pullRequestsPatch(
122 thread: Pick<OrchestrationThread, "projectId">,
123 pullRequests: ReadonlyArray<ThreadPullRequestLink>,
124 projects: OrchestrationReadModel["projects"],
125): Pick<OrchestrationThread, "pullRequests" | "linkedPullRequest"> {
126 return {
127 pullRequests,
128 linkedPullRequest: legacyLinkedPullRequestOf(
129 pullRequests,
130 thread.projectId,
131 projects.find((project) => project.id === thread.projectId)?.repositoryIdentity,
132 ),
133 };
134}
135
136function upsertPullRequestLink(
137 pullRequests: ReadonlyArray<ThreadPullRequestLink>,
138 link: ThreadPullRequestLink,
139): ReadonlyArray<ThreadPullRequestLink> {
140 const index = pullRequests.findIndex((entry) => threadPullRequestKeysEqual(entry, link));
141 return index === -1
142 ? [...pullRequests, link]
143 : pullRequests.map((entry, entryIndex) => (entryIndex === index ? link : entry));
144}
145
146function removePullRequestLink(
147 pullRequests: ReadonlyArray<ThreadPullRequestLink>,
148 key: ThreadPullRequestKey,
149): ReadonlyArray<ThreadPullRequestLink> {
150 return pullRequests.filter((entry) => !threadPullRequestKeysEqual(entry, key));
151}
152
153/**
154 * Host for a legacy `linkedPullRequest` being replayed into the link array.
155 * Legacy links never carried one; the project's canonical key
156 * (`<host>/<owner>/<name>`) is the best witness, then the link URL.
157 */
158function legacyPullRequestHost(
159 project: OrchestrationProject | undefined,
160 linked: ThreadLinkedPullRequest,
161): string {
162 const canonicalHost = project?.repositoryIdentity?.canonicalKey.split("/")[0];
163 if (canonicalHost) return canonicalHost.toLowerCase();
164 try {
165 return new URL(linked.url).hostname.toLowerCase();
166 } catch {
167 return "unknown";
168 }
169}
170
171function legacyLinkToPullRequests(
172 thread: Pick<OrchestrationThread, "pullRequests">,
173 project: OrchestrationProject | undefined,
174 linked: ThreadLinkedPullRequest | null,
175 linkedAt: string,
176): ReadonlyArray<ThreadPullRequestLink> {
177 // The legacy field held one user-chosen link, so null clears exactly the
178 // manual ones and leaves created/agent/stack links alone.
179 const withoutManual = thread.pullRequests.filter((entry) => entry.source !== "manual");
180 if (linked === null) return withoutManual;
181 return upsertPullRequestLink(withoutManual, {
182 ...legacyThreadPullRequestKey(linked, legacyPullRequestHost(project, linked)),
183 url: linked.url,
184 source: "manual",
185 linkedAt,
186 snapshot: null,
187 stack: null,
188 });
189}
190
104function decodeForEvent<A>(191function decodeForEvent<A>(
105 schema: Schema.Decoder<A, never>,192 schema: Schema.Decoder<A, never>,
106 value: unknown,193 value: unknown,
...@@ -336,6 +423,7 @@ export function projectEvent(...@@ -336,6 +423,7 @@ export function projectEvent(
336 interactionMode: payload.interactionMode,423 interactionMode: payload.interactionMode,
337 branch: payload.branch,424 branch: payload.branch,
338 worktreePath: payload.worktreePath,425 worktreePath: payload.worktreePath,
426 pullRequests: [],
339 branchPullRequest: null,427 branchPullRequest: null,
340 latestTurn: null,428 latestTurn: null,
341 createdAt: payload.createdAt,429 createdAt: payload.createdAt,
...@@ -498,30 +586,129 @@ export function projectEvent(...@@ -498,30 +586,129 @@ export function projectEvent(
498586
499 case "thread.meta-updated":587 case "thread.meta-updated":
500 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(588 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
501 Effect.map((payload) => ({589 Effect.map((payload) => {
502 ...nextBase,590 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
503 threads: updateThread(nextBase.threads, payload.threadId, {591 // Legacy single-link events replay into the link array so the
504 ...(payload.title !== undefined ? { title: payload.title } : {}),592 // derived linkedPullRequest and pullRequests never disagree.
505 ...(payload.activeOrderKey !== undefined593 const legacyLinkPatch =
506 ? { activeOrderKey: payload.activeOrderKey }594 thread !== undefined && payload.linkedPullRequest !== undefined
507 : {}),595 ? pullRequestsPatch(
508 ...(payload.titleRegeneration !== undefined596 thread,
509 ? { titleRegeneration: payload.titleRegeneration }597 legacyLinkToPullRequests(
510 : {}),598 thread,
511 ...(payload.modelSelection !== undefined599 nextBase.projects.find((project) => project.id === thread.projectId),
512 ? { modelSelection: payload.modelSelection }600 payload.linkedPullRequest,
513 : {}),601 payload.updatedAt,
514 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),602 ),
515 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),603 nextBase.projects,
516 ...(payload.linkedPullRequest !== undefined604 )
517 ? { linkedPullRequest: payload.linkedPullRequest }605 : {};
518 : {}),606 return {
519 ...(payload.branchPullRequest !== undefined607 ...nextBase,
520 ? { branchPullRequest: payload.branchPullRequest }608 threads: updateThread(nextBase.threads, payload.threadId, {
521 : {}),609 ...(payload.title !== undefined ? { title: payload.title } : {}),
522 updatedAt: payload.updatedAt,610 ...(payload.titleRegeneration !== undefined
523 }),611 ? { titleRegeneration: payload.titleRegeneration }
524 })),612 : {}),
613 ...(payload.modelSelection !== undefined
614 ? { modelSelection: payload.modelSelection }
615 : {}),
616 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
617 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
618 ...(payload.activeOrderKey !== undefined
619 ? { activeOrderKey: payload.activeOrderKey }
620 : {}),
621 ...(payload.branchPullRequest !== undefined
622 ? { branchPullRequest: payload.branchPullRequest }
623 : {}),
624 ...legacyLinkPatch,
625 updatedAt: payload.updatedAt,
626 }),
627 };
628 }),
629 );
630
631 case "thread.pull-request-linked":
632 return decodeForEvent(
633 ThreadPullRequestLinkedPayload,
634 event.payload,
635 event.type,
636 "payload",
637 ).pipe(
638 Effect.map((payload) => {
639 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
640 if (!thread) {
641 return nextBase;
642 }
643 return {
644 ...nextBase,
645 threads: updateThread(nextBase.threads, payload.threadId, {
646 ...pullRequestsPatch(
647 thread,
648 upsertPullRequestLink(thread.pullRequests, payload.link),
649 nextBase.projects,
650 ),
651 updatedAt: payload.updatedAt,
652 }),
653 };
654 }),
655 );
656
657 case "thread.pull-request-unlinked":
658 return decodeForEvent(
659 ThreadPullRequestUnlinkedPayload,
660 event.payload,
661 event.type,
662 "payload",
663 ).pipe(
664 Effect.map((payload) => {
665 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
666 if (!thread) {
667 return nextBase;
668 }
669 return {
670 ...nextBase,
671 threads: updateThread(nextBase.threads, payload.threadId, {
672 ...pullRequestsPatch(
673 thread,
674 removePullRequestLink(thread.pullRequests, payload),
675 nextBase.projects,
676 ),
677 updatedAt: payload.updatedAt,
678 }),
679 };
680 }),
681 );
682
683 case "thread.pull-request-synced":
684 return decodeForEvent(
685 ThreadPullRequestSyncedPayload,
686 event.payload,
687 event.type,
688 "payload",
689 ).pipe(
690 Effect.map((payload) => {
691 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
692 // A sync for a link the user removed in the meantime is stale; drop it.
693 if (
694 !thread ||
695 !thread.pullRequests.some((link) => threadPullRequestKeysEqual(link, payload))
696 ) {
697 return nextBase;
698 }
699 const pullRequests = thread.pullRequests.map((link) =>
700 threadPullRequestKeysEqual(link, payload)
701 ? { ...link, snapshot: payload.snapshot, stack: payload.stack }
702 : link,
703 );
704 return {
705 ...nextBase,
706 threads: updateThread(nextBase.threads, payload.threadId, {
707 ...pullRequestsPatch(thread, pullRequests, nextBase.projects),
708 updatedAt: payload.updatedAt,
709 }),
710 };
711 }),
525 );712 );
526713
527 case "thread.runtime-mode-set":714 case "thread.runtime-mode-set":
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+167-1
...@@ -15,15 +15,21 @@ import * as Statement from "effect/unstable/sql/Statement";...@@ -15,15 +15,21 @@ import * as Statement from "effect/unstable/sql/Statement";
15import { SqlitePersistenceMemory } from "./Sqlite.ts";15import { SqlitePersistenceMemory } from "./Sqlite.ts";
16import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts";16import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts";
17import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts";17import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts";
18import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts";18import * as ProjectionThreadPullRequests from "../ProjectionThreadPullRequests.ts";
19import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts";19import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts";
20import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts";20import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts";
21import {
22 ProjectionThreadPullRequestRepository,
23 type ProjectionThreadPullRequest,
24} from "../ProjectionThreadPullRequests.ts";
25import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts";
21import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts";26import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts";
2227
23const projectionRepositoriesLayer = it.layer(28const projectionRepositoriesLayer = it.layer(
24 Layer.mergeAll(29 Layer.mergeAll(
25 ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),30 ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
26 ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),31 ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
32 ProjectionThreadPullRequests.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
27 ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),33 ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
28 SqlitePersistenceMemory,34 SqlitePersistenceMemory,
29 ),35 ),
...@@ -529,4 +535,164 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -529,4 +535,164 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
529 assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest);535 assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest);
530 }),536 }),
531 );537 );
538
539 it.effect("uses one Azure identity for repository writes, lookups, and deletion", () =>
540 Effect.gen(function* () {
541 const pullRequests = yield* ProjectionThreadPullRequestRepository;
542 const threadId = ThreadId.make("azure-alias-link");
543 const row: ProjectionThreadPullRequest = {
544 threadId,
545 host: "org.visualstudio.com",
546 repository: "project/_git/web",
547 number: 7,
548 url: "https://org.visualstudio.com/project/_git/web/pullrequest/7",
549 source: "manual",
550 linkedAt: "2026-09-09T00:00:00.000Z",
551 snapshot: null,
552 stack: null,
553 };
554 yield* pullRequests.upsert(row);
555 yield* pullRequests.upsert({
556 ...row,
557 host: "dev.azure.com",
558 repository: "org/project/_git/web",
559 });
560 const found = yield* pullRequests.listByPullRequest({
561 host: "ssh.dev.azure.com",
562 repository: "v3/org/project/web",
563 number: 7,
564 });
565 assert.deepStrictEqual(found, [
566 { ...row, host: "dev.azure.com", repository: "org/project/_git/web" },
567 ]);
568 assert.deepStrictEqual(
569 yield* pullRequests.listByPullRequest({
570 host: "dev.azure.com",
571 repository: "other/project/_git/web",
572 number: 7,
573 }),
574 [],
575 );
576 yield* pullRequests.delete({
577 threadId,
578 host: "vs-ssh.visualstudio.com",
579 repository: "v3/org/project/web",
580 number: 7,
581 });
582 assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []);
583 }),
584 );
585
586 it.effect("round-trips pull request links with JSON snapshot and stack columns", () =>
587 Effect.gen(function* () {
588 const pullRequests = yield* ProjectionThreadPullRequestRepository;
589 const sql = yield* SqlClient.SqlClient;
590 const threadId = ThreadId.make("thread-pr-links");
591 const otherThreadId = ThreadId.make("thread-pr-links-other");
592
593 const unsynced: ProjectionThreadPullRequest = {
594 threadId,
595 host: "github.com",
596 repository: "pingdotgg/t3code",
597 number: 42,
598 url: "https://github.com/pingdotgg/t3code/pull/42",
599 source: "manual",
600 linkedAt: "2026-03-24T00:00:00.000Z",
601 snapshot: null,
602 stack: null,
603 };
604 const synced: ProjectionThreadPullRequest = {
605 threadId,
606 host: "github.com",
607 repository: "pingdotgg/t3code",
608 number: 7,
609 url: "https://github.com/pingdotgg/t3code/pull/7",
610 source: "stack",
611 linkedAt: "2026-03-23T00:00:00.000Z",
612 snapshot: {
613 state: "open",
614 title: "Add links",
615 headBranch: "feat/links",
616 baseBranch: "main",
617 isDraft: false,
618 updatedAt: "2026-03-23T01:00:00.000Z",
619 syncedAt: "2026-03-23T02:00:00.000Z",
620 },
621 stack: {
622 kind: "native",
623 id: "stack-1",
624 number: 1,
625 url: "https://github.com/pingdotgg/t3code/stack/1",
626 base: "main",
627 layers: [
628 { number: 7, headBranch: "feat/links", state: "open" },
629 { number: 42, headBranch: "feat/links-ui", state: "open" },
630 ],
631 },
632 };
633 const sharedOnOtherThread: ProjectionThreadPullRequest = {
634 ...unsynced,
635 threadId: otherThreadId,
636 source: "agent",
637 linkedAt: "2026-03-25T00:00:00.000Z",
638 };
639
640 yield* pullRequests.upsert(unsynced);
641 yield* pullRequests.upsert(synced);
642 yield* pullRequests.upsert(sharedOnOtherThread);
643
644 const rawRows = yield* sql<{
645 readonly number: number;
646 readonly snapshotJson: string | null;
647 readonly stackJson: string | null;
648 }>`
649 SELECT number, snapshot_json AS "snapshotJson", stack_json AS "stackJson"
650 FROM projection_thread_pull_requests
651 WHERE thread_id = ${threadId}
652 ORDER BY number ASC
653 `;
654 assert.strictEqual(rawRows[0]?.number, 7);
655 // @effect-diagnostics-next-line preferSchemaOverJson:off
656 assert.deepStrictEqual(JSON.parse(rawRows[0]?.snapshotJson ?? "null"), synced.snapshot);
657 // @effect-diagnostics-next-line preferSchemaOverJson:off
658 assert.deepStrictEqual(JSON.parse(rawRows[0]?.stackJson ?? "null"), synced.stack);
659 assert.strictEqual(rawRows[1]?.snapshotJson, null);
660 assert.strictEqual(rawRows[1]?.stackJson, null);
661
662 // Ordered by linked_at, then number.
663 assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, unsynced]);
664
665 // One pull request across threads, ordered by linked_at.
666 assert.deepStrictEqual(
667 yield* pullRequests.listByPullRequest({
668 host: "github.com",
669 repository: "pingdotgg/t3code",
670 number: 42,
671 }),
672 [unsynced, sharedOnOtherThread],
673 );
674
675 // Upsert on the composite key replaces snapshot and stack in place.
676 const resynced = { ...unsynced, snapshot: synced.snapshot, stack: null } as const;
677 yield* pullRequests.upsert(resynced);
678 assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, resynced]);
679
680 yield* pullRequests.delete({
681 threadId,
682 host: "github.com",
683 repository: "pingdotgg/t3code",
684 number: 7,
685 });
686 assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [resynced]);
687
688 yield* pullRequests.deleteByThreadIdAndSource({ threadId, source: "manual" });
689 assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []);
690 assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), [
691 sharedOnOtherThread,
692 ]);
693
694 yield* pullRequests.deleteByThreadId({ threadId: otherThreadId });
695 assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), []);
696 }),
697 );
532});698});
apps/server/src/persistence/Migrations.ts+2
...@@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps....@@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.
61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";61import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
62import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";62import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
63import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";63import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
64import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
6465
65/**66/**
66 * Migration loader with all migrations defined inline.67 * Migration loader with all migrations defined inline.
...@@ -122,6 +123,7 @@ const migrationEntries = [...@@ -122,6 +123,7 @@ const migrationEntries = [
122 [47, "ProjectionProjectIcon", Migration0047],123 [47, "ProjectionProjectIcon", Migration0047],
123 [48, "ProjectionThreadBranchPullRequest", Migration0048],124 [48, "ProjectionThreadBranchPullRequest", Migration0048],
124 [49, "ProjectionThreadsActiveOrderKey", Migration0049],125 [49, "ProjectionThreadsActiveOrderKey", Migration0049],
126 [50, "ProjectionThreadPullRequests", Migration0050],
125] as const;127] as const;
126128
127export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);129export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts created+181
...@@ -0,0 +1,181 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as Layer from "effect/Layer";
5import * as SqlClient from "effect/unstable/sql/SqlClient";
6
7import { runMigrations } from "../Migrations.ts";
8import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
9
10const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
11
12const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
13
14interface PullRequestRow {
15 readonly threadId: string;
16 readonly host: string;
17 readonly repository: string;
18 readonly number: number;
19 readonly url: string;
20 readonly source: string;
21 readonly linkedAt: string;
22 readonly snapshotJson: string | null;
23 readonly stackJson: string | null;
24}
25
26layer("050_ProjectionThreadPullRequests", (it) => {
27 it.effect("creates the link table and backfills legacy single links", () =>
28 Effect.gen(function* () {
29 const sql = yield* SqlClient.SqlClient;
30
31 yield* runMigrations({ toMigrationInclusive: 49 });
32
33 yield* sql`
34 INSERT INTO projection_projects (
35 project_id,
36 title,
37 workspace_root,
38 scripts_json,
39 created_at,
40 updated_at,
41 deleted_at
42 )
43 VALUES (
44 'project-1',
45 'Project 1',
46 '/tmp/project-1',
47 '[]',
48 '2026-03-01T00:00:00.000Z',
49 '2026-03-01T00:00:00.000Z',
50 NULL
51 )
52 `;
53
54 yield* sql`
55 INSERT INTO projection_threads (
56 thread_id,
57 project_id,
58 title,
59 model_selection_json,
60 linked_pull_request_json,
61 created_at,
62 updated_at
63 )
64 VALUES
65 (
66 'thread-github',
67 'project-1',
68 'GitHub link',
69 '{"instanceId":"codex","model":"gpt-5.4"}',
70 '{"projectId":"project-1","repository":"PingDotGG/T3Code","number":42,"url":"https://GitHub.com/pingdotgg/t3code/pull/42"}',
71 '2026-03-01T00:00:01.000Z',
72 '2026-03-02T00:00:00.000Z'
73 ),
74 (
75 'thread-bad-url',
76 'project-1',
77 'Unparseable URL',
78 '{"instanceId":"codex","model":"gpt-5.4"}',
79 '{"projectId":"project-1","repository":"acme/widgets","number":7,"url":"not a url"}',
80 '2026-03-01T00:00:02.000Z',
81 '2026-03-03T00:00:00.000Z'
82 ),
83 (
84 'thread-malformed',
85 'project-1',
86 'Malformed JSON',
87 '{"instanceId":"codex","model":"gpt-5.4"}',
88 '{"repository":"acme/widgets"}',
89 '2026-03-01T00:00:03.000Z',
90 '2026-03-04T00:00:00.000Z'
91 ),
92 (
93 'thread-unlinked',
94 'project-1',
95 'No link',
96 '{"instanceId":"codex","model":"gpt-5.4"}',
97 NULL,
98 '2026-03-01T00:00:04.000Z',
99 '2026-03-05T00:00:00.000Z'
100 )
101 `;
102
103 yield* runMigrations({ toMigrationInclusive: 50 });
104
105 const rows = yield* sql<PullRequestRow>`
106 SELECT
107 thread_id AS "threadId",
108 host,
109 repository,
110 number,
111 url,
112 source,
113 linked_at AS "linkedAt",
114 snapshot_json AS "snapshotJson",
115 stack_json AS "stackJson"
116 FROM projection_thread_pull_requests
117 ORDER BY thread_id ASC
118 `;
119
120 assert.deepStrictEqual(rows, [
121 {
122 threadId: "thread-bad-url",
123 host: "unknown",
124 repository: "acme/widgets",
125 number: 7,
126 url: "not a url",
127 source: "manual",
128 linkedAt: "2026-03-03T00:00:00.000Z",
129 snapshotJson: null,
130 stackJson: null,
131 },
132 {
133 threadId: "thread-github",
134 host: "github.com",
135 repository: "pingdotgg/t3code",
136 number: 42,
137 url: "https://GitHub.com/pingdotgg/t3code/pull/42",
138 source: "manual",
139 linkedAt: "2026-03-02T00:00:00.000Z",
140 snapshotJson: null,
141 stackJson: null,
142 },
143 ]);
144
145 // The legacy column stays so a rollback keeps its data.
146 const columns = yield* sql<{ readonly name: string }>`
147 PRAGMA table_info(projection_threads)
148 `;
149 assert.ok(columns.some((column) => column.name === "linked_pull_request_json"));
150
151 const indexes = yield* sql<{ readonly name: string }>`
152 PRAGMA index_list(projection_thread_pull_requests)
153 `;
154 assert.ok(indexes.some((index) => index.name === "idx_projection_thread_pull_requests_pr"));
155 }),
156 );
157});
158
159it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("050 Azure legacy links", (it) => {
160 it.effect("keeps legacy Azure repositories distinct across organizations", () =>
161 Effect.gen(function* () {
162 const sql = yield* SqlClient.SqlClient;
163 yield* runMigrations({ toMigrationInclusive: 49 });
164 for (const organization of ["org-a", "org-b"]) {
165 yield* sql`
166 INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at)
167 VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}',
168 ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })},
169 '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')
170 `;
171 }
172 yield* runMigrations({ toMigrationInclusive: 50 });
173 const rows =
174 yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
175 assert.deepStrictEqual(rows, [
176 { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },
177 { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
178 ]);
179 }),
180 );
181});
apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts created+92
...@@ -0,0 +1,92 @@
1import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2import * as Effect from "effect/Effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5interface LegacyLinkedThreadRow {
6 readonly threadId: string;
7 readonly updatedAt: string;
8 readonly linkedPullRequestJson: string;
9}
10
11interface LegacyLinkedPullRequest {
12 readonly repository: string;
13 readonly number: number;
14 readonly url: string;
15}
16
17function parseLegacyLinkedPullRequest(json: string): LegacyLinkedPullRequest | null {
18 try {
19 const value: unknown = JSON.parse(json);
20 if (typeof value !== "object" || value === null) return null;
21 const { repository, number, url } = value as Record<string, unknown>;
22 if (typeof repository !== "string" || repository.trim().length === 0) return null;
23 if (typeof number !== "number" || !Number.isInteger(number) || number < 1) return null;
24 if (typeof url !== "string" || url.trim().length === 0) return null;
25 return { repository, number, url };
26 } catch {
27 return null;
28 }
29}
30
31export default Effect.gen(function* () {
32 const sql = yield* SqlClient.SqlClient;
33
34 yield* sql`
35 CREATE TABLE IF NOT EXISTS projection_thread_pull_requests (
36 thread_id TEXT NOT NULL,
37 host TEXT NOT NULL,
38 repository TEXT NOT NULL,
39 number INTEGER NOT NULL,
40 url TEXT NOT NULL,
41 source TEXT NOT NULL,
42 linked_at TEXT NOT NULL,
43 snapshot_json TEXT,
44 stack_json TEXT,
45 PRIMARY KEY (thread_id, host, repository, number)
46 )
47 `;
48
49 yield* sql`
50 CREATE INDEX IF NOT EXISTS idx_projection_thread_pull_requests_pr
51 ON projection_thread_pull_requests(host, repository, number)
52 `;
53
54 const legacyRows = yield* sql<LegacyLinkedThreadRow>`
55 SELECT
56 thread_id AS "threadId",
57 updated_at AS "updatedAt",
58 linked_pull_request_json AS "linkedPullRequestJson"
59 FROM projection_threads
60 WHERE linked_pull_request_json IS NOT NULL
61 `;
62
63 for (const row of legacyRows) {
64 const linked = parseLegacyLinkedPullRequest(row.linkedPullRequestJson);
65 if (linked === null) continue;
66 const key = legacyThreadPullRequestKey(linked);
67 yield* sql`
68 INSERT OR IGNORE INTO projection_thread_pull_requests (
69 thread_id,
70 host,
71 repository,
72 number,
73 url,
74 source,
75 linked_at,
76 snapshot_json,
77 stack_json
78 )
79 VALUES (
80 ${row.threadId},
81 ${key.host},
82 ${key.repository},
83 ${linked.number},
84 ${linked.url},
85 'manual',
86 ${row.updatedAt},
87 NULL,
88 NULL
89 )
90 `;
91 }
92});
apps/server/src/persistence/ProjectionThreadPullRequests.ts created+266
...@@ -0,0 +1,266 @@
1import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2import {
3 IsoDateTime,
4 PositiveInt,
5 ThreadId,
6 ThreadPullRequestKey,
7 ThreadPullRequestLinkSource,
8 ThreadPullRequestSnapshot,
9 ThreadPullRequestStack,
10 TrimmedNonEmptyString,
11} from "@t3tools/contracts";
12import * as Schema from "effect/Schema";
13import * as Context from "effect/Context";
14import * as Effect from "effect/Effect";
15
16import { toPersistenceSqlError, type ProjectionRepositoryError } from "./Errors.ts";
17
18import * as Layer from "effect/Layer";
19import * as Struct from "effect/Struct";
20import * as SqlClient from "effect/unstable/sql/SqlClient";
21import * as SqlSchema from "effect/unstable/sql/SqlSchema";
22
23export const ProjectionThreadPullRequest = Schema.Struct({
24 threadId: ThreadId,
25 host: TrimmedNonEmptyString,
26 repository: TrimmedNonEmptyString,
27 number: PositiveInt,
28 url: TrimmedNonEmptyString,
29 source: ThreadPullRequestLinkSource,
30 linkedAt: IsoDateTime,
31 snapshot: Schema.NullOr(ThreadPullRequestSnapshot),
32 stack: Schema.NullOr(ThreadPullRequestStack),
33});
34export type ProjectionThreadPullRequest = typeof ProjectionThreadPullRequest.Type;
35
36export const ListProjectionThreadPullRequestsInput = Schema.Struct({
37 threadId: ThreadId,
38});
39export type ListProjectionThreadPullRequestsInput =
40 typeof ListProjectionThreadPullRequestsInput.Type;
41
42export const ListProjectionThreadPullRequestsByPullRequestInput = ThreadPullRequestKey;
43export type ListProjectionThreadPullRequestsByPullRequestInput =
44 typeof ListProjectionThreadPullRequestsByPullRequestInput.Type;
45
46export const DeleteProjectionThreadPullRequestInput = Schema.Struct({
47 threadId: ThreadId,
48 ...ThreadPullRequestKey.fields,
49});
50export type DeleteProjectionThreadPullRequestInput =
51 typeof DeleteProjectionThreadPullRequestInput.Type;
52
53export const DeleteProjectionThreadPullRequestsInput = Schema.Struct({
54 threadId: ThreadId,
55});
56export type DeleteProjectionThreadPullRequestsInput =
57 typeof DeleteProjectionThreadPullRequestsInput.Type;
58
59export const DeleteProjectionThreadPullRequestsBySourceInput = Schema.Struct({
60 threadId: ThreadId,
61 source: ThreadPullRequestLinkSource,
62});
63export type DeleteProjectionThreadPullRequestsBySourceInput =
64 typeof DeleteProjectionThreadPullRequestsBySourceInput.Type;
65
66const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields(
67 Struct.assign({
68 snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)),
69 stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)),
70 }),
71);
72
73export class ProjectionThreadPullRequestRepository extends Context.Service<
74 ProjectionThreadPullRequestRepository,
75 {
76 readonly upsert: (
77 row: ProjectionThreadPullRequest,
78 ) => Effect.Effect<void, ProjectionRepositoryError>;
79 readonly listByThreadId: (
80 input: ListProjectionThreadPullRequestsInput,
81 ) => Effect.Effect<ReadonlyArray<ProjectionThreadPullRequest>, ProjectionRepositoryError>;
82 readonly listByPullRequest: (
83 input: ListProjectionThreadPullRequestsByPullRequestInput,
84 ) => Effect.Effect<ReadonlyArray<ProjectionThreadPullRequest>, ProjectionRepositoryError>;
85 readonly delete: (
86 input: DeleteProjectionThreadPullRequestInput,
87 ) => Effect.Effect<void, ProjectionRepositoryError>;
88 readonly deleteByThreadId: (
89 input: DeleteProjectionThreadPullRequestsInput,
90 ) => Effect.Effect<void, ProjectionRepositoryError>;
91 readonly deleteByThreadIdAndSource: (
92 input: DeleteProjectionThreadPullRequestsBySourceInput,
93 ) => Effect.Effect<void, ProjectionRepositoryError>;
94 }
95>()("t3/persistence/ProjectionThreadPullRequests/ProjectionThreadPullRequestRepository") {}
96
97/** @public Service construction is part of the canonical Effect module API. */
98export const make = Effect.gen(function* () {
99 const sql = yield* SqlClient.SqlClient;
100
101 const upsertProjectionThreadPullRequestRow = SqlSchema.void({
102 Request: ProjectionThreadPullRequest,
103 execute: (row) => sql`
104 INSERT INTO projection_thread_pull_requests (
105 thread_id,
106 host,
107 repository,
108 number,
109 url,
110 source,
111 linked_at,
112 snapshot_json,
113 stack_json
114 )
115 VALUES (
116 ${row.threadId},
117 ${row.host},
118 ${row.repository},
119 ${row.number},
120 ${row.url},
121 ${row.source},
122 ${row.linkedAt},
123 ${row.snapshot === null ? null : JSON.stringify(row.snapshot)},
124 ${row.stack === null ? null : JSON.stringify(row.stack)}
125 )
126 ON CONFLICT (thread_id, host, repository, number)
127 DO UPDATE SET
128 url = excluded.url,
129 source = excluded.source,
130 linked_at = excluded.linked_at,
131 snapshot_json = excluded.snapshot_json,
132 stack_json = excluded.stack_json
133 `,
134 });
135
136 const listProjectionThreadPullRequestRows = SqlSchema.findAll({
137 Request: ListProjectionThreadPullRequestsInput,
138 Result: ProjectionThreadPullRequestDbRow,
139 execute: ({ threadId }) => sql`
140 SELECT
141 thread_id AS "threadId",
142 host,
143 repository,
144 number,
145 url,
146 source,
147 linked_at AS "linkedAt",
148 snapshot_json AS "snapshot",
149 stack_json AS "stack"
150 FROM projection_thread_pull_requests
151 WHERE thread_id = ${threadId}
152 ORDER BY linked_at ASC, number ASC
153 `,
154 });
155
156 const listProjectionThreadPullRequestRowsByPullRequest = SqlSchema.findAll({
157 Request: ListProjectionThreadPullRequestsByPullRequestInput,
158 Result: ProjectionThreadPullRequestDbRow,
159 execute: ({ host, repository, number }) => sql`
160 SELECT
161 thread_id AS "threadId",
162 host,
163 repository,
164 number,
165 url,
166 source,
167 linked_at AS "linkedAt",
168 snapshot_json AS "snapshot",
169 stack_json AS "stack"
170 FROM projection_thread_pull_requests
171 WHERE host = ${host}
172 AND repository = ${repository}
173 AND number = ${number}
174 ORDER BY linked_at ASC, thread_id ASC
175 `,
176 });
177
178 const deleteProjectionThreadPullRequestRow = SqlSchema.void({
179 Request: DeleteProjectionThreadPullRequestInput,
180 execute: ({ threadId, host, repository, number }) => sql`
181 DELETE FROM projection_thread_pull_requests
182 WHERE thread_id = ${threadId}
183 AND host = ${host}
184 AND repository = ${repository}
185 AND number = ${number}
186 `,
187 });
188
189 const deleteProjectionThreadPullRequestRows = SqlSchema.void({
190 Request: DeleteProjectionThreadPullRequestsInput,
191 execute: ({ threadId }) => sql`
192 DELETE FROM projection_thread_pull_requests
193 WHERE thread_id = ${threadId}
194 `,
195 });
196
197 const deleteProjectionThreadPullRequestRowsBySource = SqlSchema.void({
198 Request: DeleteProjectionThreadPullRequestsBySourceInput,
199 execute: ({ threadId, source }) => sql`
200 DELETE FROM projection_thread_pull_requests
201 WHERE thread_id = ${threadId}
202 AND source = ${source}
203 `,
204 });
205
206 const upsert: ProjectionThreadPullRequestRepository["Service"]["upsert"] = (row) =>
207 upsertProjectionThreadPullRequestRow({ ...row, ...normalizeThreadPullRequestKey(row) }).pipe(
208 Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query")),
209 );
210
211 const listByThreadId: ProjectionThreadPullRequestRepository["Service"]["listByThreadId"] = (
212 input,
213 ) =>
214 listProjectionThreadPullRequestRows(input).pipe(
215 Effect.mapError(
216 toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"),
217 ),
218 );
219
220 const listByPullRequest: ProjectionThreadPullRequestRepository["Service"]["listByPullRequest"] = (
221 input,
222 ) =>
223 listProjectionThreadPullRequestRowsByPullRequest(normalizeThreadPullRequestKey(input)).pipe(
224 Effect.mapError(
225 toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByPullRequest:query"),
226 ),
227 );
228
229 const deleteLink: ProjectionThreadPullRequestRepository["Service"]["delete"] = (input) =>
230 deleteProjectionThreadPullRequestRow({
231 ...input,
232 ...normalizeThreadPullRequestKey(input),
233 }).pipe(
234 Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query")),
235 );
236
237 const deleteByThreadId: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadId"] = (
238 input,
239 ) =>
240 deleteProjectionThreadPullRequestRows(input).pipe(
241 Effect.mapError(
242 toPersistenceSqlError("ProjectionThreadPullRequestRepository.deleteByThreadId:query"),
243 ),
244 );
245
246 const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadIdAndSource"] =
247 (input) =>
248 deleteProjectionThreadPullRequestRowsBySource(input).pipe(
249 Effect.mapError(
250 toPersistenceSqlError(
251 "ProjectionThreadPullRequestRepository.deleteByThreadIdAndSource:query",
252 ),
253 ),
254 );
255
256 return {
257 upsert,
258 listByThreadId,
259 listByPullRequest,
260 delete: deleteLink,
261 deleteByThreadId,
262 deleteByThreadIdAndSource,
263 } satisfies ProjectionThreadPullRequestRepository["Service"];
264});
265
266export const layer = Layer.effect(ProjectionThreadPullRequestRepository, make);
apps/server/src/project/AgentSessionImporter.test.ts+1
...@@ -124,6 +124,7 @@ const makeProjectedThread = (input: {...@@ -124,6 +124,7 @@ const makeProjectedThread = (input: {
124 modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" },124 modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" },
125 runtimeMode: "full-access",125 runtimeMode: "full-access",
126 interactionMode: "default",126 interactionMode: "default",
127 pullRequests: [],
127 branch: null,128 branch: null,
128 worktreePath: null,129 worktreePath: null,
129 latestTurn: null,130 latestTurn: null,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
1// @effect-diagnostics nodeBuiltinImport:off2// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";3import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";4import * as NodeOS from "node:os";
...@@ -392,8 +393,7 @@ describe("ClaudeAdapterLive", () => {...@@ -392,8 +393,7 @@ describe("ClaudeAdapterLive", () => {
392 assert.deepEqual(createInput?.options.systemPrompt, {393 assert.deepEqual(createInput?.options.systemPrompt, {
393 type: "preset",394 type: "preset",
394 preset: "claude_code",395 preset: "claude_code",
395 append:396 append: buildRuntimeInstructions({ harness: "Claude Code" }),
396 "<runtime_info>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.</runtime_info>",
397 });397 });
398 assert.equal(createInput?.options.permissionMode, "bypassPermissions");398 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
399 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);399 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
apps/server/src/provider/Layers/CodexAdapter.ts+1
...@@ -2283,6 +2283,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2283,6 +2283,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2283 "-c",2283 "-c",
2284 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',2284 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
2285 ],2285 ],
2286 browserToolsAvailable: mcpSession.preview,
2286 }2287 }
2287 : {}),2288 : {}),
2288 };2289 };
apps/server/src/provider/Layers/CodexSessionRuntime.ts+11-2
...@@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions {...@@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions {
166 readonly serviceTier?: CodexServiceTier | undefined;166 readonly serviceTier?: CodexServiceTier | undefined;
167 readonly resumeCursor?: CodexResumeCursor;167 readonly resumeCursor?: CodexResumeCursor;
168 readonly appServerArgs?: ReadonlyArray<string>;168 readonly appServerArgs?: ReadonlyArray<string>;
169 /**
170 * Whether the attached `t3-code` MCP server exposes the preview tools. The
171 * server is attached for every session now (the pull request toolkit is
172 * always on), so its presence in `appServerArgs` no longer implies browser
173 * access; the credential's own capability decides the developer prompt.
174 */
175 readonly browserToolsAvailable?: boolean;
169}176}
170177
171export interface CodexSessionRuntimeSendTurnInput {178export interface CodexSessionRuntimeSendTurnInput {
...@@ -2349,10 +2356,12 @@ export const makeCodexSessionRuntime = (...@@ -2349,10 +2356,12 @@ export const makeCodexSessionRuntime = (
2349 ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),2356 ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
2350 ...(input.effort ? { effort: input.effort } : {}),2357 ...(input.effort ? { effort: input.effort } : {}),
2351 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),2358 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
2352 // Derived from the session's own MCP configuration rather than the2359 // Derived from the session's own credential rather than the
2353 // setting, so the prompt describes the tools this turn actually2360 // setting, so the prompt describes the tools this turn actually
2354 // has even if the setting changed after the session started.2361 // has even if the setting changed after the session started.
2355 browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs),2362 browserToolsAvailable:
2363 hasConfiguredMcpServer(options.appServerArgs) &&
2364 (options.browserToolsAvailable ?? true),
2356 });2365 });
2357 const rawResponse = yield* client.raw.request("turn/start", params);2366 const rawResponse = yield* client.raw.request("turn/start", params);
2358 const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(2367 const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(
apps/server/src/provider/Layers/ProviderService.test.ts+14-30
...@@ -4797,7 +4797,6 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {...@@ -4797,7 +4797,6 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {
4797const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);4797const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
47984798
4799describe("agent browser access", () => {4799describe("agent browser access", () => {
4800 const revokedThreads: Array<ThreadId> = [];
4801 const projectId = ProjectId.make("project-browser-access");4800 const projectId = ProjectId.make("project-browser-access");
48024801
4803 const startSessionWith = (4802 const startSessionWith = (
...@@ -4806,7 +4805,7 @@ describe("agent browser access", () => {...@@ -4806,7 +4805,7 @@ describe("agent browser access", () => {
4806 projectOverride?: boolean,4805 projectOverride?: boolean,
4807 ) =>4806 ) =>
4808 Effect.gen(function* () {4807 Effect.gen(function* () {
4809 const issued: Array<ThreadId> = [];4808 const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = [];
4810 const codex = makeFakeCodexAdapter();4809 const codex = makeFakeCodexAdapter();
4811 const providerAdapterLayer = Layer.succeed(4810 const providerAdapterLayer = Layer.succeed(
4812 ProviderAdapterRegistry.ProviderAdapterRegistry,4811 ProviderAdapterRegistry.ProviderAdapterRegistry,
...@@ -4865,10 +4864,9 @@ describe("agent browser access", () => {...@@ -4865,10 +4864,9 @@ describe("agent browser access", () => {
4865 const providerLayer = makeProviderServiceLive({4864 const providerLayer = makeProviderServiceLive({
4866 issueMcpCredential: (request) =>4865 issueMcpCredential: (request) =>
4867 Effect.sync(() => {4866 Effect.sync(() => {
4868 issued.push(request.threadId);4867 issued.push({ threadId: request.threadId, preview: request.preview });
4869 return undefined;4868 return undefined;
4870 }),4869 }),
4871 revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)),
4872 }).pipe(4870 }).pipe(
4873 Layer.provide(providerAdapterLayer),4871 Layer.provide(providerAdapterLayer),
4874 Layer.provide(directoryLayer),4872 Layer.provide(directoryLayer),
...@@ -4903,48 +4901,34 @@ describe("agent browser access", () => {...@@ -4903,48 +4901,34 @@ describe("agent browser access", () => {
4903 return issued;4901 return issued;
4904 });4902 });
49054903
4906 // Credential issuance is the observable that matters: it is the only place a4904 // The capability on the credential is the observable that matters: a session
4907 // credential is minted, and `/mcp` accepts nothing else, so withholding it is4905 // always gets a credential (the pull request toolkit is never withheld), and
4908 // what actually denies every provider and external MCP client.4906 // `preview` on it is what actually grants or denies the browser tools.
4909 it.effect("requests no MCP credential when agent browser access is off", () =>4907 it.effect("issues a credential without preview when agent browser access is off", () =>
4910 Effect.gen(function* () {4908 Effect.gen(function* () {
4911 const issued = yield* startSessionWith(false, asThreadId("thread-browser-off"));4909 const threadId = asThreadId("thread-browser-off");
49124910
4913 assert.deepEqual(issued, []);4911 const issued = yield* startSessionWith(false, threadId);
4914 }).pipe(Effect.provide(NodeServices.layer)),
4915 );
4916
4917 it.effect("revokes an already-issued credential when access is off", () =>
4918 Effect.gen(function* () {
4919 const threadId = asThreadId("thread-browser-revoke");
4920 revokedThreads.length = 0;
4921
4922 yield* startSessionWith(false, threadId);
49234912
4924 // Clearing the in-memory map is not enough: a token issued before the4913 assert.deepEqual(issued, [{ threadId, preview: false }]);
4925 // toggle flipped stays valid against `/mcp` for its whole liveness
4926 // window, and later turns refresh it.
4927 assert.deepEqual(revokedThreads, [threadId]);
4928 }).pipe(Effect.provide(NodeServices.layer)),4914 }).pipe(Effect.provide(NodeServices.layer)),
4929 );4915 );
49304916
4931 it.effect("requests an MCP credential when agent browser access is on", () =>4917 it.effect("issues a credential with preview when agent browser access is on", () =>
4932 Effect.gen(function* () {4918 Effect.gen(function* () {
4933 const threadId = asThreadId("thread-browser-on");4919 const threadId = asThreadId("thread-browser-on");
49344920
4935 const issued = yield* startSessionWith(true, threadId);4921 const issued = yield* startSessionWith(true, threadId);
49364922
4937 assert.deepEqual(issued, [threadId]);4923 assert.deepEqual(issued, [{ threadId, preview: true }]);
4938 }).pipe(Effect.provide(NodeServices.layer)),4924 }).pipe(Effect.provide(NodeServices.layer)),
4939 );4925 );
49404926
4941 it.effect("withholds and revokes MCP credentials when the project disables browser access", () =>4927 it.effect("issues a credential without preview when the project disables browser access", () =>
4942 Effect.gen(function* () {4928 Effect.gen(function* () {
4943 const threadId = asThreadId("thread-project-browser-off");4929 const threadId = asThreadId("thread-project-browser-off");
4944 revokedThreads.length = 0;
4945 const issued = yield* startSessionWith(true, threadId, false);4930 const issued = yield* startSessionWith(true, threadId, false);
4946 assert.deepEqual(issued, []);4931 assert.deepEqual(issued, [{ threadId, preview: false }]);
4947 assert.deepEqual(revokedThreads, [threadId]);
4948 }).pipe(Effect.provide(NodeServices.layer)),4932 }).pipe(Effect.provide(NodeServices.layer)),
4949 );4933 );
49504934
...@@ -4952,7 +4936,7 @@ describe("agent browser access", () => {...@@ -4952,7 +4936,7 @@ describe("agent browser access", () => {
4952 Effect.gen(function* () {4936 Effect.gen(function* () {
4953 const threadId = asThreadId("thread-project-browser-on");4937 const threadId = asThreadId("thread-project-browser-on");
4954 const issued = yield* startSessionWith(false, threadId, true);4938 const issued = yield* startSessionWith(false, threadId, true);
4955 assert.deepEqual(issued, [threadId]);4939 assert.deepEqual(issued, [{ threadId, preview: true }]);
4956 }).pipe(Effect.provide(NodeServices.layer)),4940 }).pipe(Effect.provide(NodeServices.layer)),
4957 );4941 );
4958});4942});
apps/server/src/provider/Layers/ProviderService.ts+13-23
...@@ -249,8 +249,6 @@ export interface ProviderServiceLiveOptions {...@@ -249,8 +249,6 @@ export interface ProviderServiceLiveOptions {
249 * test see whether a credential was requested at all.249 * test see whether a credential was requested at all.
250 */250 */
251 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;251 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
252 /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */
253 readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread;
254}252}
255253
256interface TurnAnalyticsMetadata {254interface TurnAnalyticsMetadata {
...@@ -479,8 +477,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -479,8 +477,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
479 );477 );
480 const issueMcpCredential =478 const issueMcpCredential =
481 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;479 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
482 const revokeMcpCredential =
483 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
484 const fileSystem = yield* FileSystem.FileSystem;480 const fileSystem = yield* FileSystem.FileSystem;
485 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();481 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
486 const pendingCompactions = new Map<ThreadId, PendingCompaction>();482 const pendingCompactions = new Map<ThreadId, PendingCompaction>();
...@@ -853,14 +849,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -853,14 +849,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
853 yield* recordCompletedTurnProperties(properties);849 yield* recordCompletedTurnProperties(properties);
854 });850 });
855 /**851 /**
856 * Attach the `t3-code` MCP server to the session that is about to start.852 * Whether the credential minted below may drive the user's browser.
857 *853 *
858 * This is the only place a credential is minted, so withholding one here is
859 * what disables agent browser access everywhere: every adapter already
860 * treats a missing session as "no MCP server", and the `/mcp` endpoint
861 * accepts nothing but tokens issued from this path.
862 */
863 /**
864 * Deny on an unreadable settings file rather than letting the read failure854 * Deny on an unreadable settings file rather than letting the read failure
865 * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen855 * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
866 * a union every caller handles, for a branch that only decides whether one856 * a union every caller handles, for a branch that only decides whether one
...@@ -889,20 +879,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -889,20 +879,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
889 ),879 ),
890 );880 );
891881
882 /**
883 * Attach the `t3-code` MCP server to the session that is about to start.
884 *
885 * Every session gets a credential: the pull request toolkit is always on,
886 * since it only registers links on the session's own thread. Browser access
887 * is a capability on that credential, so turning the setting off withholds
888 * the preview tools without taking the server away. `issueActiveMcpCredential`
889 * revokes the thread's previous token first, which matters because a session
890 * restart (runtime mode, cwd, model) re-prepares without stopping.
891 */
892 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>892 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
893 Effect.gen(function* () {893 Effect.gen(function* () {
894 if (!(yield* agentBrowserAccessEnabled(threadId))) {894 const preview = yield* agentBrowserAccessEnabled(threadId);
895 // Revoke as well as clear. Every other prepare path reaches895 const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview });
896 // `issueActiveMcpCredential`, which revokes the thread first, so
897 // skipping it here would leave a previously issued bearer token valid
898 // against `/mcp` for the rest of its liveness window — and later turns
899 // would keep refreshing it. A session restart (runtime mode, cwd,
900 // model) re-prepares without stopping, so it relies on this.
901 yield* revokeMcpCredential(threadId);
902 yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId));
903 return undefined;
904 }
905 const credential = yield* issueMcpCredential({ threadId, providerInstanceId });
906 if (credential) {896 if (credential) {
907 yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config));897 yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config));
908 }898 }
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -98,6 +98,7 @@ function makeReadModel(...@@ -98,6 +98,7 @@ function makeReadModel(
98 runtimeMode: "full-access" as const,98 runtimeMode: "full-access" as const,
99 branch: null,99 branch: null,
100 worktreePath: null,100 worktreePath: null,
101 pullRequests: [],
101 createdAt: now,102 createdAt: now,
102 updatedAt: now,103 updatedAt: now,
103 archivedAt: null,104 archivedAt: null,
apps/server/src/provider/RuntimeInstructions.test.ts+8
...@@ -2,6 +2,14 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,14 @@ import { describe, expect, it } from "vite-plus/test";
2import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";2import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
33
4describe("buildRuntimeInstructions", () => {4describe("buildRuntimeInstructions", () => {
5 it("requires explicit registration of every PR and stack layer", () => {
6 const instructions = buildRuntimeInstructions({ harness: "Codex" });
7 expect(instructions).toContain("When the t3-code MCP server exposes link_pull_request");
8 expect(instructions).toContain("with the full PR URL immediately after creating a PR");
9 expect(instructions).toContain("For a stack, call it for every layer");
10 expect(instructions).toContain("call list_thread_pull_requests and link any PR");
11 });
12
5 it("keeps known model and effort metadata on one line", () => {13 it("keeps known model and effort metadata on one line", () => {
6 expect(14 expect(
7 buildRuntimeInstructions({15 buildRuntimeInstructions({
apps/server/src/provider/RuntimeInstructions.ts+5-1
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>
2When 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.
3</pull_request_linking>`;
4
1/** Shared runtime context; omit model and effort when the harness manages them dynamically. */5/** Shared runtime context; omit model and effort when the harness manages them dynamically. */
2export function buildRuntimeInstructions(runtime: {6export function buildRuntimeInstructions(runtime: {
3 readonly harness: string;7 readonly harness: string;
...@@ -9,7 +13,7 @@ export function buildRuntimeInstructions(runtime: {...@@ -9,7 +13,7 @@ export function buildRuntimeInstructions(runtime: {
9 const effort = toSingleLine(runtime.reasoningEffort ?? "");13 const effort = toSingleLine(runtime.reasoningEffort ?? "");
10 const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : "";14 const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : "";
11 const effortInfo = effort ? ` with ${effort} reasoning effort` : "";15 const effortInfo = effort ? ` with ${effort} reasoning effort` : "";
12 return `<runtime_info>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.</runtime_info>`;16 return `<runtime_info>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.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`;
13}17}
1418
15function toSingleLine(value: string): string {19function toSingleLine(value: string): string {
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+227-26
...@@ -183,20 +183,37 @@ afterEach(() => {...@@ -183,20 +183,37 @@ afterEach(() => {
183});183});
184184
185layer("GitHubPullRequestCli.layer", (it) => {185layer("GitHubPullRequestCli.layer", (it) => {
186 it.effect("reads linked pull request status through one narrow request", () =>186 it.effect("reads linked pull request status with the overview fields in one request", () =>
187 Effect.gen(function* () {187 Effect.gen(function* () {
188 mockedGetPullRequest.mockReturnValueOnce(188 mockedExecute.mockReturnValueOnce(
189 Effect.succeed({189 Effect.succeed(
190 number: 7,190 output(
191 title: "Reuse the summary",191 // @effect-diagnostics-next-line preferSchemaOverJson:off
192 url: "https://github.com/acme/web/pull/7",192 JSON.stringify({
193 baseRefName: "main",193 number: 7,
194 headRefName: "feat/summary",194 title: "Reuse the summary",
195 state: "merged",195 url: "https://github.com/acme/web/pull/7",
196 closedAt: "2026-08-23T10:00:00Z",196 author: { login: "octocat", name: "Octo Cat" },
197 mergedAt: "2026-08-23T10:00:00Z",197 baseRefName: "main",
198 updatedAt: "2026-08-24T12:34:56.000Z",198 headRefName: "feat/summary",
199 }),199 state: "OPEN",
200 isDraft: false,
201 mergeable: "MERGEABLE",
202 reviewDecision: "APPROVED",
203 additions: 12,
204 deletions: 3,
205 changedFiles: 2,
206 createdAt: "2026-08-20T00:00:00.000Z",
207 updatedAt: "2026-08-24T12:34:56.000Z",
208 reviewRequests: [],
209 labels: [],
210 statusCheckRollup: [
211 { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" },
212 ],
213 body: "",
214 }),
215 ),
216 ),
200 );217 );
201 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;218 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
202219
...@@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => {
207 number: 7,224 number: 7,
208 });225 });
209226
210 assert.deepStrictEqual(summary, {227 assert.deepStrictEqual(
228 {
229 number: summary.number,
230 state: summary.state,
231 headBranch: summary.headBranch,
232 isDraft: summary.isDraft,
233 author: summary.author?.login,
234 additions: summary.additions,
235 deletions: summary.deletions,
236 changedFiles: summary.changedFiles,
237 reviewDecision: summary.reviewDecision,
238 checksState: summary.checksState,
239 mergeability: summary.mergeability,
240 },
241 {
242 number: 7,
243 state: "open",
244 headBranch: "feat/summary",
245 isDraft: false,
246 author: "octocat",
247 additions: 12,
248 deletions: 3,
249 changedFiles: 2,
250 reviewDecision: "approved",
251 checksState: "passing",
252 mergeability: "mergeable",
253 },
254 );
255 expect(mockedExecute).toHaveBeenCalledOnce();
256 expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([
257 "pr",
258 "view",
259 "7",
260 "--repo",
261 "github.com/acme/web",
262 "--json",
263 expect.stringContaining("statusCheckRollup"),
264 ]);
265 expect(mockedGetPullRequest).not.toHaveBeenCalled();
266 }),
267 );
268
269 it.effect("reads the stack a pull request is in through the stacks preview, on its host", () =>
270 Effect.gen(function* () {
271 mockedExecute.mockReturnValueOnce(
272 Effect.succeed(
273 output(
274 // @effect-diagnostics-next-line preferSchemaOverJson:off
275 JSON.stringify([
276 {
277 id: 42,
278 number: 3,
279 url: "https://api.github.com/repos/acme/web/stacks/3",
280 base: { ref: "main" },
281 pull_requests: [
282 {
283 number: 6,
284 head: { ref: "feat/one" },
285 state: "closed",
286 merged_at: "2026-09-02",
287 },
288 { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null },
289 ],
290 },
291 ]),
292 ),
293 ),
294 );
295 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
296
297 const stack = yield* cli.getPullRequestStack({
298 cwd: "/w",
299 repository: "acme/web",
300 host: "ghe.example.com",
211 number: 7,301 number: 7,
212 title: "Reuse the summary",
213 url: "https://github.com/acme/web/pull/7",
214 headBranch: "feat/summary",
215 baseBranch: "main",
216 state: "merged",
217 closedAt: "2026-08-23T10:00:00Z",
218 mergedAt: "2026-08-23T10:00:00Z",
219 updatedAt: "2026-08-24T12:34:56.000Z",
220 });302 });
221 expect(mockedGetPullRequest).toHaveBeenCalledOnce();303
222 expect(mockedGetPullRequest).toHaveBeenCalledWith({304 assert.deepStrictEqual(stack, {
305 id: "42",
306 number: 3,
307 url: "https://api.github.com/repos/acme/web/stacks/3",
308 base: "main",
309 layers: [
310 { number: 6, headBranch: "feat/one", state: "merged" },
311 { number: 7, headBranch: "feat/two", state: "open" },
312 ],
313 });
314 assert.deepStrictEqual(callAt(0).args, [
315 "api",
316 "--hostname",
317 "ghe.example.com",
318 "repos/acme/web/stacks?pull_request=7",
319 ]);
320 }),
321 );
322
323 it.effect("reads an empty stacks listing as not stacked", () =>
324 Effect.gen(function* () {
325 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
326 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
327
328 const stack = yield* cli.getPullRequestStack({
223 cwd: "/w",329 cwd: "/w",
224 reference: "https://github.com/acme/web/pull/7",330 repository: "acme/web",
331 host: "github.com",
332 number: 7,
333 });
334
335 assert.isNull(stack);
336 }),
337 );
338
339 it.effect("reads a host that refuses the stacks preview as not stacked", () =>
340 Effect.gen(function* () {
341 // The CLI classifies a missing preview endpoint as not found.
342 mockedExecute.mockReturnValueOnce(
343 Effect.fail(
344 new GitHubCli.GitHubPullRequestNotFoundError({
345 command: "gh",
346 cwd: "/w",
347 cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"),
348 }),
349 ),
350 );
351 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
352
353 const stack = yield* cli.getPullRequestStack({
354 cwd: "/w",
355 repository: "acme/web",
356 host: "github.com",
357 number: 7,
358 });
359
360 assert.isNull(stack);
361 }),
362 );
363
364 it.effect("does not read a signed-out gh as an unstacked pull request", () =>
365 Effect.gen(function* () {
366 mockedExecute.mockReturnValueOnce(
367 Effect.fail(
368 new GitHubCli.GitHubCliAuthenticationError({
369 command: "gh",
370 cwd: "/w",
371 cause: new Error("gh auth login"),
372 }),
373 ),
374 );
375 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
376
377 const error = yield* Effect.flip(
378 cli.getPullRequestStack({
379 cwd: "/w",
380 repository: "acme/web",
381 host: "github.com",
382 number: 7,
383 }),
384 );
385
386 assert.strictEqual(error._tag, "GitHubCliAuthenticationError");
387 }),
388 );
389
390 it.effect("preserves transient stack failures instead of reporting no stack", () =>
391 Effect.gen(function* () {
392 const failure = new GitHubCli.GitHubCliCommandError({
393 command: "gh",
394 cwd: "/w",
395 cause: new Error("HTTP 503"),
225 });396 });
226 expect(mockedExecute).not.toHaveBeenCalled();397 mockedExecute.mockReturnValueOnce(Effect.fail(failure));
398 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
399 const error = yield* Effect.flip(
400 cli.getPullRequestStack({
401 cwd: "/w",
402 repository: "acme/web",
403 host: "github.com",
404 number: 7,
405 }),
406 );
407 assert.strictEqual(error, failure);
408 }),
409 );
410
411 it.effect("reports a stacks answer it cannot read against the stack read", () =>
412 Effect.gen(function* () {
413 mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]')));
414 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
415
416 const error = yield* Effect.flip(
417 cli.getPullRequestStack({
418 cwd: "/w",
419 repository: "acme/web",
420 host: "github.com",
421 number: 7,
422 }),
423 );
424
425 assert.strictEqual(error._tag, "GitHubPullRequestReadError");
426 if (error._tag !== "GitHubPullRequestReadError") return;
427 assert.strictEqual(error.operation, "getPullRequestStack");
227 }),428 }),
228 );429 );
229430
apps/server/src/pullRequest/GitHubPullRequestCli.ts+94-42
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 decodePullRequestListJson,39 decodePullRequestListJson,
40 decodePullRequestNodeIdJson,40 decodePullRequestNodeIdJson,
41 decodePullRequestSearchJson,41 decodePullRequestSearchJson,
42 decodePullRequestStacksJson,
42 decodePullRequestStatsJson,43 decodePullRequestStatsJson,
43 decodeReactionSubjectScopeJson,44 decodeReactionSubjectScopeJson,
44 decodeRepositoryAccessJson,45 decodeRepositoryAccessJson,
...@@ -84,6 +85,7 @@ import {...@@ -84,6 +85,7 @@ import {
84 type GitHubPullRequestHead,85 type GitHubPullRequestHead,
85 type GitHubPullRequestListItem,86 type GitHubPullRequestListItem,
86 type GitHubPullRequestSearchItem,87 type GitHubPullRequestSearchItem,
88 type GitHubPullRequestStack,
87 type GitHubReviewThreadComments,89 type GitHubReviewThreadComments,
88 type GitHubRepositoryAccess,90 type GitHubRepositoryAccess,
89 type GitHubWorkflowRunApproval,91 type GitHubWorkflowRunApproval,
...@@ -91,7 +93,7 @@ import {...@@ -91,7 +93,7 @@ import {
91 type GitHubReviewThreadPage,93 type GitHubReviewThreadPage,
92 type GitHubViewerAccess,94 type GitHubViewerAccess,
93} from "./gitHubPullRequestJson.ts";95} from "./gitHubPullRequestJson.ts";
94import type { ProviderListCursor } from "./PullRequestProvider.ts";96import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts";
9597
96/**98/**
97 * Names the read that produced unusable output, so a failure reports the call it came from99 * Names the read that produced unusable output, so a failure reports the call it came from
...@@ -460,21 +462,7 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -460,21 +462,7 @@ export class GitHubPullRequestCli extends Context.Service<
460 readonly repository: string;462 readonly repository: string;
461 readonly host: string;463 readonly host: string;
462 readonly number: number;464 readonly number: number;
463 }) => Effect.Effect<465 }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestCliError>;
464 {
465 readonly number: number;
466 readonly title: string;
467 readonly url: string;
468 readonly headBranch: string;
469 readonly baseBranch: string;
470 readonly state: "open" | "closed" | "merged";
471 readonly isDraft?: boolean;
472 readonly closedAt?: string | null;
473 readonly mergedAt?: string | null;
474 readonly updatedAt: string;
475 },
476 GitHubPullRequestCliError
477 >;
478466
479 readonly getPullRequestDetail: (input: {467 readonly getPullRequestDetail: (input: {
480 readonly cwd: string;468 readonly cwd: string;
...@@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service<
494 readonly isCrossRepository: true;482 readonly isCrossRepository: true;
495 }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>;483 }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>;
496484
485 /**
486 * The host-native stack this pull request is in, or null when it is in none — which is also
487 * the answer for a host that refuses the stacks preview altogether.
488 */
489 readonly getPullRequestStack: (input: {
490 readonly cwd: string;
491 readonly repository: string;
492 readonly host: string;
493 readonly number: number;
494 }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>;
495
497 /**496 /**
498 * How far the branch trails its base, and whether this viewer may update it. Its own read497 * How far the branch trails its base, and whether this viewer may update it. Its own read
499 * because the comparison needs the head ref the detail answers with — a fork's branch is not498 * because the comparison needs the head ref the detail answers with — a fork's branch is not
...@@ -807,8 +806,8 @@ function matchesFilters(...@@ -807,8 +806,8 @@ function matchesFilters(
807 viewer: string,806 viewer: string,
808): boolean {807): boolean {
809 if (filters === undefined) return true;808 if (filters === undefined) return true;
810 const labels = item.labels.map((label) => label.name.trim().toLowerCase());809 const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
811 const holds = (label: string) => labels.includes(label.trim().toLowerCase());810 const holds = (label: string) => labels.has(label.trim().toLowerCase());
812 return (811 return (
813 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&812 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
814 (filters.review === undefined ||813 (filters.review === undefined ||
...@@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () {...@@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () {
1630 ).pipe(Effect.map((results) => results.flat()));1629 ).pipe(Effect.map((results) => results.flat()));
1631 },1630 },
16321631
1632 // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands
1633 // the thread overview its author, diff stat, review decision and checks in the same read.
1633 getPullRequestSummary: (input) =>1634 getPullRequestSummary: (input) =>
1634 github1635 github
1635 .getPullRequest({1636 .execute({
1636 cwd: input.cwd,1637 cwd: input.cwd,
1637 reference: `https://${input.host}/${input.repository}/pull/${input.number}`,1638 args: [
1639 "pr",
1640 "view",
1641 String(input.number),
1642 ...repositoryArgs(input),
1643 "--json",
1644 PULL_REQUEST_DETAIL_JSON_FIELDS,
1645 ],
1638 })1646 })
1639 .pipe(1647 .pipe(
1640 Effect.flatMap((summary) =>1648 Effect.flatMap((result) => {
1641 summary.updatedAt === undefined1649 const decoded = decodePullRequestDetailJson(result.stdout.trim());
1642 ? Effect.fail(1650 if (!Result.isSuccess(decoded)) {
1643 new GitHubPullRequestUpdatedAtUnavailableError({1651 return Effect.fail(
1644 command: "gh",1652 new GitHubPullRequestReadError({
1645 cwd: input.cwd,1653 command: "gh",
1646 repository: input.repository,1654 cwd: input.cwd,
1647 number: input.number,1655 operation: "getPullRequestSummary",
1648 }),1656 cause: decoded.failure,
1649 )
1650 : Effect.succeed({
1651 number: summary.number,
1652 title: summary.title,
1653 url: summary.url,
1654 headBranch: summary.headRefName,
1655 baseBranch: summary.baseRefName,
1656 state: summary.state ?? "open",
1657 ...(summary.isDraft === true ? { isDraft: true } : {}),
1658 closedAt: summary.closedAt ?? null,
1659 mergedAt: summary.mergedAt ?? null,
1660 updatedAt: summary.updatedAt,
1661 }),1657 }),
1662 ),1658 );
1659 }
1660 const detail = decoded.success;
1661 return Effect.succeed({
1662 number: detail.number,
1663 title: detail.title,
1664 url: detail.url,
1665 headBranch: detail.headBranch,
1666 baseBranch: detail.baseBranch,
1667 state: detail.state,
1668 updatedAt: detail.updatedAt,
1669 closedAt: detail.closedAt ?? null,
1670 mergedAt: detail.mergedAt ?? null,
1671 isDraft: detail.isDraft,
1672 author: detail.author,
1673 additions: detail.additions,
1674 deletions: detail.deletions,
1675 changedFiles: detail.changedFiles,
1676 reviewDecision: detail.reviewDecision,
1677 checksState: detail.checksState,
1678 mergeability: detail.mergeability,
1679 });
1680 }),
1663 ),1681 ),
16641682
1665 getPullRequestDetail,1683 getPullRequestDetail,
1666 listWorkflowRunsRequiringApproval,1684 listWorkflowRunsRequiringApproval,
16671685
1686 getPullRequestStack: (input) => {
1687 const { owner, name } = parseRepositorySelector(input.repository);
1688 return github
1689 .execute({
1690 cwd: input.cwd,
1691 args: [
1692 "api",
1693 "--hostname",
1694 input.host,
1695 `repos/${owner}/${name}/stacks?pull_request=${input.number}`,
1696 ],
1697 })
1698 .pipe(
1699 Effect.flatMap((result) => {
1700 const decoded = decodePullRequestStacksJson(result.stdout.trim());
1701 return Result.isSuccess(decoded)
1702 ? Effect.succeed(decoded.success)
1703 : Effect.fail(
1704 new GitHubPullRequestReadError({
1705 command: "gh",
1706 cwd: input.cwd,
1707 operation: "getPullRequestStack",
1708 cause: decoded.failure,
1709 }),
1710 );
1711 }),
1712 // Hosts without the stacks preview return 404. Other failures must preserve the
1713 // previously synced stack and let the caller retry.
1714 Effect.catchTags({
1715 GitHubPullRequestNotFoundError: () => Effect.succeed(null),
1716 }),
1717 );
1718 },
1719
1668 getPullRequestBaseComparison: (input) => {1720 getPullRequestBaseComparison: (input) => {
1669 const { owner, name } = parseRepositorySelector(input.repository);1721 const { owner, name } = parseRepositorySelector(input.repository);
1670 return graphqlRead({1722 return graphqlRead({
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+61
...@@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () =>...@@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () =>
25 baseBranch: "main",25 baseBranch: "main",
26 state: "open" as const,26 state: "open" as const,
27 updatedAt: "2026-08-24T12:34:56.000Z",27 updatedAt: "2026-08-24T12:34:56.000Z",
28 author: { login: "octocat", name: null, avatarUrl: null },
28 };29 };
29 }),30 }),
30 }),31 }),
...@@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () =>...@@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () =>
4243
43 expect(summary.state).toBe("open");44 expect(summary.state).toBe("open");
44 expect(summaryReads).toBe(1);45 expect(summaryReads).toBe(1);
46 // The author's avatar comes from the login-shaped URL, not a second request.
47 expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80");
48 }),
49);
50
51it.effect("declares host-native stacks and passes the one the CLI reads through", () =>
52 Effect.gen(function* () {
53 const stack = {
54 id: "42",
55 number: 3,
56 url: "https://github.com/acme/web/stacks/3",
57 base: "main",
58 layers: [
59 { number: 6, headBranch: "feat/one", state: "merged" as const },
60 { number: 7, headBranch: "feat/two", state: "open" as const },
61 ],
62 };
63 const provider = yield* make.pipe(
64 Effect.provide(
65 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
66 getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null),
67 }),
68 ),
69 );
70
71 expect(provider.capabilities.stacks).toBe(true);
72 const readStack = provider.getChangeRequestStack;
73 if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
74 const ref = { cwd: "/w", repository: "acme/web", host: "github.com" };
75 expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack);
76 expect(yield* readStack({ ...ref, number: 8 })).toBeNull();
77 }),
78);
79
80it.effect("reports a failed stack read against its own operation", () =>
81 Effect.gen(function* () {
82 const provider = yield* make.pipe(
83 Effect.provide(
84 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
85 getPullRequestStack: () =>
86 Effect.fail(
87 new GitHubPullRequestCli.GitHubPullRequestReadError({
88 command: "gh",
89 cwd: "/w",
90 operation: "getPullRequestStack",
91 cause: new Error("unreadable"),
92 }),
93 ),
94 }),
95 ),
96 );
97
98 const readStack = provider.getChangeRequestStack;
99 if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
100 const error = yield* Effect.flip(
101 readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }),
102 );
103
104 expect(error.operation).toBe("getChangeRequestStack");
105 expect(error.reason).toBe("failed");
45 }),106 }),
46);107);
47108
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+15-1
...@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = {
47 },47 },
48 reviewers: { request: true, listCandidates: true },48 reviewers: { request: true, listCandidates: true },
49 edit: { changeRequest: true, comment: true },49 edit: { changeRequest: true, comment: true },
50 stacks: true,
50 labels: true,51 labels: true,
51};52};
5253
...@@ -292,7 +293,20 @@ export const make = Effect.gen(function* () {...@@ -292,7 +293,20 @@ export const make = Effect.gen(function* () {
292 .pipe(Effect.mapError(fail("listChangeRequestStats"))),293 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
293294
294 getChangeRequestSummary: (input) =>295 getChangeRequestSummary: (input) =>
295 cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))),296 cli.getPullRequestSummary(input).pipe(
297 // `gh pr view` names the author without an avatar; the login-shaped URL every user
298 // has stands in, without the second request the listing spends on it.
299 Effect.map((summary) => ({
300 ...summary,
301 ...(summary.author === undefined
302 ? {}
303 : { author: withAvatar(summary.author, new Map(), input.host) }),
304 })),
305 Effect.mapError(fail("getChangeRequestSummary")),
306 ),
307
308 getChangeRequestStack: (input) =>
309 cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))),
296310
297 getChangeRequest: (input) =>311 getChangeRequest: (input) =>
298 Effect.all(312 Effect.all(
apps/server/src/pullRequest/PullRequestProvider.ts+36
...@@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary {...@@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary {
103 readonly closedAt?: string | null;103 readonly closedAt?: string | null;
104 readonly mergedAt?: string | null;104 readonly mergedAt?: string | null;
105 readonly updatedAt: string;105 readonly updatedAt: string;
106 /** Overview fields, present where the host's single read returns them at no extra cost. */
107 readonly author?: PullRequestActor | null | undefined;
108 readonly additions?: number | undefined;
109 readonly deletions?: number | undefined;
110 readonly changedFiles?: number | undefined;
111 readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
112 readonly checksState?: PullRequestChecksState | null | undefined;
113 readonly mergeability?: PullRequestMergeability | undefined;
114}
115
116/** One layer of a host-native stack, bottom to top order is the array's. */
117export interface ProviderChangeRequestStackLayer {
118 readonly number: number;
119 readonly headBranch: string;
120 readonly state: PullRequestState;
121}
122
123/**
124 * A host-native stack: an ordered set of change requests the host itself merges and retargets as
125 * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay
126 * ignorant of which host said so.
127 */
128export interface ProviderChangeRequestStack {
129 readonly id: string;
130 readonly number: number;
131 readonly url: string;
132 readonly base: string;
133 readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>;
106}134}
107135
108export interface ProviderChangeRequestPage {136export interface ProviderChangeRequestPage {
...@@ -331,6 +359,14 @@ export interface PullRequestProviderApi {...@@ -331,6 +359,14 @@ export interface PullRequestProviderApi {
331 input: ProviderRepositoryRef & { readonly number: number },359 input: ProviderRepositoryRef & { readonly number: number },
332 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;360 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
333361
362 /**
363 * The host-native stack a change request belongs to, or null when it is not stacked. Optional
364 * because most hosts have no such object; the service derives chains from base branches there.
365 */
366 readonly getChangeRequestStack?: (
367 input: ProviderRepositoryRef & { readonly number: number },
368 ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
369
334 /** Comments, line threads, and commits, kept off the critical path for the core detail. */370 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
335 readonly getChangeRequestActivity: (371 readonly getChangeRequestActivity: (
336 input: ProviderRepositoryRef & { readonly number: number },372 input: ProviderRepositoryRef & { readonly number: number },
apps/server/src/pullRequest/PullRequestService.test.ts+323-39
...@@ -32,6 +32,7 @@ function project(input: {...@@ -32,6 +32,7 @@ function project(input: {
32 readonly repository?: string;32 readonly repository?: string;
33 readonly provider?: string;33 readonly provider?: string;
34 readonly host?: string;34 readonly host?: string;
35 readonly remoteUrl?: string;
35}): OrchestrationProjectShell {36}): OrchestrationProjectShell {
36 // The host defaults from the provider, so a fixture only names one when the point of the37 // The host defaults from the provider, so a fixture only names one when the point of the
37 // test is two hosts of the same kind.38 // test is two hosts of the same kind.
...@@ -47,7 +48,7 @@ function project(input: {...@@ -47,7 +48,7 @@ function project(input: {
47 locator: {48 locator: {
48 source: "git-remote" as const,49 source: "git-remote" as const,
49 remoteName: "origin",50 remoteName: "origin",
50 remoteUrl: `https://${host}/${input.repository}.git`,51 remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`,
51 },52 },
52 provider: input.provider ?? "github",53 provider: input.provider ?? "github",
53 displayName: input.repository,54 displayName: input.repository,
...@@ -470,7 +471,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows"...@@ -470,7 +471,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows"
470471
471 // Keyed by the selector Azure is actually asked with, which is the repository's own name.472 // Keyed by the selector Azure is actually asked with, which is the repository's own name.
472 assert.deepStrictEqual(result.nextCursors, {473 assert.deepStrictEqual(result.nextCursors, {
473 "dev.azure.com web": "2026-07-02T00:00:00Z|4|7",474 "dev.azure.com dev.azure.com/acme/web": "2026-07-02T00:00:00Z|4|7",
474 });475 });
475 }),476 }),
476);477);
...@@ -1563,6 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project",...@@ -1563,6 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project",
1563 }),1564 }),
1564);1565);
15651566
1567it.effect("reads a host-native stack through the provider and null where it has none", () =>
1568 Effect.gen(function* () {
1569 const service = yield* makeService({
1570 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1571 providers: [
1572 fakeProvider("github", {
1573 getChangeRequestStack: () =>
1574 Effect.succeed({
1575 id: "9",
1576 number: 3,
1577 url: "https://github.com/acme/web/stacks/3",
1578 base: "main",
1579 layers: [
1580 { number: 7, headBranch: "a", state: "open" as const },
1581 { number: 8, headBranch: "b", state: "open" as const },
1582 ],
1583 }),
1584 }),
1585 ],
1586 });
1587
1588 const stack = yield* service.stack({
1589 projectId: "p1" as ProjectId,
1590 repository: "acme/web",
1591 number: 7,
1592 });
1593 assert.deepStrictEqual(
1594 stack?.layers.map((layer) => layer.number),
1595 [7, 8],
1596 );
1597
1598 const withoutStacks = yield* makeService({
1599 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1600 providers: [fakeProvider("github")],
1601 });
1602 assert.isNull(
1603 yield* withoutStacks.stack({
1604 projectId: "p1" as ProjectId,
1605 repository: "acme/web",
1606 number: 7,
1607 }),
1608 );
1609 }),
1610);
1611
1612it.effect("routes a hosted reference to another repository through a project on that host", () =>
1613 Effect.gen(function* () {
1614 const seen: Array<{ cwd: string; repository: string; host: string }> = [];
1615 const service = yield* makeService({
1616 projects: [
1617 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
1618 ],
1619 providers: [
1620 fakeProvider("github", {
1621 getChangeRequestSummary: (input) =>
1622 Effect.sync(() => {
1623 seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
1624 return changeRequest(7, "2026-07-02T00:00:00Z");
1625 }),
1626 }),
1627 ],
1628 });
1629
1630 const summary = yield* service.summary(
1631 { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 },
1632 { recoverTransientFailure: false },
1633 );
1634
1635 assert.strictEqual(summary.number, 7);
1636 assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]);
1637 }),
1638);
1639
1640it.effect("routes Azure reads and writes through the requested organization's checkout", () =>
1641 Effect.gen(function* () {
1642 const seen: string[] = [];
1643 const service = yield* makeService({
1644 projects: ["org-a", "org-b"].map((organization) =>
1645 project({
1646 id: organization,
1647 title: organization,
1648 workspaceRoot: `/${organization}`,
1649 repository: `${organization}/project/_git/web`,
1650 provider: "azure-devops",
1651 host: "dev.azure.com",
1652 }),
1653 ),
1654 providers: [
1655 fakeProvider("azure-devops", {
1656 getChangeRequestSummary: (input) =>
1657 Effect.sync(() => {
1658 seen.push(`read ${input.cwd} ${input.repository}`);
1659 return changeRequest(7, "2026-07-02T00:00:00Z");
1660 }),
1661 runAction: (input) =>
1662 Effect.sync(() => {
1663 seen.push(`write ${input.cwd} ${input.repository}`);
1664 }),
1665 }),
1666 ],
1667 });
1668 const reference = {
1669 projectId: "org-a" as ProjectId,
1670 host: "dev.azure.com",
1671 repository: "org-b/project/_git/web",
1672 number: 7,
1673 };
1674 yield* service.summary(reference, { recoverTransientFailure: false });
1675 yield* service.runAction({ ...reference, action: "merge" });
1676 assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]);
1677 }),
1678);
1679
1680for (const checkout of [
1681 {
1682 host: "ssh.dev.azure.com",
1683 repository: "v3/org-b/project/web",
1684 remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web",
1685 },
1686 {
1687 host: "vs-ssh.visualstudio.com",
1688 repository: "v3/org-b/project/web",
1689 remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web",
1690 },
1691 {
1692 host: "org-b.visualstudio.com",
1693 repository: "DefaultCollection/project/_git/web",
1694 remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web",
1695 },
1696]) {
1697 it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () =>
1698 Effect.gen(function* () {
1699 const seen: string[] = [];
1700 const target = project({
1701 id: "target",
1702 title: "target",
1703 workspaceRoot: "/target",
1704 provider: "azure-devops",
1705 ...checkout,
1706 });
1707 const service = yield* makeService({
1708 projects: [
1709 ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>
1710 project({
1711 id: repository,
1712 title: repository,
1713 workspaceRoot: `/${repository}`,
1714 provider: "azure-devops",
1715 host: "dev.azure.com",
1716 repository,
1717 }),
1718 ),
1719 target,
1720 ],
1721 providers: [
1722 fakeProvider("azure-devops", {
1723 getChangeRequestSummary: (input) =>
1724 Effect.sync(() => {
1725 seen.push(`read ${input.cwd} ${input.repository}`);
1726 return changeRequest(7, "2026-07-02T00:00:00Z");
1727 }),
1728 runAction: (input) =>
1729 Effect.sync(() => {
1730 seen.push(`write ${input.cwd} ${input.repository}`);
1731 }),
1732 }),
1733 ],
1734 });
1735 const reference = {
1736 projectId: "org-a/project/_git/web" as ProjectId,
1737 host: "dev.azure.com",
1738 repository: "org-b/project/_git/web",
1739 number: 7,
1740 };
1741 yield* service.summary(reference, { recoverTransientFailure: false });
1742 yield* service.runAction({ ...reference, action: "merge" });
1743 assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]);
1744 }),
1745 );
1746}
1747
1748it.effect("refuses Azure cross-organization reads and writes without its checkout", () =>
1749 Effect.gen(function* () {
1750 const service = yield* makeService({
1751 projects: [
1752 project({
1753 id: "org-a",
1754 title: "org-a",
1755 workspaceRoot: "/org-a",
1756 repository: "org-a/project/_git/web",
1757 provider: "azure-devops",
1758 host: "dev.azure.com",
1759 }),
1760 ],
1761 providers: [
1762 fakeProvider("azure-devops", {
1763 getChangeRequestSummary: () => Effect.die("must not read the wrong organization"),
1764 runAction: () => Effect.die("must not modify the wrong organization"),
1765 }),
1766 ],
1767 });
1768 const reference = {
1769 projectId: "org-a" as ProjectId,
1770 host: "dev.azure.com",
1771 repository: "org-b/project/_git/web",
1772 number: 7,
1773 };
1774 const readError = yield* Effect.flip(
1775 service.summary(reference, { recoverTransientFailure: false }),
1776 );
1777 const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" }));
1778 assert.strictEqual(readError._tag, "PullRequestUnavailableError");
1779 assert.strictEqual(writeError._tag, "PullRequestUnavailableError");
1780 }),
1781);
1782
1783it.effect("refuses a hosted reference when nothing is checked out from that host", () =>
1784 Effect.gen(function* () {
1785 const service = yield* makeService({
1786 projects: [
1787 project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
1788 ],
1789 providers: [fakeProvider("github")],
1790 });
1791
1792 const error = yield* service
1793 .summary(
1794 {
1795 projectId: "frontend" as ProjectId,
1796 host: "gitlab.com",
1797 repository: "acme/api",
1798 number: 7,
1799 },
1800 { recoverTransientFailure: false },
1801 )
1802 .pipe(Effect.flip);
1803
1804 assert.strictEqual(error._tag, "PullRequestUnavailableError");
1805 }),
1806);
1807
1566it.effect("refuses a diff on a host that cannot produce one", () =>1808it.effect("refuses a diff on a host that cannot produce one", () =>
1567 Effect.gen(function* () {1809 Effect.gen(function* () {
1568 const service = yield* makeService({1810 const service = yield* makeService({
...@@ -3377,6 +3619,48 @@ it.effect("does not ask the host again for a linked summary it already holds", (...@@ -3377,6 +3619,48 @@ it.effect("does not ask the host again for a linked summary it already holds", (
3377 }),3619 }),
3378);3620);
33793621
3622it.effect(
3623 "opening detail preserves enriched linked summaries and updates draft and diff fields",
3624 () =>
3625 Effect.gen(function* () {
3626 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3627 const service = yield* makeService({
3628 projects: [
3629 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3630 ],
3631 providers: [
3632 fakeProvider("github", {
3633 getChangeRequestSummary: () =>
3634 Effect.succeed({
3635 ...changeRequest(1, "2026-07-02T00:00:00Z"),
3636 isDraft: true,
3637 reviewDecision: "approved",
3638 checksState: "passing",
3639 }),
3640 getChangeRequest: () =>
3641 Effect.succeed({
3642 ...hostedChangeRequest("body", 14),
3643 deletions: 3,
3644 changedFiles: 5,
3645 mergeability: "conflicting",
3646 }),
3647 }),
3648 ],
3649 });
3650 yield* service.summary(reference);
3651 const detail = yield* service.detail(reference);
3652 const summary = yield* service.summary(reference);
3653 assert.strictEqual(summary.isDraft, false);
3654 assert.deepStrictEqual(summary.author, detail.author);
3655 assert.strictEqual(summary.additions, 14);
3656 assert.strictEqual(summary.deletions, 3);
3657 assert.strictEqual(summary.changedFiles, 5);
3658 assert.strictEqual(summary.mergeability, "conflicting");
3659 assert.strictEqual(summary.reviewDecision, "approved");
3660 assert.strictEqual(summary.checksState, "passing");
3661 }),
3662);
3663
3380it.effect("reuses an observed merged state for strict settlement reads", () =>3664it.effect("reuses an observed merged state for strict settlement reads", () =>
3381 Effect.gen(function* () {3665 Effect.gen(function* () {
3382 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };3666 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
...@@ -3587,43 +3871,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil...@@ -3587,43 +3871,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil
3587 }),3871 }),
3588);3872);
35893873
3590it("names an Azure DevOps repository by its own name, not its project path", () => {
3591 // `az repos pr list --repository` takes a name and detects the organisation and project from
3592 // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then
3593 // reads as unavailable on the page.
3594 const selector = PullRequestService.repositoryIdentityOf({
3595 repositoryIdentity: {
3596 provider: "azure-devops",
3597 displayName: "contoso/payments/_git/checkout",
3598 owner: "contoso",
3599 name: "checkout",
3600 },
3601 } as never);
3602 assert.strictEqual(selector, "checkout");
3603});
3604
3605it("falls back to the path's last segment where an Azure identity has no name", () => {
3606 const selector = PullRequestService.repositoryIdentityOf({
3607 repositoryIdentity: {
3608 provider: "azure-devops",
3609 displayName: "contoso/payments/_git/checkout",
3610 },
3611 } as never);
3612 assert.strictEqual(selector, "checkout");
3613});
3614
3615it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => {
3616 const selector = PullRequestService.repositoryIdentityOf({
3617 repositoryIdentity: {
3618 provider: "gitlab",
3619 displayName: "group/subgroup/service",
3620 owner: "group",
3621 name: "service",
3622 },
3623 } as never);
3624 assert.strictEqual(selector, "group/subgroup/service");
3625});
3626
3627it.effect("narrows the rows of a host that ignored the filters it was handed", () =>3874it.effect("narrows the rows of a host that ignored the filters it was handed", () =>
3628 Effect.gen(function* () {3875 Effect.gen(function* () {
3629 const service = yield* makeService({3876 const service = yield* makeService({
...@@ -4137,3 +4384,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the...@@ -4137,3 +4384,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the
4137 assert.strictEqual(unnamed.viewer, undefined);4384 assert.strictEqual(unnamed.viewer, undefined);
4138 }),4385 }),
4139);4386);
4387
4388it.effect("keeps Azure continuation cursors separate for repositories with the same name", () =>
4389 Effect.gen(function* () {
4390 const seen: string[] = [];
4391 const service = yield* makeService({
4392 projects: ["org-a", "org-b"].map((organization) =>
4393 project({
4394 id: organization,
4395 title: organization,
4396 workspaceRoot: `/${organization}`,
4397 repository: `${organization}/project/_git/web`,
4398 provider: "azure-devops",
4399 host: "dev.azure.com",
4400 }),
4401 ),
4402 providers: [
4403 fakeProvider("azure-devops", {
4404 listChangeRequests: (input) =>
4405 Effect.sync(() => {
4406 seen.push(input.cwd);
4407 return {
4408 items: [changeRequest(7, "2026-07-02T00:00:00Z")],
4409 truncated: true,
4410 continues: true,
4411 };
4412 }),
4413 }),
4414 ],
4415 });
4416 const first = yield* service.list({ state: "open" });
4417 assert.lengthOf(Object.keys(first.nextCursors), 2);
4418 const key = Object.keys(first.nextCursors).find((key) => key.includes("org-b"))!;
4419 seen.length = 0;
4420 yield* service.list({ state: "open", cursors: { [key]: first.nextCursors[key]! } });
4421 assert.deepStrictEqual(seen, ["/org-b"]);
4422 }),
4423);
apps/server/src/pullRequest/PullRequestService.ts+202-59
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import {
2 canonicalRepositoryKey,
3 sourceControlRepositorySelector,
4} from "@t3tools/shared/sourceControl";
1import * as Cache from "effect/Cache";5import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";6import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";7import * as Context from "effect/Context";
...@@ -46,6 +50,7 @@ import {...@@ -46,6 +50,7 @@ import {
46 type PullRequestLabelCandidateList,50 type PullRequestLabelCandidateList,
47 type PullRequestLabelChangeInput,51 type PullRequestLabelChangeInput,
48 type PullRequestSubmitReviewInput,52 type PullRequestSubmitReviewInput,
53 type PullRequestStack,
49 type PullRequestSummary,54 type PullRequestSummary,
50 type PullRequestThreadReplyInput,55 type PullRequestThreadReplyInput,
51 type PullRequestThreadResolutionInput,56 type PullRequestThreadResolutionInput,
...@@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service<...@@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service<
144 input: PullRequestRef,149 input: PullRequestRef,
145 options?: { readonly recoverTransientFailure?: boolean },150 options?: { readonly recoverTransientFailure?: boolean },
146 ) => Effect.Effect<PullRequestSummary, PullRequestError>;151 ) => Effect.Effect<PullRequestSummary, PullRequestError>;
152 /**
153 * The host-native stack the pull request belongs to, or null when it is not in one or the
154 * host keeps no such object. Cached like a summary; a stack changes about as often.
155 */
156 readonly stack: (
157 input: PullRequestRef,
158 ) => Effect.Effect<PullRequestStack | null, PullRequestError>;
147 readonly subscribeMerges: Effect.Effect<159 readonly subscribeMerges: Effect.Effect<
148 Stream.Stream<PullRequestMergeEvent>,160 Stream.Stream<PullRequestMergeEvent>,
149 never,161 never,
...@@ -241,6 +253,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang...@@ -241,6 +253,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang
241253
242/** A project this page can read: its remote is on a host with an implementation. */254/** A project this page can read: its remote is on a host with an implementation. */
243interface SupportedProject {255interface SupportedProject {
256 readonly cursorKey: string;
244 readonly project: OrchestrationProjectShell;257 readonly project: OrchestrationProjectShell;
245 readonly api: PullRequestProviderApi;258 readonly api: PullRequestProviderApi;
246 readonly repository: string;259 readonly repository: string;
...@@ -453,7 +466,7 @@ function withRateLimitBackoff(...@@ -453,7 +466,7 @@ function withRateLimitBackoff(
453 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,466 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,
454 ) => wrap(operation, call, true);467 ) => wrap(operation, call, true);
455468
456 return {469 const wrapped = {
457 kind: api.kind,470 kind: api.kind,
458 capabilities: api.capabilities,471 capabilities: api.capabilities,
459 getViewer: wrap("getViewer", api.getViewer),472 getViewer: wrap("getViewer", api.getViewer),
...@@ -474,6 +487,9 @@ function withRateLimitBackoff(...@@ -474,6 +487,9 @@ function withRateLimitBackoff(
474 : {487 : {
475 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),488 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),
476 }),489 }),
490 ...(api.getChangeRequestStack === undefined
491 ? {}
492 : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }),
477 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),493 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
478 ...(api.getReviewThreadComments === undefined494 ...(api.getReviewThreadComments === undefined
479 ? {}495 ? {}
...@@ -506,30 +522,9 @@ function withRateLimitBackoff(...@@ -506,30 +522,9 @@ function withRateLimitBackoff(
506 setReaction: interactive("setReaction", api.setReaction),522 setReaction: interactive("setReaction", api.setReaction),
507 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),523 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
508 };524 };
509}525 // Optional provider methods must be forwarded too; returning the interface alone permits omissions.
510526 return wrapped satisfies PullRequestProviderApi &
511/**527 Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>;
512 * The provider-native repository selector. `displayName` is the full path below the host, which
513 * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
514 * recorded before that field existed.
515 *
516 * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and
517 * takes the organisation and project from the checkout it detects — so the recorded
518 * `org/project/_git/repo` path is refused outright and the whole repository reads as
519 * unavailable. Its name is the last segment, which is what this hands over.
520 *
521 * One function because everything downstream is keyed by what it answers: the rows' own
522 * `repository`, the per-repository cursors, and the detail and diff reads a row leads to.
523 */
524export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null {
525 const identity = project.repositoryIdentity;
526 if (!identity) return null;
527 if (identity.provider === "azure-devops") {
528 const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git");
529 return identity.name || segments.at(-1) || null;
530 }
531 if (identity.displayName) return identity.displayName;
532 return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
533}528}
534529
535export const make = Effect.gen(function* () {530export const make = Effect.gen(function* () {
...@@ -554,7 +549,11 @@ export const make = Effect.gen(function* () {...@@ -554,7 +549,11 @@ export const make = Effect.gen(function* () {
554 for (const project of projects) {549 for (const project of projects) {
555 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;550 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
556 const identity = project.repositoryIdentity;551 const identity = project.repositoryIdentity;
557 if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue;552 if (
553 identity?.provider !== "unknown" ||
554 sourceControlRepositorySelector(project.repositoryIdentity) === null
555 )
556 continue;
558 const host = pullRequestHostOf(identity, "unknown");557 const host = pullRequestHostOf(identity, "unknown");
559 // A legacy identity has no canonical host until its provider is refined, so it must reach558 // A legacy identity has no canonical host until its provider is refined, so it must reach
560 // the refinement before a host filter can decide whether it belongs in the result.559 // the refinement before a host filter can decide whether it belongs in the result.
...@@ -628,7 +627,7 @@ export const make = Effect.gen(function* () {...@@ -628,7 +627,7 @@ export const make = Effect.gen(function* () {
628 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;627 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;
629 const identity = project.repositoryIdentity;628 const identity = project.repositoryIdentity;
630 let kind = identity?.provider as SourceControlProviderKind | undefined;629 let kind = identity?.provider as SourceControlProviderKind | undefined;
631 const repository = repositoryIdentityOf(project);630 const repository = sourceControlRepositorySelector(project.repositoryIdentity);
632 if (!identity || kind === undefined || repository === null) continue;631 if (!identity || kind === undefined || repository === null) continue;
633 // Worktrees of one repository are separate projects; reading the remote once keeps632 // Worktrees of one repository are separate projects; reading the remote once keeps
634 // the page from repeating every change request per local checkout. The host is part633 // the page from repeating every change request per local checkout. The host is part
...@@ -647,7 +646,10 @@ export const make = Effect.gen(function* () {...@@ -647,7 +646,10 @@ export const make = Effect.gen(function* () {
647 if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);646 if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);
648 else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot);647 else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot);
649 }648 }
650 const key = listCursorKey(host, repository);649 const key = listCursorKey(
650 host,
651 kind === "azure-devops" ? identity.canonicalKey : repository,
652 );
651 if (seen.has(key)) continue;653 if (seen.has(key)) continue;
652 seen.add(key);654 seen.add(key);
653 if (api === null) {655 if (api === null) {
...@@ -657,6 +659,7 @@ export const make = Effect.gen(function* () {...@@ -657,6 +659,7 @@ export const make = Effect.gen(function* () {
657 continue;659 continue;
658 }660 }
659 supported.push({661 supported.push({
662 cursorKey: key,
660 project,663 project,
661 api: withRateLimitBackoff(api, host, rateLimits),664 api: withRateLimitBackoff(api, host, rateLimits),
662 repository,665 repository,
...@@ -667,16 +670,30 @@ export const make = Effect.gen(function* () {...@@ -667,16 +670,30 @@ export const make = Effect.gen(function* () {
667 }),670 }),
668 );671 );
669672
673 /**
674 * The project whose checkout and credentials serve a reference. The project's own
675 * repository is the default; a reference that names a `host` may instead point at any
676 * repository on that host. Prefer its own checkout; providers with explicit repository
677 * targeting can fall back to another checkout on the host. Azure derives its organization
678 * from the checkout, so it requires a matching repository.
679 */
670 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>680 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>
671 listWorkspaceProjects({ projectId: ref.projectId }).pipe(681 listWorkspaceProjects({ projectId: ref.projectId }).pipe(
672 Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => {682 Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => {
673 const match = supported[0];683 const own = supported[0];
674 if (!match) {684 const repository = ref.repository.trim();
675 return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));685 const host = ref.host?.trim().toLowerCase();
686 if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) {
687 // Hostless references only ever meant the project's own repository, and a hosted one
688 // naming it still is; either way the project serves itself.
689 if (host === undefined || host === own.host) return Effect.succeed(own);
676 }690 }
677 // The repository travels through the client, so it is checked against the project's691 if (host === undefined) {
678 // own remote rather than being handed to a provider verbatim.692 if (own === undefined) {
679 if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) {693 return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));
694 }
695 // The repository travels through the client, so it is checked against the project's
696 // own remote rather than being handed to a provider verbatim.
680 return Effect.fail(697 return Effect.fail(
681 new PullRequestOperationError({698 new PullRequestOperationError({
682 operation: "resolveRepository",699 operation: "resolveRepository",
...@@ -684,7 +701,39 @@ export const make = Effect.gen(function* () {...@@ -684,7 +701,39 @@ export const make = Effect.gen(function* () {
684 }),701 }),
685 );702 );
686 }703 }
687 return Effect.succeed(match);704 const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase());
705 // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare
706 // the complete repository identity before narrowing those checkouts by host.
707 return listWorkspaceProjects(
708 repositoryKey.startsWith("dev.azure.com/") ? {} : { host },
709 ).pipe(
710 Effect.flatMap(({ supported }) => {
711 const onHost = supported.filter((candidate) => candidate.host === host);
712 const route =
713 supported.find(
714 (candidate) =>
715 candidate.api.kind === "azure-devops" &&
716 candidate.project.repositoryIdentity != null &&
717 canonicalRepositoryKey(
718 candidate.project.repositoryIdentity.canonicalKey.toLowerCase(),
719 ) === repositoryKey,
720 ) ??
721 onHost.find(
722 (candidate) =>
723 candidate.api.kind !== "azure-devops" &&
724 candidate.repository.toLowerCase() === repository.toLowerCase(),
725 ) ??
726 onHost.find((candidate) => candidate.api.kind !== "azure-devops");
727 if (route === undefined) {
728 return Effect.fail(
729 new PullRequestUnavailableError({ reason: "provider-unsupported" }),
730 );
731 }
732 return Effect.succeed(
733 route.api.kind === "azure-devops" ? route : { ...route, repository },
734 );
735 }),
736 );
688 }),737 }),
689 );738 );
690739
...@@ -829,8 +878,8 @@ export const make = Effect.gen(function* () {...@@ -829,8 +878,8 @@ export const make = Effect.gen(function* () {
829 viewer: string,878 viewer: string,
830 ): boolean => {879 ): boolean => {
831 if (filters === undefined) return true;880 if (filters === undefined) return true;
832 const labels = item.labels.map((label) => label.name.trim().toLowerCase());881 const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
833 const holds = (label: string) => labels.includes(label.trim().toLowerCase());882 const holds = (label: string) => labels.has(label.trim().toLowerCase());
834 return (883 return (
835 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&884 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
836 // Judged on the provider row rather than the entry, because the two absences mean885 // Judged on the provider row rather than the entry, because the two absences mean
...@@ -949,9 +998,7 @@ export const make = Effect.gen(function* () {...@@ -949,9 +998,7 @@ export const make = Effect.gen(function* () {
949 const selected =998 const selected =
950 continuation === null999 continuation === null
951 ? projects1000 ? projects
952 : projects.filter(({ host, repository }) =>1001 : projects.filter(({ cursorKey }) => continuation.has(cursorKey));
953 continuation.has(listCursorKey(host, repository)),
954 );
955 const readable = selected.filter(({ host }) => viewers[host] !== undefined);1002 const readable = selected.filter(({ host }) => viewers[host] !== undefined);
956 // A host that could not be read still has projects, and they are absent from the list.1003 // A host that could not be read still has projects, and they are absent from the list.
957 // Reporting them keeps "N repositories were unavailable" honest instead of dropping them.1004 // Reporting them keeps "N repositories were unavailable" honest instead of dropping them.
...@@ -993,7 +1040,7 @@ export const make = Effect.gen(function* () {...@@ -993,7 +1040,7 @@ export const make = Effect.gen(function* () {
9931040
994 const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT;1041 const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT;
995 const cursorOf = (project: SupportedProject): ListCursor | undefined =>1042 const cursorOf = (project: SupportedProject): ListCursor | undefined =>
996 continuation?.get(listCursorKey(project.host, project.repository));1043 continuation?.get(project.cursorKey);
9971044
998 /**1045 /**
999 * One repository asked on its own. What every host without a search across repositories1046 * One repository asked on its own. What every host without a search across repositories
...@@ -1002,7 +1049,7 @@ export const make = Effect.gen(function* () {...@@ -1002,7 +1049,7 @@ export const make = Effect.gen(function* () {
1002 const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => {1049 const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => {
1003 {1050 {
1004 const viewer = viewers[project.host]!;1051 const viewer = viewers[project.host]!;
1005 const key = listCursorKey(project.host, project.repository);1052 const key = project.cursorKey;
1006 const cursor = cursorOf(project);1053 const cursor = cursorOf(project);
1007 return project.api1054 return project.api
1008 .listChangeRequests({1055 .listChangeRequests({
...@@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () {...@@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () {
1161 !cursorHere.seenAt.includes(item.number),1208 !cursorHere.seenAt.includes(item.number),
1162 );1209 );
1163 return Effect.succeed({1210 return Effect.succeed({
1164 key: listCursorKey(project.host, project.repository),1211 key: project.cursorKey,
1165 entries: items1212 entries: items
1166 .filter((item) => matchesRowFilters(item, input.filters, viewer))1213 .filter((item) => matchesRowFilters(item, input.filters, viewer))
1167 .map((item) => toEntry({ project, item, viewer })),1214 .map((item) => toEntry({ project, item, viewer })),
...@@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () {...@@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () {
1254 title: changeRequest.title,1301 title: changeRequest.title,
1255 url: changeRequest.url,1302 url: changeRequest.url,
1256 state: changeRequest.state,1303 state: changeRequest.state,
1257 ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
1258 headBranch: changeRequest.headBranch,1304 headBranch: changeRequest.headBranch,
1259 baseBranch: changeRequest.baseBranch,1305 baseBranch: changeRequest.baseBranch,
1260 closedAt: changeRequest.closedAt ?? null,1306 closedAt: changeRequest.closedAt ?? null,
1261 mergedAt: changeRequest.mergedAt ?? null,1307 mergedAt: changeRequest.mergedAt ?? null,
1262 updatedAt: changeRequest.updatedAt,1308 updatedAt: changeRequest.updatedAt,
1309 ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }),
1310 ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }),
1311 ...(changeRequest.additions === undefined
1312 ? {}
1313 : { additions: changeRequest.additions }),
1314 ...(changeRequest.deletions === undefined
1315 ? {}
1316 : { deletions: changeRequest.deletions }),
1317 ...(changeRequest.changedFiles === undefined
1318 ? {}
1319 : { changedFiles: changeRequest.changedFiles }),
1320 ...(changeRequest.reviewDecision === undefined
1321 ? {}
1322 : { reviewDecision: changeRequest.reviewDecision }),
1323 ...(changeRequest.checksState === undefined
1324 ? {}
1325 : { checksState: changeRequest.checksState }),
1326 ...(changeRequest.mergeability === undefined
1327 ? {}
1328 : { mergeability: changeRequest.mergeability }),
1263 })),1329 })),
1264 );1330 );
1265 }),1331 }),
1266 );1332 );
12671333
1334 const stackUncached: PullRequestService["Service"]["stack"] = (input) =>
1335 requireProject(input).pipe(
1336 Effect.flatMap((project) => {
1337 const read = project.api.getChangeRequestStack;
1338 if (read === undefined) return Effect.succeed(null);
1339 return read({
1340 cwd: project.project.workspaceRoot,
1341 repository: project.repository,
1342 host: project.host,
1343 number: input.number,
1344 }).pipe(
1345 Effect.mapError(toPullRequestError("stack")),
1346 Effect.map((stack): PullRequestStack | null =>
1347 stack === null
1348 ? null
1349 : {
1350 id: stack.id,
1351 number: stack.number,
1352 url: stack.url,
1353 base: stack.base,
1354 layers: stack.layers.map((layer) => ({
1355 number: layer.number,
1356 headBranch: layer.headBranch,
1357 state: layer.state,
1358 })),
1359 },
1360 ),
1361 );
1362 }),
1363 );
1364
1268 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>1365 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
1269 requireProject(input).pipe(1366 requireProject(input).pipe(
1270 Effect.flatMap((project) =>1367 Effect.flatMap((project) =>
...@@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () {...@@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () {
1507 })1604 })
1508 .pipe(1605 .pipe(
1509 Effect.mapError(toPullRequestError("runAction")),1606 Effect.mapError(toPullRequestError("runAction")),
1510 Effect.as(project.repository),1607 Effect.as(
1608 project.api.kind === "azure-devops"
1609 ? input.repository.trim()
1610 : project.repository,
1611 ),
1511 );1612 );
1512 }),1613 }),
1513 );1614 );
...@@ -2133,11 +2234,35 @@ export const make = Effect.gen(function* () {...@@ -2133,11 +2234,35 @@ export const make = Effect.gen(function* () {
2133 let turnRefreshEpoch = 0;2234 let turnRefreshEpoch = 0;
2134 const refEpochs = new Map<string, number>();2235 const refEpochs = new Map<string, number>();
2135 const REF_EPOCH_CAPACITY = 2_048;2236 const REF_EPOCH_CAPACITY = 2_048;
2136 const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;2237 const refScope = (ref: PullRequestRef) =>
2238 `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`;
2137 const refEpoch = (ref: PullRequestRef) =>2239 const refEpoch = (ref: PullRequestRef) =>
2138 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);2240 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
2241 // Keys carry the reference back out of the cache loader, so the slot layout is shared with
2242 // `refOfCacheKey` rather than read positionally at every loader.
2139 const refCacheKey = (ref: PullRequestRef) =>2243 const refCacheKey = (ref: PullRequestRef) =>
2140 JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]);2244 JSON.stringify([
2245 refEpoch(ref),
2246 ref.projectId,
2247 ref.host?.toLowerCase() ?? null,
2248 ref.repository.toLowerCase(),
2249 ref.number,
2250 ]);
2251 const refOfCacheKey = (key: string): PullRequestRef => {
2252 const [, projectId, host, repository, number] = JSON.parse(key) as [
2253 number,
2254 string,
2255 string | null,
2256 string,
2257 number,
2258 ];
2259 return {
2260 projectId,
2261 ...(host === null ? {} : { host }),
2262 repository,
2263 number,
2264 } as PullRequestRef;
2265 };
2141 // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse2266 // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse
2142 // them; explicit refreshes, mutations, and turns strand old and in-flight results.2267 // them; explicit refreshes, mutations, and turns strand old and in-flight results.
2143 const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]);2268 const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]);
...@@ -2181,8 +2306,7 @@ export const make = Effect.gen(function* () {...@@ -2181,8 +2306,7 @@ export const make = Effect.gen(function* () {
21812306
2182 const summaryCache = yield* Cache.makeWith(2307 const summaryCache = yield* Cache.makeWith(
2183 (key: string) => {2308 (key: string) => {
2184 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];2309 return summaryUncached(refOfCacheKey(key));
2185 return summaryUncached({ projectId, repository, number } as PullRequestRef);
2186 },2310 },
2187 {2311 {
2188 capacity: DETAIL_CACHE_CAPACITY,2312 capacity: DETAIL_CACHE_CAPACITY,
...@@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () {...@@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () {
2203 );2327 );
2204 };2328 };
22052329
2330 const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), {
2331 capacity: DETAIL_CACHE_CAPACITY,
2332 timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2333 });
2334 const stack: PullRequestService["Service"]["stack"] = (input) =>
2335 Cache.get(stackCache, refCacheKey(input));
2336
2206 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder2337 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
2207 // of in-flight state: concurrent identical reads coalesce on the key into one host request.2338 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
2208 // The continuation cursors are part of the key, entries sorted so one continuation is one2339 // The continuation cursors are part of the key, entries sorted so one continuation is one
...@@ -2283,8 +2414,7 @@ export const make = Effect.gen(function* () {...@@ -2283,8 +2414,7 @@ export const make = Effect.gen(function* () {
2283 const detailCache = yield* Cache.makeWith(2414 const detailCache = yield* Cache.makeWith(
2284 (key: string) => {2415 (key: string) => {
2285 const statsKey = statsCacheKey(key);2416 const statsKey = statsCacheKey(key);
2286 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];2417 return detailUncached(refOfCacheKey(key)).pipe(
2287 return detailUncached({ projectId, repository, number } as PullRequestRef).pipe(
2288 Effect.tap(2418 Effect.tap(
2289 Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) {2419 Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) {
2290 recordStats(2420 recordStats(
...@@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () {...@@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () {
2307 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),2437 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
2308 },2438 },
2309 );2439 );
2310 const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({2440 const summaryFromDetail = (
2441 detail: PullRequestDetail,
2442 previous: PullRequestSummary | undefined,
2443 ): PullRequestSummary => ({
2444 // Detail does not carry review/check summaries. Keep the last summary observation.
2445 ...previous,
2311 provider: detail.provider,2446 provider: detail.provider,
2312 projectId: detail.projectId,2447 projectId: detail.projectId,
2313 repository: detail.repository,2448 repository: detail.repository,
...@@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () {...@@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () {
2315 title: detail.title,2450 title: detail.title,
2316 url: detail.url,2451 url: detail.url,
2317 state: detail.state,2452 state: detail.state,
2318 ...(detail.isDraft === true ? { isDraft: true } : {}),2453 isDraft: detail.isDraft,
2454 author: detail.author,
2455 additions: detail.additions,
2456 deletions: detail.deletions,
2457 changedFiles: detail.changedFiles,
2458 mergeability: detail.mergeability,
2319 headBranch: detail.headBranch,2459 headBranch: detail.headBranch,
2320 baseBranch: detail.baseBranch,2460 baseBranch: detail.baseBranch,
2321 closedAt: detail.closedAt,2461 closedAt: detail.closedAt,
...@@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () {...@@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () {
2338 key,2478 key,
2339 Cache.get(detailCache, key).pipe(2479 Cache.get(detailCache, key).pipe(
2340 Effect.tap((value) => {2480 Effect.tap((value) => {
2341 const summary = summaryFromDetail(value);2481 const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
2342 return shouldReplaceHeldSummary(key, summary)2482 return shouldReplaceHeldSummary(key, summary)
2343 ? lastGoodSummary.record(key, summary)2483 ? lastGoodSummary.record(key, summary)
2344 : Effect.void;2484 : Effect.void;
...@@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () {...@@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () {
23502490
2351 const activityCache = yield* Cache.makeWith(2491 const activityCache = yield* Cache.makeWith(
2352 (key: string) => {2492 (key: string) => {
2353 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];2493 return activityUncached(refOfCacheKey(key));
2354 return activityUncached({ projectId, repository, number } as PullRequestRef);
2355 },2494 },
2356 {2495 {
2357 capacity: DETAIL_CACHE_CAPACITY,2496 capacity: DETAIL_CACHE_CAPACITY,
...@@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () {...@@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () {
23652504
2366 const diffCache = yield* Cache.makeWith(2505 const diffCache = yield* Cache.makeWith(
2367 (key: string) => {2506 (key: string) => {
2368 const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [2507 const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [
2369 number,2508 number,
2370 string,2509 string,
2510 string | null,
2371 string,2511 string,
2372 number,2512 number,
2373 string | null,2513 string | null,
...@@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () {...@@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () {
2375 ];2515 ];
2376 return diffUncached({2516 return diffUncached({
2377 projectId,2517 projectId,
2518 ...(host === null ? {} : { host }),
2378 repository,2519 repository,
2379 number,2520 number,
2380 ...(cursor === null ? {} : { cursor }),2521 ...(cursor === null ? {} : { cursor }),
...@@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () {...@@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () {
2385 capacity: DIFF_CACHE_CAPACITY,2526 capacity: DIFF_CACHE_CAPACITY,
2386 timeToLive: (exit, key) => {2527 timeToLive: (exit, key) => {
2387 if (!Exit.isSuccess(exit)) return Duration.zero;2528 if (!Exit.isSuccess(exit)) return Duration.zero;
2388 const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[5];2529 const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[6];
2389 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;2530 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;
2390 },2531 },
2391 },2532 },
...@@ -2394,7 +2535,8 @@ export const make = Effect.gen(function* () {...@@ -2394,7 +2535,8 @@ export const make = Effect.gen(function* () {
2394 const key = JSON.stringify([2535 const key = JSON.stringify([
2395 refEpoch(input),2536 refEpoch(input),
2396 input.projectId,2537 input.projectId,
2397 input.repository,2538 input.host?.toLowerCase() ?? null,
2539 input.repository.toLowerCase(),
2398 input.number,2540 input.number,
2399 input.cursor ?? null,2541 input.cursor ?? null,
2400 input.commit ?? null,2542 input.commit ?? null,
...@@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () {...@@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () {
2523 list,2665 list,
2524 listStats,2666 listStats,
2525 summary,2667 summary,
2668 stack,
2526 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(2669 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
2527 Effect.map((subscription) => Stream.fromSubscription(subscription)),2670 Effect.map((subscription) => Stream.fromSubscription(subscription)),
2528 ),2671 ),
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+79
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 decodePullRequestListJson,11 decodePullRequestListJson,
12 decodePullRequestNodeIdJson,12 decodePullRequestNodeIdJson,
13 decodePullRequestSearchJson,13 decodePullRequestSearchJson,
14 decodePullRequestStacksJson,
14 decodeLabelCandidatesJson,15 decodeLabelCandidatesJson,
15 decodeRepositoryAccessJson,16 decodeRepositoryAccessJson,
16 decodeReviewerCandidatesJson,17 decodeReviewerCandidatesJson,
...@@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => {...@@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => {
1500 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);1501 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);
1501 });1502 });
1502});1503});
1504
1505describe("host-native stack decoding", () => {
1506 /** A stack as the preview lists it, bottom to top, with the fields it answers today. */
1507 function stack(overrides: Record<string, unknown> = {}) {
1508 return {
1509 id: 42,
1510 number: 3,
1511 node_id: "STK_kwDO",
1512 url: "https://api.github.com/repos/acme/web/stacks/3",
1513 base: { ref: "main", sha: "abc" },
1514 open: true,
1515 created_at: "2026-09-01T00:00:00Z",
1516 pull_requests: [
1517 {
1518 number: 10,
1519 head: { ref: "feat/one" },
1520 state: "closed",
1521 merged_at: "2026-09-02T00:00:00Z",
1522 },
1523 { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null },
1524 { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null },
1525 ],
1526 ...overrides,
1527 };
1528 }
1529
1530 /** The one stack a listing answered with, which these reads all expect to find. */
1531 function expectStack(overrides: Record<string, unknown> = {}) {
1532 const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)])));
1533 if (decoded === null) throw new Error("expected a stack");
1534 return decoded;
1535 }
1536
1537 it("reads the first stack, bottom to top, with merged_at outranking state", () => {
1538 expect(expectStack()).toEqual({
1539 id: "42",
1540 number: 3,
1541 url: "https://api.github.com/repos/acme/web/stacks/3",
1542 base: "main",
1543 layers: [
1544 { number: 10, headBranch: "feat/one", state: "merged" },
1545 { number: 11, headBranch: "feat/two", state: "open" },
1546 { number: 12, headBranch: "feat/three", state: "closed" },
1547 ],
1548 });
1549 });
1550
1551 it("accepts a base named as a bare branch, which is what the preview started out sending", () => {
1552 expect(expectStack({ base: "develop" }).base).toBe("develop");
1553 });
1554
1555 it("prefers the page a person opens over the API URL, where the host reports one", () => {
1556 expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe(
1557 "https://github.com/acme/web/stacks/3",
1558 );
1559 });
1560
1561 it("falls back to the node id, then the number, for a stack without an id", () => {
1562 expect(expectStack({ id: undefined }).id).toBe("STK_kwDO");
1563 expect(expectStack({ id: null, node_id: null }).id).toBe("3");
1564 });
1565
1566 it("reads an empty listing as not stacked", () => {
1567 expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull();
1568 });
1569
1570 it("refuses a stack without a number or without its pull requests", () => {
1571 expect(
1572 Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))),
1573 ).toBe(false);
1574 expect(
1575 Result.isSuccess(
1576 decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])),
1577 ),
1578 ).toBe(false);
1579 expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false);
1580 });
1581});
apps/server/src/pullRequest/gitHubPullRequestJson.ts+66
...@@ -2445,3 +2445,69 @@ export function decodePullRequestFilesJson(...@@ -2445,3 +2445,69 @@ export function decodePullRequestFilesJson(
2445 omittedFileStats,2445 omittedFileStats,
2446 });2446 });
2447}2447}
2448
2449/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
2450const RawStackPullRequestSchema = Schema.Struct({
2451 number: Schema.Int,
2452 head: Schema.Struct({ ref: Schema.String }),
2453 state: Schema.optional(Schema.NullOr(Schema.String)),
2454 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
2455});
2456
2457/**
2458 * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may
2459 * still move. Only what a stack is made of is required — where it lives, what it stands on, and
2460 * its pull requests — and `base` is accepted both as the ref object the preview sends today and
2461 * as the bare branch name it started out as.
2462 */
2463const RawStackSchema = Schema.Struct({
2464 id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))),
2465 number: Schema.Int,
2466 node_id: Schema.optional(Schema.NullOr(Schema.String)),
2467 url: Schema.String,
2468 html_url: Schema.optional(Schema.NullOr(Schema.String)),
2469 base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]),
2470 pull_requests: Schema.Array(RawStackPullRequestSchema),
2471});
2472
2473const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
2474
2475export interface GitHubPullRequestStackLayer {
2476 readonly number: number;
2477 readonly headBranch: string;
2478 readonly state: PullRequestState;
2479}
2480
2481export interface GitHubPullRequestStack {
2482 readonly id: string;
2483 readonly number: number;
2484 readonly url: string;
2485 readonly base: string;
2486 /** Bottom to top, which is the order GitHub lists them in. */
2487 readonly layers: ReadonlyArray<GitHubPullRequestStackLayer>;
2488}
2489
2490/**
2491 * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in
2492 * at most one stack, so the array is GitHub's way of saying "none" rather than a page.
2493 */
2494export function decodePullRequestStacksJson(
2495 raw: string,
2496): Result.Result<GitHubPullRequestStack | null, DecodeFailure> {
2497 const decoded = decodeStacks(raw);
2498 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2499 const stack = decoded.success[0];
2500 if (stack === undefined) return Result.succeed(null);
2501 return Result.succeed({
2502 id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id),
2503 number: stack.number,
2504 // The page a person opens where the preview reports one; the API URL is what it always has.
2505 url: trimmed(stack.html_url) ?? stack.url,
2506 base: typeof stack.base === "string" ? stack.base : stack.base.ref,
2507 layers: stack.pull_requests.map((pullRequest) => ({
2508 number: pullRequest.number,
2509 headBranch: pullRequest.head.ref,
2510 state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }),
2511 })),
2512 });
2513}
apps/server/src/pullRequest/linkedThreads.test.ts created+117
...@@ -0,0 +1,117 @@
1import { assert, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
6import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
7
8it.effect(
9 "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links",
10 () =>
11 Effect.gen(function* () {
12 const sql = yield* SqlClient.SqlClient;
13 const createdAt = "2026-09-01T00:00:00.000Z";
14 const archivedAt = "2026-09-03T00:00:00.000Z";
15 yield* sql`
16 INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
17 VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt})
18 `;
19 const fixtures = [
20 {
21 id: "azure",
22 host: "dev.azure.com",
23 repository: "org/project/_git/web",
24 number: 7,
25 source: "manual",
26 },
27 {
28 id: "other-org",
29 host: "dev.azure.com",
30 repository: "other/project/_git/web",
31 number: 7,
32 source: "manual",
33 },
34 { id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
35 {
36 id: "archived",
37 host: "github.com",
38 repository: "acme/web",
39 number: 7,
40 source: "created",
41 },
42 { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
43 {
44 id: "dismissed",
45 host: "github.com",
46 repository: "acme/web",
47 number: 7,
48 source: "stack-dismissed",
49 },
50 {
51 id: "other-host",
52 host: "github.example.com",
53 repository: "acme/web",
54 number: 7,
55 source: "manual",
56 },
57 {
58 id: "other-repository",
59 host: "github.com",
60 repository: "acme/api",
61 number: 7,
62 source: "manual",
63 },
64 {
65 id: "other-number",
66 host: "github.com",
67 repository: "acme/web",
68 number: 8,
69 source: "manual",
70 },
71 ];
72 for (const fixture of fixtures) {
73 yield* sql`
74 INSERT INTO projection_threads (
75 thread_id, project_id, title, model_selection_json, created_at, updated_at, archived_at, deleted_at
76 ) VALUES (
77 ${fixture.id}, 'project-1', ${fixture.id}, '{"instanceId":"codex","model":"gpt-5.4"}',
78 ${createdAt}, ${fixture.id === "archived" ? archivedAt : createdAt},
79 ${fixture.id === "archived" ? archivedAt : null},
80 ${fixture.id === "deleted" ? archivedAt : null}
81 )
82 `;
83 yield* sql`
84 INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
85 VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number},
86 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt})
87 `;
88 }
89
90 expect(
91 (yield* listLinkedPullRequestThreads({
92 host: "org.visualstudio.com",
93 repository: "project/_git/web",
94 number: 7,
95 })).threads.map((thread) => thread.id),
96 ).toEqual(["azure"]);
97 const result = yield* listLinkedPullRequestThreads({
98 host: "GitHub.Com",
99 repository: "ACME/WEB",
100 number: 7,
101 });
102 expect(result).toEqual({
103 threads: [
104 { id: "archived", projectId: "project-1", title: "archived", archivedAt },
105 { id: "active", projectId: "project-1", title: "active", archivedAt: null },
106 ],
107 });
108 assert.deepStrictEqual(
109 yield* listLinkedPullRequestThreads({
110 host: "github.com",
111 repository: "acme/web",
112 number: 99,
113 }),
114 { threads: [] },
115 );
116 }).pipe(Effect.provide(SqlitePersistenceMemory)),
117);
apps/server/src/pullRequest/linkedThreads.ts created+39
...@@ -0,0 +1,39 @@
1import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2import {
3 PullRequestLinkedThreadsResult,
4 PullRequestOperationError,
5 type ThreadPullRequestKey,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9import * as SqlClient from "effect/unstable/sql/SqlClient";
10
11const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult);
12
13export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")(
14 function* (input: ThreadPullRequestKey) {
15 const key = normalizeThreadPullRequestKey(input);
16 const sql = yield* SqlClient.SqlClient;
17 const threads = yield* sql`
18 SELECT t.thread_id AS id, t.project_id AS "projectId", t.title,
19 t.archived_at AS "archivedAt"
20 FROM projection_thread_pull_requests AS link
21 JOIN projection_threads AS t ON t.thread_id = link.thread_id
22 WHERE link.host = ${key.host.toLowerCase()}
23 AND link.repository = ${key.repository.toLowerCase()}
24 AND link.number = ${key.number}
25 AND link.source != 'stack-dismissed'
26 AND t.deleted_at IS NULL
27 ORDER BY t.updated_at DESC, t.thread_id ASC
28 `;
29 return yield* decodeLinkedThreads({ threads });
30 },
31 Effect.mapError(
32 (cause) =>
33 new PullRequestOperationError({
34 operation: "linkedThreads",
35 detail: "Could not load linked threads.",
36 cause,
37 }),
38 ),
39);
apps/server/src/pullRequest/pullRequestSyncKey.test.ts created+46
...@@ -0,0 +1,46 @@
1import { ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";
5
6describe("pullRequestSyncKey", () => {
7 const reference = { projectId: ProjectId.make("project"), repository: "web", number: 7 };
8 it.each([
9 "dev.azure.com/org/project/_git/web",
10 "ssh.dev.azure.com/v3/org/project/web",
11 "vs-ssh.visualstudio.com/v3/org/project/web",
12 "org.visualstudio.com/defaultcollection/project/_git/web",
13 ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => {
14 const identity = {
15 canonicalKey,
16 provider: "azure-devops",
17 name: "web",
18 displayName: canonicalKey.split("/").slice(1).join("/"),
19 locator: {
20 source: "git-remote" as const,
21 remoteName: "origin",
22 remoteUrl: `https://${canonicalKey}`,
23 },
24 };
25 const expected = { host: "dev.azure.com", repository: "org/project/_git/web", number: 7 };
26 expect(pullRequestSyncKey(reference, identity)).toEqual(expected);
27 expect(
28 pullRequestSyncKey({ ...reference, host: canonicalKey.split("/")[0]! }, identity),
29 ).toEqual(expected);
30 expect(pullRequestSyncKey({ ...reference, repository: "other" }, identity)).toBeNull();
31 expect(pullRequestSyncKey({ ...reference, host: "unrelated.test" }, identity)).toBeNull();
32 });
33 it("normalizes complete hosted aliases without requiring a checkout", () => {
34 expect(
35 pullRequestSyncKey({
36 ...reference,
37 host: "org.visualstudio.com",
38 repository: "project/_git/web",
39 }),
40 ).toEqual({ host: "dev.azure.com", repository: "org/project/_git/web", number: 7 });
41 expect(
42 pullRequestSyncKey({ ...reference, host: "github.com", repository: "acme/web" }),
43 ).toEqual({ host: "github.com", repository: "acme/web", number: 7 });
44 expect(pullRequestSyncKey(reference)).toBeNull();
45 });
46});
apps/server/src/pullRequest/pullRequestSyncKey.ts created+44
...@@ -0,0 +1,44 @@
1import {
2 pullRequestHostOf,
3 type PullRequestRef,
4 type RepositoryIdentity,
5 type SourceControlProviderKind,
6 type ThreadPullRequestKey,
7} from "@t3tools/contracts";
8import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
9import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
10
11/** Convert checkout-scoped references to the host-level identity used by linked threads. */
12export function pullRequestSyncKey(
13 reference: PullRequestRef,
14 identity?: RepositoryIdentity | null,
15): ThreadPullRequestKey | null {
16 if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) {
17 if (
18 reference.repository.toLowerCase() !==
19 sourceControlRepositorySelector(identity)?.toLowerCase() ||
20 (reference.host !== undefined &&
21 reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops"))
22 )
23 return null;
24 const [host, ...repository] = identity.canonicalKey.split("/");
25 if (!host || repository.length === 0) return null;
26 return normalizeThreadPullRequestKey({
27 host,
28 repository: repository.join("/"),
29 number: reference.number,
30 });
31 }
32 const host =
33 reference.host ??
34 (identity
35 ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind)
36 : undefined);
37 return host === undefined
38 ? null
39 : normalizeThreadPullRequestKey({
40 host,
41 repository: reference.repository,
42 number: reference.number,
43 });
44}
apps/server/src/relay/AgentAwarenessRelay.test.ts+3
...@@ -334,6 +334,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -334,6 +334,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
334 interactionMode: "default",334 interactionMode: "default",
335 branch: null,335 branch: null,
336 worktreePath: null,336 worktreePath: null,
337 pullRequests: [],
337 latestTurn: null,338 latestTurn: null,
338 createdAt: now,339 createdAt: now,
339 updatedAt: now,340 updatedAt: now,
...@@ -477,6 +478,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -477,6 +478,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
477 interactionMode: "default",478 interactionMode: "default",
478 branch: null,479 branch: null,
479 worktreePath: null,480 worktreePath: null,
481 pullRequests: [],
480 latestTurn: {482 latestTurn: {
481 turnId: "turn-1" as TurnId,483 turnId: "turn-1" as TurnId,
482 state: "running",484 state: "running",
...@@ -668,6 +670,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -668,6 +670,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
668 interactionMode: "default",670 interactionMode: "default",
669 branch: null,671 branch: null,
670 worktreePath: null,672 worktreePath: null,
673 pullRequests: [],
671 latestTurn: {674 latestTurn: {
672 turnId: "turn-1" as TurnId,675 turnId: "turn-1" as TurnId,
673 state: "running",676 state: "running",
apps/server/src/server.test.ts+10-1
...@@ -117,6 +117,7 @@ import {...@@ -117,6 +117,7 @@ import {
117} from "./orchestration/Errors.ts";117} from "./orchestration/Errors.ts";
118import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";118import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
119import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";119import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
120import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
120import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";121import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
121import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts";122import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts";
122import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";123import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";
...@@ -331,6 +332,7 @@ const makeDefaultOrchestrationReadModel = () => {...@@ -331,6 +332,7 @@ const makeDefaultOrchestrationReadModel = () => {
331 runtimeMode: "full-access" as const,332 runtimeMode: "full-access" as const,
332 branch: null,333 branch: null,
333 worktreePath: null,334 worktreePath: null,
335 pullRequests: [],
334 createdAt: now,336 createdAt: now,
335 updatedAt: now,337 updatedAt: now,
336 archivedAt: null,338 archivedAt: null,
...@@ -361,6 +363,7 @@ const makeDefaultOrchestrationThreadShell = (...@@ -361,6 +363,7 @@ const makeDefaultOrchestrationThreadShell = (
361 interactionMode: "default",363 interactionMode: "default",
362 branch: null,364 branch: null,
363 worktreePath: null,365 worktreePath: null,
366 pullRequests: [],
364 latestTurn: null,367 latestTurn: null,
365 createdAt: now,368 createdAt: now,
366 updatedAt: now,369 updatedAt: now,
...@@ -384,7 +387,7 @@ const browserOtlpTracingLayer = Layer.mergeAll(...@@ -384,7 +387,7 @@ const browserOtlpTracingLayer = Layer.mergeAll(
384387
385const makeAuthTestLayer = () =>388const makeAuthTestLayer = () =>
386 EnvironmentAuth.layer.pipe(389 EnvironmentAuth.layer.pipe(
387 Layer.provide(SqlitePersistenceMemory),390 Layer.provideMerge(SqlitePersistenceMemory),
388 Layer.provide(ServerSecretStore.layer),391 Layer.provide(ServerSecretStore.layer),
389 Layer.provide(392 Layer.provide(
390 Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({393 Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({
...@@ -956,6 +959,11 @@ const buildAppUnderTest = (options?: {...@@ -956,6 +959,11 @@ const buildAppUnderTest = (options?: {
956 drainThrough: () => Effect.void,959 drainThrough: () => Effect.void,
957 ...options?.layers?.threadDeletionReactor,960 ...options?.layers?.threadDeletionReactor,
958 }),961 }),
962 Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({
963 start: () => Effect.void,
964 drain: Effect.void,
965 requestSync: () => Effect.void,
966 }),
959 ),967 ),
960 ),968 ),
961 Layer.provide(969 Layer.provide(
...@@ -8104,6 +8112,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -8104,6 +8112,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8104 runtimeMode: "full-access" as const,8112 runtimeMode: "full-access" as const,
8105 branch: null,8113 branch: null,
8106 worktreePath: null,8114 worktreePath: null,
8115 pullRequests: [],
8107 createdAt: now,8116 createdAt: now,
8108 updatedAt: now,8117 updatedAt: now,
8109 archivedAt: null,8118 archivedAt: null,
apps/server/src/server.ts+2
...@@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma...@@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
71import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";71import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
72import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";72import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
73import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";73import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
74import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
74import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";75import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
75import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";76import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
76import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";77import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
...@@ -280,6 +281,7 @@ const ReactorLayerLive = Layer.empty.pipe(...@@ -280,6 +281,7 @@ const ReactorLayerLive = Layer.empty.pipe(
280 Layer.provideMerge(CheckpointReactorLive),281 Layer.provideMerge(CheckpointReactorLive),
281 Layer.provideMerge(ThreadDeletionReactorLive),282 Layer.provideMerge(ThreadDeletionReactorLive),
282 Layer.provideMerge(ThreadSettlementReactor.layer),283 Layer.provideMerge(ThreadSettlementReactor.layer),
284 Layer.provideMerge(PullRequestSyncReactor.layer),
283 Layer.provideMerge(ThreadPullRequestReactor.layer),285 Layer.provideMerge(ThreadPullRequestReactor.layer),
284 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),286 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
285 Layer.provideMerge(RuntimeReceiptBusLive),287 Layer.provideMerge(RuntimeReceiptBusLive),
apps/server/src/ws.ts+89-8
...@@ -70,6 +70,7 @@ import {...@@ -70,6 +70,7 @@ import {
70 type TerminalError,70 type TerminalError,
71 type TerminalEvent,71 type TerminalEvent,
72 type TerminalMetadataStreamEvent,72 type TerminalMetadataStreamEvent,
73 type PullRequestRef,
73 WS_METHODS,74 WS_METHODS,
74 WsRpcGroup,75 WsRpcGroup,
75} from "@t3tools/contracts";76} from "@t3tools/contracts";
...@@ -124,6 +125,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";...@@ -124,6 +125,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
124import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";125import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
125import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";126import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
126import * as GitWorkflowService from "./git/GitWorkflowService.ts";127import * as GitWorkflowService from "./git/GitWorkflowService.ts";
128import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
127import * as ReviewService from "./review/ReviewService.ts";129import * as ReviewService from "./review/ReviewService.ts";
128import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";130import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
129import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";131import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
...@@ -142,6 +144,10 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts";...@@ -142,6 +144,10 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
142import * as UsageService from "./usage/UsageService.ts";144import * as UsageService from "./usage/UsageService.ts";
143import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";145import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
144import * as PullRequestService from "./pullRequest/PullRequestService.ts";146import * as PullRequestService from "./pullRequest/PullRequestService.ts";
147import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts";
148import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts";
149import * as SqlClient from "effect/unstable/sql/SqlClient";
150import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
145import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";151import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
146import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";152import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
147import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";153import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
...@@ -476,7 +482,18 @@ const makeWsRpcLayer = (...@@ -476,7 +482,18 @@ const makeWsRpcLayer = (
476 Effect.gen(function* () {482 Effect.gen(function* () {
477 const currentSessionId = currentSession.sessionId;483 const currentSessionId = currentSession.sessionId;
478 const crypto = yield* Crypto.Crypto;484 const crypto = yield* Crypto.Crypto;
485 const sql = yield* SqlClient.SqlClient;
479 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;486 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
487 /** A reference's host-level link key; the project's own host where the ref names none. */
488 const resolvePullRequestSyncKey = (reference: PullRequestRef) =>
489 reference.host !== undefined && reference.repository.includes("/")
490 ? Effect.succeed(pullRequestSyncKey(reference))
491 : projectionSnapshotQuery.getProjectShellById(reference.projectId).pipe(
492 Effect.map((project) =>
493 pullRequestSyncKey(reference, Option.getOrUndefined(project)?.repositoryIdentity),
494 ),
495 Effect.orElseSucceed(() => null),
496 );
480 const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService;497 const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService;
481 const threadDeletionReactor = yield* ThreadDeletionReactor;498 const threadDeletionReactor = yield* ThreadDeletionReactor;
482 const analytics = yield* AnalyticsService.AnalyticsService;499 const analytics = yield* AnalyticsService.AnalyticsService;
...@@ -604,6 +621,7 @@ const makeWsRpcLayer = (...@@ -604,6 +621,7 @@ const makeWsRpcLayer = (
604 const sourceControlRepositories =621 const sourceControlRepositories =
605 yield* SourceControlRepositoryService.SourceControlRepositoryService;622 yield* SourceControlRepositoryService.SourceControlRepositoryService;
606 const pullRequests = yield* PullRequestService.PullRequestService;623 const pullRequests = yield* PullRequestService.PullRequestService;
624 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
607 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;625 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
608 const sessions = yield* SessionStore.SessionStore;626 const sessions = yield* SessionStore.SessionStore;
609 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;627 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
...@@ -2133,6 +2151,24 @@ const makeWsRpcLayer = (...@@ -2133,6 +2151,24 @@ const makeWsRpcLayer = (
2133 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {2151 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
2134 "rpc.aggregate": "pull-requests",2152 "rpc.aggregate": "pull-requests",
2135 }),2153 }),
2154 [WS_METHODS.pullRequestsStack]: (input) =>
2155 observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), {
2156 "rpc.aggregate": "pull-requests",
2157 }),
2158 [WS_METHODS.pullRequestsLinkedThreads]: (input) =>
2159 observeRpcEffect(
2160 WS_METHODS.pullRequestsLinkedThreads,
2161 resolvePullRequestSyncKey(input).pipe(
2162 Effect.flatMap((key) =>
2163 key === null
2164 ? Effect.succeed({ threads: [] })
2165 : listLinkedPullRequestThreads(key).pipe(
2166 Effect.provideService(SqlClient.SqlClient, sql),
2167 ),
2168 ),
2169 ),
2170 { "rpc.aggregate": "pull-requests" },
2171 ),
2136 [WS_METHODS.pullRequestsDetail]: (input) =>2172 [WS_METHODS.pullRequestsDetail]: (input) =>
2137 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {2173 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
2138 "rpc.aggregate": "pull-requests",2174 "rpc.aggregate": "pull-requests",
...@@ -2156,9 +2192,21 @@ const makeWsRpcLayer = (...@@ -2156,9 +2192,21 @@ const makeWsRpcLayer = (
2156 { "rpc.aggregate": "pull-requests" },2192 { "rpc.aggregate": "pull-requests" },
2157 ),2193 ),
2158 [WS_METHODS.pullRequestsRunAction]: (input) =>2194 [WS_METHODS.pullRequestsRunAction]: (input) =>
2159 observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), {2195 observeRpcEffect(
2160 "rpc.aggregate": "pull-requests",2196 WS_METHODS.pullRequestsRunAction,
2161 }),2197 pullRequests
2198 .runAction(input)
2199 .pipe(
2200 Effect.tap(() =>
2201 resolvePullRequestSyncKey(input).pipe(
2202 Effect.flatMap((key) =>
2203 key === null ? Effect.void : pullRequestSync.requestSync(key),
2204 ),
2205 ),
2206 ),
2207 ),
2208 { "rpc.aggregate": "pull-requests" },
2209 ),
2162 [WS_METHODS.pullRequestsUpdate]: (input) =>2210 [WS_METHODS.pullRequestsUpdate]: (input) =>
2163 observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), {2211 observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), {
2164 "rpc.aggregate": "pull-requests",2212 "rpc.aggregate": "pull-requests",
...@@ -2196,9 +2244,23 @@ const makeWsRpcLayer = (...@@ -2196,9 +2244,23 @@ const makeWsRpcLayer = (
2196 "rpc.aggregate": "pull-requests",2244 "rpc.aggregate": "pull-requests",
2197 }),2245 }),
2198 [WS_METHODS.pullRequestsInvalidate]: (input) =>2246 [WS_METHODS.pullRequestsInvalidate]: (input) =>
2199 observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {2247 observeRpcEffect(
2200 "rpc.aggregate": "pull-requests",2248 WS_METHODS.pullRequestsInvalidate,
2201 }),2249 pullRequests.invalidate(input).pipe(
2250 // A reader asking for fresh host state also wants the thread badges it feeds to
2251 // catch up, including a merged link the sweep would otherwise never revisit.
2252 Effect.andThen(
2253 input.reference === undefined
2254 ? Effect.void
2255 : resolvePullRequestSyncKey(input.reference).pipe(
2256 Effect.flatMap((key) =>
2257 key === null ? Effect.void : pullRequestSync.requestSync(key),
2258 ),
2259 ),
2260 ),
2261 ),
2262 { "rpc.aggregate": "pull-requests" },
2263 ),
2202 [WS_METHODS.pullRequestsSubscribeRefreshes]: () =>2264 [WS_METHODS.pullRequestsSubscribeRefreshes]: () =>
2203 observeRpcStream(2265 observeRpcStream(
2204 WS_METHODS.pullRequestsSubscribeRefreshes,2266 WS_METHODS.pullRequestsSubscribeRefreshes,
...@@ -2507,8 +2569,25 @@ const makeWsRpcLayer = (...@@ -2507,8 +2569,25 @@ const makeWsRpcLayer = (
2507 .pipe(2569 .pipe(
2508 Effect.matchCauseEffect({2570 Effect.matchCauseEffect({
2509 onFailure: (cause) => Queue.failCause(queue, cause),2571 onFailure: (cause) => Queue.failCause(queue, cause),
2510 onSuccess: () =>2572 onSuccess: (result) =>
2511 refreshGitStatus(input.cwd).pipe(2573 (input.threadId === undefined
2574 ? Effect.void
2575 : linkCreatedPullRequest({
2576 threadId: input.threadId,
2577 result,
2578 commandId: serverCommandId("pr-created-link"),
2579 }).pipe(
2580 Effect.provideService(
2581 OrchestrationEngine.OrchestrationEngineService,
2582 orchestrationEngine,
2583 ),
2584 Effect.provideService(
2585 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
2586 projectionSnapshotQuery,
2587 ),
2588 )
2589 ).pipe(
2590 Effect.andThen(refreshGitStatus(input.cwd)),
2512 Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),2591 Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),
2513 ),2592 ),
2514 }),2593 }),
...@@ -2932,6 +3011,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -2932,6 +3011,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
2932 ),3011 ),
2933 });3012 });
2934 const pullRequests = yield* PullRequestService.PullRequestService;3013 const pullRequests = yield* PullRequestService.PullRequestService;
3014 const sql = yield* SqlClient.SqlClient;
2935 return HttpRouter.add(3015 return HttpRouter.add(
2936 "GET",3016 "GET",
2937 "/ws",3017 "/ws",
...@@ -2966,6 +3046,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -2966,6 +3046,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
2966 previewAutomationBroker,3046 previewAutomationBroker,
2967 ).pipe(3047 ).pipe(
2968 Layer.provideMerge(RpcSerialization.layerJson),3048 Layer.provideMerge(RpcSerialization.layerJson),
3049 Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),
2969 Layer.provide(AgentSessionScanner.layer),3050 Layer.provide(AgentSessionScanner.layer),
2970 Layer.provide(ProviderMaintenanceRunner.layer),3051 Layer.provide(ProviderMaintenanceRunner.layer),
2971 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),3052 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
apps/web/src/components/ChatMarkdown.test.tsx+2-2
...@@ -43,6 +43,7 @@ vi.mock("../state/session", async (importOriginal) => ({...@@ -43,6 +43,7 @@ vi.mock("../state/session", async (importOriginal) => ({
43vi.mock("../state/entities", () => ({43vi.mock("../state/entities", () => ({
44 readThreadShell: () => null,44 readThreadShell: () => null,
45 useProjects: () => [],45 useProjects: () => [],
46 useServerConfigs: () => new Map(),
46}));47}));
47vi.mock("../remoteOpen", () => ({48vi.mock("../remoteOpen", () => ({
48 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),49 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
...@@ -52,8 +53,7 @@ vi.mock("../editorPreferences", () => ({...@@ -52,8 +53,7 @@ vi.mock("../editorPreferences", () => ({
52 usePreferredEditor: () => [null, vi.fn()],53 usePreferredEditor: () => [null, vi.fn()],
53}));54}));
54vi.mock("~/lib/openPullRequestLink", () => ({55vi.mock("~/lib/openPullRequestLink", () => ({
55 findProjectForChangeRequest: () => undefined,56 findProjectOnChangeRequestHost: () => undefined,
56 matchesLinkedPullRequestUrl: () => false,
57 parseChangeRequestUrl: () => null,57 parseChangeRequestUrl: () => null,
58 useOpenChangeRequestLink: () => vi.fn(),58 useOpenChangeRequestLink: () => vi.fn(),
59}));59}));
apps/web/src/components/ChatMarkdown.tsx+25-50
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import {3import {
3 CheckIcon,4 CheckIcon,
...@@ -26,7 +27,7 @@ import type {...@@ -26,7 +27,7 @@ import type {
26 EnvironmentId,27 EnvironmentId,
27 ScopedThreadRef,28 ScopedThreadRef,
28 ServerProviderSkill,29 ServerProviderSkill,
29 ThreadLinkedPullRequest,30 ThreadPullRequestKey,
30} from "@t3tools/contracts";31} from "@t3tools/contracts";
31import { faviconUrlForOrigin } from "@t3tools/shared/favicon";32import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
32import {33import {
...@@ -155,7 +156,6 @@ import { previewEnvironment } from "../state/preview";...@@ -155,7 +156,6 @@ import { previewEnvironment } from "../state/preview";
155import { useAtomCommand } from "../state/use-atom-command";156import { useAtomCommand } from "../state/use-atom-command";
156import { useAtomQueryRunner } from "../state/use-atom-query-runner";157import { useAtomQueryRunner } from "../state/use-atom-query-runner";
157import { projectEnvironment } from "../state/projects";158import { projectEnvironment } from "../state/projects";
158import { threadEnvironment } from "../state/threads";
159import {159import {
160 claimWorkspaceBasenameLookup,160 claimWorkspaceBasenameLookup,
161 needsWorkspaceBasenameLookup,161 needsWorkspaceBasenameLookup,
...@@ -164,7 +164,6 @@ import {...@@ -164,7 +164,6 @@ import {
164} from "../workspaceBasenameLookup";164} from "../workspaceBasenameLookup";
165import {165import {
166 findProjectForChangeRequest,166 findProjectForChangeRequest,
167 matchesLinkedPullRequestUrl,
168 parseChangeRequestUrl,167 parseChangeRequestUrl,
169 pullRequestCandidateUrlFromReferenceAutolink,168 pullRequestCandidateUrlFromReferenceAutolink,
170 useOpenChangeRequestLink,169 useOpenChangeRequestLink,
...@@ -2185,9 +2184,7 @@ function useChatMarkdownState({...@@ -2185,9 +2184,7 @@ function useChatMarkdownState({
2185 const openPreview = useAtomCommand(previewEnvironment.open, {2184 const openPreview = useAtomCommand(previewEnvironment.open, {
2186 reportFailure: false,2185 reportFailure: false,
2187 });2186 });
2188 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {2187 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
2189 reportFailure: false,
2190 });
2191 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;2188 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
2192 const remoteOpen = useRemoteOpenResolution(environmentId);2189 const remoteOpen = useRemoteOpenResolution(environmentId);
2193 const canUseShellActions = canUseMarkdownFileShellActions(2190 const canUseShellActions = canUseMarkdownFileShellActions(
...@@ -2239,9 +2236,6 @@ function useChatMarkdownState({...@@ -2239,9 +2236,6 @@ function useChatMarkdownState({
2239 [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef],2236 [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef],
2240 );2237 );
2241 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));2238 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
2242 const threadServerConfig = useAtomValue(
2243 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
2244 );
2245 const projects = useProjects();2239 const projects = useProjects();
2246 const availableEditors = serverConfig?.availableEditors ?? [];2240 const availableEditors = serverConfig?.availableEditors ?? [];
2247 const [preferredEditor] = usePreferredEditor(availableEditors);2241 const [preferredEditor] = usePreferredEditor(availableEditors);
...@@ -2331,52 +2325,33 @@ function useChatMarkdownState({...@@ -2331,52 +2325,33 @@ function useChatMarkdownState({
2331 // makes a persisted "app" apply once settings hydrate after launch.2325 // makes a persisted "app" apply once settings hydrate after launch.
2332 const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget);2326 const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget);
2333 const resolveThreadPullRequest = useCallback(2327 const resolveThreadPullRequest = useCallback(
2334 (href: string): ThreadLinkedPullRequest | null => {2328 (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => {
2335 if (2329 if (
2336 threadRef === undefined ||2330 threadRef === undefined ||
2337 readThreadShell(threadRef) === null ||2331 readThreadShell(threadRef) === null ||
2338 threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true2332 !pullRequestLinking.canLink(href)
2339 ) {2333 )
2340 return null;2334 return null;
2341 }
2342 const parsed = parseChangeRequestUrl(href);2335 const parsed = parseChangeRequestUrl(href);
2343 if (parsed === null) return null;2336 return parsed === null ? null : { ...parsed, url: href };
2344 const project = findProjectForChangeRequest(
2345 projects.filter((candidate) => candidate.environmentId === threadRef.environmentId),
2346 parsed,
2347 );
2348 if (project === undefined) return null;
2349 return {
2350 projectId: project.id,
2351 repository: project.repositoryIdentity?.displayName ?? parsed.repository,
2352 number: parsed.number,
2353 url: href,
2354 };
2355 },2337 },
2356 [projects, threadRef, threadServerConfig],2338 [pullRequestLinking, threadRef],
2339 );
2340 const linkedThreadPullRequestFor = useCallback(
2341 (href: string) => {
2342 if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href))
2343 return null;
2344 const parsed = parseChangeRequestUrl(href);
2345 return parsed === null ? null : { ...parsed, url: href };
2346 },
2347 [pullRequestLinking, threadRef],
2357 );2348 );
2358 const updateThreadPullRequestLink = useCallback(2349 const updateThreadPullRequestLink = useCallback(
2359 async (href: string, linked: boolean) => {2350 async (href: string, linked: boolean) => {
2360 if (threadRef === undefined) return;2351 if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return;
2361 const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null;2352 await pullRequestLinking.changeLink(threadRef, href, linked);
2362 if (linked && linkedPullRequest === null) {
2363 throw new Error("The pull request is not available in this environment.");
2364 }
2365 if (!linked) {
2366 const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest;
2367 if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) {
2368 return;
2369 }
2370 }
2371 const result = await updateThreadMetadata({
2372 environmentId: threadRef.environmentId,
2373 input: { threadId: threadRef.threadId, linkedPullRequest },
2374 });
2375 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2376 throw squashAtomCommandFailure(result);
2377 }
2378 },2353 },
2379 [resolveThreadPullRequest, threadRef, updateThreadMetadata],2354 [linkedThreadPullRequestFor, pullRequestLinking, threadRef],
2380 );2355 );
2381 const openExternalLinkInPreview = useCallback(2356 const openExternalLinkInPreview = useCallback(
2382 (url: string) => {2357 (url: string) => {
...@@ -2588,6 +2563,7 @@ function useChatMarkdownState({...@@ -2588,6 +2563,7 @@ function useChatMarkdownState({
2588 openExternalLinkInPreview,2563 openExternalLinkInPreview,
2589 openMarkdownMedia,2564 openMarkdownMedia,
2590 projects,2565 projects,
2566 linkedThreadPullRequestFor,
2591 resolveThreadPullRequest,2567 resolveThreadPullRequest,
2592 resolvedTheme,2568 resolvedTheme,
2593 serverConfig,2569 serverConfig,
...@@ -2614,6 +2590,7 @@ function useChatMarkdownState({...@@ -2614,6 +2590,7 @@ function useChatMarkdownState({
2614 openExternalLinkInPreview,2590 openExternalLinkInPreview,
2615 openMarkdownMedia,2591 openMarkdownMedia,
2616 projects,2592 projects,
2593 linkedThreadPullRequestFor,
2617 resolveThreadPullRequest,2594 resolveThreadPullRequest,
2618 resolvedTheme,2595 resolvedTheme,
2619 serverConfig,2596 serverConfig,
...@@ -2732,6 +2709,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2732,6 +2709,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
2732 linkTargetPreference,2709 linkTargetPreference,
2733 openExternalLinkInPreview,2710 openExternalLinkInPreview,
2734 projects,2711 projects,
2712 linkedThreadPullRequestFor,
2735 resolveThreadPullRequest,2713 resolveThreadPullRequest,
2736 serverConfig,2714 serverConfig,
2737 updateThreadPullRequestLink,2715 updateThreadPullRequestLink,
...@@ -2866,13 +2844,10 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2866,13 +2844,10 @@ const CHAT_MARKDOWN_COMPONENTS = {
2866 event.stopPropagation();2844 event.stopPropagation();
2867 const api = readLocalApi();2845 const api = readLocalApi();
2868 if (!api) return;2846 if (!api) return;
2869 const pullRequest = resolveThreadPullRequest(href);
2870 const currentPullRequest =
2871 threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
2872 const threadLinkAction =2847 const threadLinkAction =
2873 currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href)2848 linkedThreadPullRequestFor(href) !== null
2874 ? "unlink-from-thread"2849 ? "unlink-from-thread"
2875 : pullRequest === null2850 : resolveThreadPullRequest(href) === null
2876 ? undefined2851 ? undefined
2877 : "link-to-thread";2852 : "link-to-thread";
2878 void showExternalLinkContextMenu({2853 void showExternalLinkContextMenu({
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+2-2
...@@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({...@@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({
32vi.mock("../state/entities", () => ({32vi.mock("../state/entities", () => ({
33 readThreadShell: () => null,33 readThreadShell: () => null,
34 useProjects: () => [],34 useProjects: () => [],
35 useServerConfigs: () => new Map(),
35}));36}));
36vi.mock("../remoteOpen", () => ({37vi.mock("../remoteOpen", () => ({
37 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),38 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
...@@ -41,8 +42,7 @@ vi.mock("../editorPreferences", () => ({...@@ -41,8 +42,7 @@ vi.mock("../editorPreferences", () => ({
41 usePreferredEditor: () => [null, vi.fn()],42 usePreferredEditor: () => [null, vi.fn()],
42}));43}));
43vi.mock("~/lib/openPullRequestLink", () => ({44vi.mock("~/lib/openPullRequestLink", () => ({
44 findProjectForChangeRequest: () => undefined,45 findProjectOnChangeRequestHost: () => undefined,
45 matchesLinkedPullRequestUrl: () => false,
46 parseChangeRequestUrl: () => null,46 parseChangeRequestUrl: () => null,
47 useOpenChangeRequestLink: () => vi.fn(),47 useOpenChangeRequestLink: () => vi.fn(),
48}));48}));
apps/web/src/components/ChatView.logic.test.ts+2
...@@ -726,6 +726,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -726,6 +726,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
726 proposedPlans: [],726 proposedPlans: [],
727 activities: [],727 activities: [],
728 checkpoints: [],728 checkpoints: [],
729 pullRequests: [],
729 createdAt: now,730 createdAt: now,
730 updatedAt: now,731 updatedAt: now,
731 archivedAt: null,732 archivedAt: null,
...@@ -843,6 +844,7 @@ describe("buildLoadingThreadFromShell", () => {...@@ -843,6 +844,7 @@ describe("buildLoadingThreadFromShell", () => {
843 snoozedUntil: null,844 snoozedUntil: null,
844 snoozedAt: null,845 snoozedAt: null,
845 session: null,846 session: null,
847 pullRequests: [],
846 latestUserMessageAt: now,848 latestUserMessageAt: now,
847 hasPendingApprovals: false,849 hasPendingApprovals: false,
848 hasPendingUserInput: false,850 hasPendingUserInput: false,
apps/web/src/components/ChatView.logic.ts+1
...@@ -353,6 +353,7 @@ export function buildLocalDraftThread(...@@ -353,6 +353,7 @@ export function buildLocalDraftThread(
353 branch: draftThread.branch,353 branch: draftThread.branch,
354 worktreePath: draftThread.worktreePath,354 worktreePath: draftThread.worktreePath,
355 checkpoints: [],355 checkpoints: [],
356 pullRequests: [],
356 activities: [],357 activities: [],
357 proposedPlans: [],358 proposedPlans: [],
358 };359 };
apps/web/src/components/ChatView.tsx+30-2
...@@ -203,6 +203,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";...@@ -203,6 +203,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
203import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";203import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
204import { RightPanelTabs } from "./RightPanelTabs";204import { RightPanelTabs } from "./RightPanelTabs";
205import { AgentsPanel } from "./AgentsPanel";205import { AgentsPanel } from "./AgentsPanel";
206import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";
207import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel";
206import {208import {
207 deriveAgentPanelModel,209 deriveAgentPanelModel,
208 foldSubagentActivities,210 foldSubagentActivities,
...@@ -4127,6 +4129,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -4127,6 +4129,12 @@ export default function ChatView(props: ChatViewProps) {
4127 if (!activeThreadRef) return;4129 if (!activeThreadRef) return;
4128 useRightPanelStore.getState().open(activeThreadRef, "agents");4130 useRightPanelStore.getState().open(activeThreadRef, "agents");
4129 }, [activeThreadRef]);4131 }, [activeThreadRef]);
4132 const supportsThreadPullRequests =
4133 serverConfig?.environment.capabilities.threadPullRequests === true;
4134 const addPullRequestsSurface = useCallback(() => {
4135 if (!activeThreadRef || !supportsThreadPullRequests) return;
4136 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
4137 }, [activeThreadRef, supportsThreadPullRequests]);
4130 const openFileSurface = useCallback(4138 const openFileSurface = useCallback(
4131 (relativePath: string) => {4139 (relativePath: string) => {
4132 if (!activeThreadRef || !activeProject) return;4140 if (!activeThreadRef || !activeProject) return;
...@@ -5375,9 +5383,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -5375,9 +5383,16 @@ export default function ChatView(props: ChatViewProps) {
5375 : resolveThreadReferenceCopyTarget({5383 : resolveThreadReferenceCopyTarget({
5376 threadId: activeThreadId,5384 threadId: activeThreadId,
5377 openPanelPullRequestUrl,5385 openPanelPullRequestUrl,
5386 pullRequests: activeThreadMetadata?.pullRequests,
5378 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,5387 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
5379 }),5388 }),
5380 [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl],5389 [
5390 activeThreadId,
5391 isServerThread,
5392 activeThreadMetadata?.pullRequests,
5393 linkedThreadPullRequest?.url,
5394 openPanelPullRequestUrl,
5395 ],
5381 );5396 );
5382 const copyActiveThreadReference = useCallback(() => {5397 const copyActiveThreadReference = useCallback(() => {
5383 const target = activeThreadReferenceCopyTarget;5398 const target = activeThreadReferenceCopyTarget;
...@@ -8037,11 +8052,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -8037,11 +8052,12 @@ export default function ChatView(props: ChatViewProps) {
8037 // reader's feet. A link the agent wrote can open any other one here, and that one has to be8052 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
8038 // checkable out like it is anywhere else.8053 // checkable out like it is anywhere else.
8039 <PullRequestDetailPanel8054 <PullRequestDetailPanel
8040 key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}8055 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
8041 environmentId={activeThread.environmentId}8056 environmentId={activeThread.environmentId}
8042 threadRef={activeThreadRef}8057 threadRef={activeThreadRef}
8043 reference={{8058 reference={{
8044 projectId: renderedRightPanelSurface.projectId as ProjectId,8059 projectId: renderedRightPanelSurface.projectId as ProjectId,
8060 ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}),
8045 repository: renderedRightPanelSurface.repository,8061 repository: renderedRightPanelSurface.repository,
8046 number: renderedRightPanelSurface.number,8062 number: renderedRightPanelSurface.number,
8047 }}8063 }}
...@@ -8062,7 +8078,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -8062,7 +8078,14 @@ export default function ChatView(props: ChatViewProps) {
8062 : "page"8078 : "page"
8063 }8079 }
8064 composerDraftTarget={composerDraftTarget}8080 composerDraftTarget={composerDraftTarget}
8081 onBack={
8082 activeThreadRef !== null && supportsThreadPullRequests
8083 ? addPullRequestsSurface
8084 : undefined
8085 }
8065 />8086 />
8087 ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? (
8088 <ThreadPullRequestsPanel threadRef={activeThreadRef} />
8066 ) : renderedRightPanelSurface?.kind === "agents" ? (8089 ) : renderedRightPanelSurface?.kind === "agents" ? (
8067 <AgentsPanel8090 <AgentsPanel
8068 model={agentPanelModel}8091 model={agentPanelModel}
...@@ -8620,12 +8643,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -8620,12 +8643,14 @@ export default function ChatView(props: ChatViewProps) {
8620 onAddDiff={addDiffSurface}8643 onAddDiff={addDiffSurface}
8621 onAddFiles={addFilesSurface}8644 onAddFiles={addFilesSurface}
8622 onAddPullRequest={addPullRequestSurface}8645 onAddPullRequest={addPullRequestSurface}
8646 onAddPullRequests={addPullRequestsSurface}
8623 onAddAgents={addAgentsSurface}8647 onAddAgents={addAgentsSurface}
8624 browserAvailable={isPreviewSupportedInRuntime()}8648 browserAvailable={isPreviewSupportedInRuntime()}
8625 terminalAvailable={activeProject !== null}8649 terminalAvailable={activeProject !== null}
8626 diffAvailable={isServerThread && isGitRepo}8650 diffAvailable={isServerThread && isGitRepo}
8627 filesAvailable={activeProject !== null}8651 filesAvailable={activeProject !== null}
8628 pullRequestAvailable={pullRequestSurfaceAvailable}8652 pullRequestAvailable={pullRequestSurfaceAvailable}
8653 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
8629 agentsAvailable8654 agentsAvailable
8630 liveAgentCount={agentPanelModel.liveCount}8655 liveAgentCount={agentPanelModel.liveCount}
8631 >8656 >
...@@ -8670,12 +8695,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -8670,12 +8695,14 @@ export default function ChatView(props: ChatViewProps) {
8670 onAddDiff={addDiffSurface}8695 onAddDiff={addDiffSurface}
8671 onAddFiles={addFilesSurface}8696 onAddFiles={addFilesSurface}
8672 onAddPullRequest={addPullRequestSurface}8697 onAddPullRequest={addPullRequestSurface}
8698 onAddPullRequests={addPullRequestsSurface}
8673 onAddAgents={addAgentsSurface}8699 onAddAgents={addAgentsSurface}
8674 browserAvailable={isPreviewSupportedInRuntime()}8700 browserAvailable={isPreviewSupportedInRuntime()}
8675 terminalAvailable={activeProject !== null}8701 terminalAvailable={activeProject !== null}
8676 diffAvailable={isServerThread && isGitRepo}8702 diffAvailable={isServerThread && isGitRepo}
8677 filesAvailable={activeProject !== null}8703 filesAvailable={activeProject !== null}
8678 pullRequestAvailable={pullRequestSurfaceAvailable}8704 pullRequestAvailable={pullRequestSurfaceAvailable}
8705 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
8679 agentsAvailable8706 agentsAvailable
8680 liveAgentCount={agentPanelModel.liveCount}8707 liveAgentCount={agentPanelModel.liveCount}
8681 >8708 >
...@@ -8684,6 +8711,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8684,6 +8711,7 @@ export default function ChatView(props: ChatViewProps) {
8684 </RightPanelSheet>8711 </RightPanelSheet>
8685 ) : null}8712 ) : null}
86868713
8714 <LinkPullRequestDialogHost />
8687 {expandedImage && (8715 {expandedImage && (
8688 <ExpandedImageDialog8716 <ExpandedImageDialog
8689 key={expandedImageKey(expandedImage)}8717 key={expandedImageKey(expandedImage)}
apps/web/src/components/CommandPalette.logic.test.ts+1
...@@ -149,6 +149,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -149,6 +149,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
149 branch: null,149 branch: null,
150 worktreePath: null,150 worktreePath: null,
151 checkpoints: [],151 checkpoints: [],
152 pullRequests: [],
152 activities: [],153 activities: [],
153 ...overrides,154 ...overrides,
154 };155 };
apps/web/src/components/CommandPalette.tsx+38-1
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1"use client";1"use client";
22
3import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
4
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";5import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
4import {6import {
5 canCreateProjectInEnvironment,7 canCreateProjectInEnvironment,
...@@ -43,6 +45,7 @@ import {...@@ -43,6 +45,7 @@ import {
43 FileSearchIcon,45 FileSearchIcon,
44 FolderIcon,46 FolderIcon,
45 FolderPlusIcon,47 FolderPlusIcon,
48 GitPullRequestArrowIcon,
46 LinkIcon,49 LinkIcon,
47 MessageSquareIcon,50 MessageSquareIcon,
48 PaletteIcon,51 PaletteIcon,
...@@ -80,7 +83,7 @@ import { sourceControlEnvironment } from "../state/sourceControl";...@@ -80,7 +83,7 @@ import { sourceControlEnvironment } from "../state/sourceControl";
80import { useAtomCommand } from "../state/use-atom-command";83import { useAtomCommand } from "../state/use-atom-command";
81import { useAtomQueryRunner } from "../state/use-atom-query-runner";84import { useAtomQueryRunner } from "../state/use-atom-query-runner";
82import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";85import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
83import { useProjects, useThreadShells } from "../state/entities";86import { useProjects, useServerConfigs, useThreadShells } from "../state/entities";
84import { useThreadSearch } from "../state/queries";87import { useThreadSearch } from "../state/queries";
85import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";88import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
86import {89import {
...@@ -148,6 +151,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"...@@ -148,6 +151,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"
148import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";151import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
149import { ProjectFavicon } from "./ProjectFavicon";152import { ProjectFavicon } from "./ProjectFavicon";
150import { ProjectFilePicker } from "./files/ProjectFilePicker";153import { ProjectFilePicker } from "./files/ProjectFilePicker";
154import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
151import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";155import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
152import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";156import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
153import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";157import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
...@@ -605,12 +609,16 @@ function OpenCommandPaletteDialog(props: {...@@ -605,12 +609,16 @@ function OpenCommandPaletteDialog(props: {
605 ? scopeThreadRef(activeThread.environmentId, activeThread.id)609 ? scopeThreadRef(activeThread.environmentId, activeThread.id)
606 : null;610 : null;
607 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef);611 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef);
612 const activeThreadServerConfig = useServerConfigs().get(
613 activeThread?.environmentId ?? ("" as EnvironmentId),
614 );
608 const activeThreadReferenceCopyTarget =615 const activeThreadReferenceCopyTarget =
609 referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)616 referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)
610 ? null617 ? null
611 : resolveThreadReferenceCopyTarget({618 : resolveThreadReferenceCopyTarget({
612 threadId: referenceThreadRef.threadId,619 threadId: referenceThreadRef.threadId,
613 openPanelPullRequestUrl,620 openPanelPullRequestUrl,
621 pullRequests: activeThread?.pullRequests,
614 linkedPullRequestUrl:622 linkedPullRequestUrl:
615 activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,623 activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,
616 });624 });
...@@ -1591,6 +1599,35 @@ function OpenCommandPaletteDialog(props: {...@@ -1591,6 +1599,35 @@ function OpenCommandPaletteDialog(props: {
1591 });1599 });
1592 }1600 }
15931601
1602 if (
1603 activeThread !== null &&
1604 threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported"
1605 ) {
1606 const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
1607 actionItems.push({
1608 kind: "action",
1609 value: "action:link-pull-request",
1610 searchTerms: ["link", "pull request", "pr", "attach", "stack"],
1611 title: "Link pull request to thread",
1612 icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,
1613 run: async () => {
1614 openLinkPullRequestDialog(threadRef);
1615 },
1616 });
1617 if (activeThreadServerConfig?.environment.capabilities.threadPullRequests === true) {
1618 actionItems.push({
1619 kind: "action",
1620 value: "action:open-thread-pull-requests",
1621 searchTerms: ["pull requests", "linked", "stack", "prs"],
1622 title: "Show linked pull requests",
1623 icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,
1624 run: async () => {
1625 useRightPanelStore.getState().open(threadRef, "pull-requests");
1626 },
1627 });
1628 }
1629 }
1630
1594 actionItems.push({1631 actionItems.push({
1595 kind: "action",1632 kind: "action",
1596 value: "action:open-file-picker",1633 value: "action:open-file-picker",
apps/web/src/components/GitActionsControl.tsx+3
...@@ -1351,6 +1351,9 @@ export default function GitActionsControl({...@@ -1351,6 +1351,9 @@ export default function GitActionsControl({
1351 ...(commitMessage ? { commitMessage } : {}),1351 ...(commitMessage ? { commitMessage } : {}),
1352 ...(featureBranch ? { featureBranch } : {}),1352 ...(featureBranch ? { featureBranch } : {}),
1353 ...(filePaths ? { filePaths } : {}),1353 ...(filePaths ? { filePaths } : {}),
1354 // A pull request the action opens is linked to the thread it ran beside. Drafts
1355 // have no server thread yet, so there is nothing to link to.
1356 ...(activeServerThread ? { threadId: activeServerThread.id } : {}),
1354 onProgress: applyProgressEvent,1357 onProgress: applyProgressEvent,
1355 });1358 });
13561359
apps/web/src/components/LegacySidebar.tsx+45-4
...@@ -1,3 +1,10 @@...@@ -1,3 +1,10 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { GitPullRequestIcon } from "lucide-react";
3import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton";
4import {
5 resolveThreadCurrentPullRequestLink,
6 visibleThreadPullRequests,
7} from "@t3tools/shared/threadPullRequests";
1import { Spinner } from "~/components/ui/spinner";8import { Spinner } from "~/components/ui/spinner";
2import {9import {
3 ArchiveIcon,10 ArchiveIcon,
...@@ -465,10 +472,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -465,10 +472,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
465 });472 });
466 const linkedPullRequestStatus = useLinkedThreadPullRequest(473 const linkedPullRequestStatus = useLinkedThreadPullRequest(
467 thread.environmentId,474 thread.environmentId,
468 thread.linkedPullRequest ?? thread.branchPullRequest,475 thread.linkedPullRequest,
469 leaseLiveStatus,476 leaseLiveStatus,
477 thread.pullRequests,
478 thread.branchPullRequest,
470 );479 );
471 const pr = linkedPullRequestStatus?.pr ?? null;480 const pr = linkedPullRequestStatus?.pr ?? null;
481 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
482 const currentLinkedPr = supportsMultiplePullRequests
483 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
484 : null;
472 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);485 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
473 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);486 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
474 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;487 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
...@@ -579,17 +592,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -579,17 +592,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
579 );592 );
580 const handlePrClick = useCallback(593 const handlePrClick = useCallback(
581 (event: React.MouseEvent<HTMLAnchorElement>) => {594 (event: React.MouseEvent<HTMLAnchorElement>) => {
582 if (!prStatus) return;595 const url = prStatus?.url ?? currentLinkedPr?.url;
596 if (!url) return;
583 const openedInRightPanel = openPrLink(597 const openedInRightPanel = openPrLink(
584 event,598 event,
585 prStatus.url,599 url,
586 openPullRequestsInRightPanel ? threadRef : undefined,600 openPullRequestsInRightPanel ? threadRef : undefined,
587 );601 );
588 if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) {602 if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) {
589 navigateToThread(threadRef);603 navigateToThread(threadRef);
590 }604 }
591 },605 },
592 [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef],606 [
607 isActive,
608 navigateToThread,
609 openPrLink,
610 openPullRequestsInRightPanel,
611 prStatus,
612 currentLinkedPr,
613 threadRef,
614 ],
593 );615 );
594 const handleRenameInputRef = useCallback(616 const handleRenameInputRef = useCallback(
595 (element: HTMLInputElement | null) => {617 (element: HTMLInputElement | null) => {
...@@ -729,6 +751,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -729,6 +751,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
729 </TooltipPopup>751 </TooltipPopup>
730 </Tooltip>752 </Tooltip>
731 )}753 )}
754 {!pr && currentLinkedPr ? (
755 <a
756 href={currentLinkedPr.url}
757 target="_blank"
758 rel="noopener noreferrer"
759 onPointerDown={(event) => event.stopPropagation()}
760 onClick={handlePrClick}
761 className="text-muted-foreground"
762 aria-label={`PR #${currentLinkedPr.number}, status pending`}
763 >
764 <GitPullRequestIcon className="size-3" />
765 </a>
766 ) : null}
767 {pr &&
768 (supportsMultiplePullRequests
769 ? visibleThreadPullRequests(thread.pullRequests).length === 0
770 : thread.linkedPullRequest == null) ? (
771 <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} />
772 ) : null}
732 {threadStatus && <ThreadStatusLabel status={threadStatus} />}773 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
733 {renamingThreadKey === threadKey ? (774 {renamingThreadKey === threadKey ? (
734 <input775 <input
apps/web/src/components/RightPanelTabs.test.tsx+48
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
1import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";2import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";4import { describe, expect, it } from "vite-plus/test";
45
5import {6import {
6 RightPanelTabs,7 RightPanelTabs,
8 resolvePullRequestTabLink,
7 shouldOpenDefaultBrowserProfileFromMenuClick,9 shouldOpenDefaultBrowserProfileFromMenuClick,
8 surfaceShortcutActionForKey,10 surfaceShortcutActionForKey,
9 surfaceShortcutTargetsTypingContext,11 surfaceShortcutTargetsTypingContext,
...@@ -117,6 +119,7 @@ function renderTabs(...@@ -117,6 +119,7 @@ function renderTabs(
117 onAddBrowserInProfile={() => undefined}119 onAddBrowserInProfile={() => undefined}
118 onAddTerminal={() => undefined}120 onAddTerminal={() => undefined}
119 onAddPullRequest={() => undefined}121 onAddPullRequest={() => undefined}
122 onAddPullRequests={() => undefined}
120 onAddDiff={() => undefined}123 onAddDiff={() => undefined}
121 onAddFiles={() => undefined}124 onAddFiles={() => undefined}
122 onAddAgents={() => undefined}125 onAddAgents={() => undefined}
...@@ -126,6 +129,7 @@ function renderTabs(...@@ -126,6 +129,7 @@ function renderTabs(
126 diffAvailable={false}129 diffAvailable={false}
127 filesAvailable={false}130 filesAvailable={false}
128 pullRequestAvailable={false}131 pullRequestAvailable={false}
132 pullRequestsAvailable={false}
129 agentsAvailable={false}133 agentsAvailable={false}
130 >134 >
131 <div>content</div>135 <div>content</div>
...@@ -278,3 +282,47 @@ describe("tabMuteMenuItem", () => {...@@ -278,3 +282,47 @@ describe("tabMuteMenuItem", () => {
278 });282 });
279 });283 });
280});284});
285
286describe("pull request tab snapshots", () => {
287 const environmentId = EnvironmentId.make("local");
288 const link: ThreadPullRequestLink = {
289 host: "github.com",
290 repository: "acme/api",
291 number: 7,
292 url: "https://github.com/acme/api/pull/7",
293 source: "manual",
294 linkedAt: "2026-01-01T00:00:00Z",
295 stack: null,
296 snapshot: null,
297 };
298 it("keeps unknown linked state authoritative and scopes matches to environment and host", () => {
299 const threads = [{ environmentId, pullRequests: [link] }];
300 expect(resolvePullRequestTabLink(threads, environmentId, "github.com", link)).toBe(link);
301 expect(
302 resolvePullRequestTabLink(threads, EnvironmentId.make("remote"), "github.com", link),
303 ).toBeUndefined();
304 expect(
305 resolvePullRequestTabLink(threads, environmentId, "github.enterprise.test", link),
306 ).toBeUndefined();
307 });
308 it("uses the newest snapshot when several threads link the same PR", () => {
309 const snapshot = {
310 state: "merged" as const,
311 title: "API",
312 headBranch: "api",
313 baseBranch: "main",
314 isDraft: false,
315 updatedAt: null,
316 syncedAt: "2026-02-01T00:00:00Z",
317 };
318 const newer = { ...link, snapshot };
319 expect(
320 resolvePullRequestTabLink(
321 [{ environmentId, pullRequests: [link, newer] }],
322 environmentId,
323 "github.com",
324 link,
325 ),
326 ).toBe(newer);
327 });
328});
apps/web/src/components/RightPanelTabs.tsx+97-3
...@@ -1,3 +1,10 @@...@@ -1,3 +1,10 @@
1import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities";
4import {
5 threadPullRequestKeysEqual,
6 visibleThreadPullRequests,
7} from "@t3tools/shared/threadPullRequests";
1import type {8import type {
2 ContextMenuItem,9 ContextMenuItem,
3 EnvironmentId,10 EnvironmentId,
...@@ -14,6 +21,7 @@ import {...@@ -14,6 +21,7 @@ import {
14 FileDiff,21 FileDiff,
15 Files,22 Files,
16 GitPullRequest,23 GitPullRequest,
24 GitPullRequestArrow,
17 Globe2,25 Globe2,
18 Plus,26 Plus,
19 TerminalSquare,27 TerminalSquare,
...@@ -104,12 +112,14 @@ interface RightPanelTabsProps {...@@ -104,12 +112,14 @@ interface RightPanelTabsProps {
104 onAddDiff: () => void;112 onAddDiff: () => void;
105 onAddFiles: () => void;113 onAddFiles: () => void;
106 onAddPullRequest: () => void;114 onAddPullRequest: () => void;
115 onAddPullRequests: () => void;
107 onAddAgents: () => void;116 onAddAgents: () => void;
108 browserAvailable: boolean;117 browserAvailable: boolean;
109 terminalAvailable: boolean;118 terminalAvailable: boolean;
110 diffAvailable: boolean;119 diffAvailable: boolean;
111 filesAvailable: boolean;120 filesAvailable: boolean;
112 pullRequestAvailable: boolean;121 pullRequestAvailable: boolean;
122 pullRequestsAvailable: boolean;
113 agentsAvailable: boolean;123 agentsAvailable: boolean;
114 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;124 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;
115 /** Running + waiting subagents; badges the Agents card in the empty state. */125 /** Running + waiting subagents; badges the Agents card in the empty state. */
...@@ -139,6 +149,7 @@ const SURFACE_DISABLED_REASONS = {...@@ -139,6 +149,7 @@ const SURFACE_DISABLED_REASONS = {
139 files: "Files are only available when a project is open.",149 files: "Files are only available when a project is open.",
140 diff: "Diff is only available for server threads in Git repositories.",150 diff: "Diff is only available for server threads in Git repositories.",
141 pullRequest: "This thread's branch has no pull request yet.",151 pullRequest: "This thread's branch has no pull request yet.",
152 pullRequests: "Linked pull requests are only available for server threads.",
142 agents: "Agents are only available from a thread.",153 agents: "Agents are only available from a thread.",
143} as const;154} as const;
144155
...@@ -161,6 +172,7 @@ const SURFACE_UNAVAILABLE_HINTS = {...@@ -161,6 +172,7 @@ const SURFACE_UNAVAILABLE_HINTS = {
161 files: "Available when a project is open.",172 files: "Available when a project is open.",
162 diff: "Available for Git repositories.",173 diff: "Available for Git repositories.",
163 pullRequest: "No pull request on this branch yet.",174 pullRequest: "No pull request on this branch yet.",
175 pullRequests: "Available for server threads.",
164 agents: "Available from a thread.",176 agents: "Available from a thread.",
165} as const;177} as const;
166178
...@@ -298,12 +310,14 @@ function RightPanelEmptyState(props: {...@@ -298,12 +310,14 @@ function RightPanelEmptyState(props: {
298 onAddDiff: () => void;310 onAddDiff: () => void;
299 onAddFiles: () => void;311 onAddFiles: () => void;
300 onAddPullRequest: () => void;312 onAddPullRequest: () => void;
313 onAddPullRequests: () => void;
301 onAddAgents: () => void;314 onAddAgents: () => void;
302 browserAvailable: boolean;315 browserAvailable: boolean;
303 terminalAvailable: boolean;316 terminalAvailable: boolean;
304 diffAvailable: boolean;317 diffAvailable: boolean;
305 filesAvailable: boolean;318 filesAvailable: boolean;
306 pullRequestAvailable: boolean;319 pullRequestAvailable: boolean;
320 pullRequestsAvailable: boolean;
307 agentsAvailable: boolean;321 agentsAvailable: boolean;
308 liveAgentCount: number;322 liveAgentCount: number;
309}) {323}) {
...@@ -361,6 +375,16 @@ function RightPanelEmptyState(props: {...@@ -361,6 +375,16 @@ function RightPanelEmptyState(props: {
361 onClick: props.onAddPullRequest,375 onClick: props.onAddPullRequest,
362 badgeCount: 0,376 badgeCount: 0,
363 },377 },
378 {
379 label: "Linked pull requests",
380 description: "Every pull request this thread has linked, stacks included.",
381 icon: GitPullRequestArrow,
382 shortcut: "L",
383 available: props.pullRequestsAvailable,
384 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests,
385 onClick: props.onAddPullRequests,
386 badgeCount: 0,
387 },
364 {388 {
365 label: "Agents",389 label: "Agents",
366 description: "Follow subagents and workflows.",390 description: "Follow subagents and workflows.",
...@@ -602,6 +626,8 @@ function surfaceTitle(...@@ -602,6 +626,8 @@ function surfaceTitle(
602 );626 );
603 case "pull-request":627 case "pull-request":
604 return `#${surface.number}`;628 return `#${surface.number}`;
629 case "pull-requests":
630 return "Pull requests";
605 case "agents":631 case "agents":
606 return "Agents";632 return "Agents";
607 case "preview": {633 case "preview": {
...@@ -683,11 +709,42 @@ function SurfaceIcon({...@@ -683,11 +709,42 @@ function SurfaceIcon({
683 seed={pullRequestStatusSeeds?.[surface.id]}709 seed={pullRequestStatusSeeds?.[surface.id]}
684 />710 />
685 );711 );
712 case "pull-requests":
713 return <GitPullRequestArrow className="size-3 shrink-0" />;
686 case "agents":714 case "agents":
687 return <Bot className="size-3 shrink-0" />;715 return <Bot className="size-3 shrink-0" />;
688 }716 }
689}717}
690718
719export function resolvePullRequestTabLink(
720 threads: readonly Pick<EnvironmentThreadShell, "environmentId" | "pullRequests">[],
721 environmentId: EnvironmentId | null,
722 host: string | null,
723 reference: { repository: string; number: number },
724) {
725 if (environmentId === null || host === null) return undefined;
726 let newest: EnvironmentThreadShell["pullRequests"][number] | undefined;
727 for (const thread of threads) {
728 if (thread.environmentId !== environmentId) continue;
729 for (const link of visibleThreadPullRequests(thread.pullRequests)) {
730 if (
731 !threadPullRequestKeysEqual(link, {
732 host,
733 repository: reference.repository,
734 number: reference.number,
735 })
736 )
737 continue;
738 if (
739 newest === undefined ||
740 (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "")
741 )
742 newest = link;
743 }
744 }
745 return newest;
746}
747
691function PullRequestSurfaceIcon({748function PullRequestSurfaceIcon({
692 surface,749 surface,
693 environmentId,750 environmentId,
...@@ -699,13 +756,36 @@ function PullRequestSurfaceIcon({...@@ -699,13 +756,36 @@ function PullRequestSurfaceIcon({
699}) {756}) {
700 const resolvedEnvironmentId =757 const resolvedEnvironmentId =
701 (surface.environmentId as EnvironmentId | undefined) ?? environmentId;758 (surface.environmentId as EnvironmentId | undefined) ?? environmentId;
702 const detail = useEnvironmentQuery(759 const projects = useProjects();
760 const threads = useThreadShells();
761 const project = projects.find(
762 (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId,
763 );
764 const identity = project?.repositoryIdentity;
765 const host =
766 surface.host ??
767 (identity?.provider
768 ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind)
769 : null);
770 const configs = useServerConfigs();
771 const capabilities =
703 resolvedEnvironmentId === null772 resolvedEnvironmentId === null
773 ? undefined
774 : configs.get(resolvedEnvironmentId)?.environment.capabilities;
775 const linkedSnapshot =
776 capabilities?.threadPullRequests === true
777 ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null)
778 : null;
779 const detail = useEnvironmentQuery(
780 resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null
704 ? null781 ? null
705 : pullRequestEnvironment.detail({782 : pullRequestEnvironment.detail({
706 environmentId: resolvedEnvironmentId,783 environmentId: resolvedEnvironmentId,
707 input: {784 input: {
708 projectId: surface.projectId as ProjectId,785 projectId: surface.projectId as ProjectId,
786 ...(capabilities?.threadPullRequests === true && surface.host !== undefined
787 ? { host: surface.host }
788 : {}),
709 repository: surface.repository,789 repository: surface.repository,
710 number: surface.number,790 number: surface.number,
711 },791 },
...@@ -714,11 +794,15 @@ function PullRequestSurfaceIcon({...@@ -714,11 +794,15 @@ function PullRequestSurfaceIcon({
714 // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the794 // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the
715 // full detail would flip an open tab to the conflict glyph the moment its read lands.795 // full detail would flip an open tab to the conflict glyph the moment its read lands.
716 const status =796 const status =
717 detail === null ? (seed ?? null) : { state: detail.state, isDraft: detail.isDraft };797 linkedSnapshot !== null
798 ? linkedSnapshot
799 : detail === null
800 ? (seed ?? null)
801 : { state: detail.state, isDraft: detail.isDraft };
718 if (status === null) {802 if (status === null) {
719 return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />;803 return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />;
720 }804 }
721 const presentation = resolvePullRequestState(status);805 const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft });
722 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;806 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
723}807}
724808
...@@ -806,6 +890,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -806,6 +890,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
806 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,890 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
807 onClick: props.onAddPullRequest,891 onClick: props.onAddPullRequest,
808 },892 },
893 {
894 label: "Linked pull requests",
895 icon: GitPullRequestArrow,
896 shortcut: "L",
897 available: props.pullRequestsAvailable,
898 disabledReason: SURFACE_DISABLED_REASONS.pullRequests,
899 onClick: props.onAddPullRequests,
900 },
809 {901 {
810 label: "Agents",902 label: "Agents",
811 icon: Bot,903 icon: Bot,
...@@ -1250,12 +1342,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1250,12 +1342,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
1250 onAddDiff={props.onAddDiff}1342 onAddDiff={props.onAddDiff}
1251 onAddFiles={props.onAddFiles}1343 onAddFiles={props.onAddFiles}
1252 onAddPullRequest={props.onAddPullRequest}1344 onAddPullRequest={props.onAddPullRequest}
1345 onAddPullRequests={props.onAddPullRequests}
1253 onAddAgents={props.onAddAgents}1346 onAddAgents={props.onAddAgents}
1254 browserAvailable={props.browserAvailable}1347 browserAvailable={props.browserAvailable}
1255 terminalAvailable={props.terminalAvailable}1348 terminalAvailable={props.terminalAvailable}
1256 diffAvailable={props.diffAvailable}1349 diffAvailable={props.diffAvailable}
1257 filesAvailable={props.filesAvailable}1350 filesAvailable={props.filesAvailable}
1258 pullRequestAvailable={props.pullRequestAvailable}1351 pullRequestAvailable={props.pullRequestAvailable}
1352 pullRequestsAvailable={props.pullRequestsAvailable}
1259 agentsAvailable={props.agentsAvailable}1353 agentsAvailable={props.agentsAvailable}
1260 liveAgentCount={props.liveAgentCount}1354 liveAgentCount={props.liveAgentCount}
1261 />1355 />
apps/web/src/components/Sidebar.logic.test.ts+1
...@@ -2133,6 +2133,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -2133,6 +2133,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2133 branch: null,2133 branch: null,
2134 worktreePath: null,2134 worktreePath: null,
2135 checkpoints: [],2135 checkpoints: [],
2136 pullRequests: [],
2136 activities: [],2137 activities: [],
2137 ...overrides,2138 ...overrides,
2138 };2139 };
apps/web/src/components/Sidebar.tsx+114-11
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton";
3import {
4 resolveThreadCurrentPullRequestLink,
5 visibleThreadPullRequests,
6} from "@t3tools/shared/threadPullRequests";
1import { useAtomValue } from "@effect/atom-react";7import { useAtomValue } from "@effect/atom-react";
2import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
3import {9import {
...@@ -70,6 +76,7 @@ import {...@@ -70,6 +76,7 @@ import {
70} from "react";76} from "react";
71import { useParams, useRouter } from "@tanstack/react-router";77import { useParams, useRouter } from "@tanstack/react-router";
7278
79import { useRightPanelStore } from "../rightPanelStore";
73import {80import {
74 isAtomCommandInterrupted,81 isAtomCommandInterrupted,
75 settlePromise,82 settlePromise,
...@@ -185,8 +192,12 @@ import {...@@ -185,8 +192,12 @@ import {
185import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";192import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
186import { createSidebarListMotion } from "./Sidebar.motion";193import { createSidebarListMotion } from "./Sidebar.motion";
187import {194import {
195 PR_STATE_COLOR_CLASS,
196 ThreadPullRequestBadgeIcon,
197 ThreadPullRequestsMiniList,
188 ThreadWorktreeIndicator,198 ThreadWorktreeIndicator,
189 prStatusIndicator,199 prStatusIndicator,
200 resolveThreadPullRequestBadge,
190 settledPrHoverColorClass,201 settledPrHoverColorClass,
191 terminalStatusFromRunningIds,202 terminalStatusFromRunningIds,
192 type TerminalStatusIndicator,203 type TerminalStatusIndicator,
...@@ -209,7 +220,7 @@ import {...@@ -209,7 +220,7 @@ import {
209} from "../providerInstances";220} from "../providerInstances";
210import { useThreadRunningTerminalIds } from "../state/terminalSessions";221import { useThreadRunningTerminalIds } from "../state/terminalSessions";
211import { stackedThreadToast, toastManager } from "./ui/toast";222import { stackedThreadToast, toastManager } from "./ui/toast";
212import { Button } from "./ui/button";223import { Button, InlineButton } from "./ui/button";
213import { Input } from "./ui/input";224import { Input } from "./ui/input";
214import {225import {
215 Combobox,226 Combobox,
...@@ -330,6 +341,7 @@ function SidebarThreadTooltip({...@@ -330,6 +341,7 @@ function SidebarThreadTooltip({
330 terminalProcessCount: number;341 terminalProcessCount: number;
331}) {342}) {
332 const driverKind = providerEntry?.driverKind ?? null;343 const driverKind = providerEntry?.driverKind ?? null;
344 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
333 return (345 return (
334 <TooltipPopup346 <TooltipPopup
335 side="right"347 side="right"
...@@ -411,6 +423,11 @@ function SidebarThreadTooltip({...@@ -411,6 +423,11 @@ function SidebarThreadTooltip({
411 </div>423 </div>
412 ) : null}424 ) : null}
413 </div>425 </div>
426 {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? (
427 <div className="border-t border-border/60 pt-2 pl-0.5 text-xs text-muted-foreground">
428 <ThreadPullRequestsMiniList pullRequests={thread.pullRequests} />
429 </div>
430 ) : null}
414 </div>431 </div>
415 </TooltipPopup>432 </TooltipPopup>
416 );433 );
...@@ -1047,8 +1064,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1047,8 +1064,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1047 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;1064 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
1048 const linkedPullRequestStatus = useLinkedThreadPullRequest(1065 const linkedPullRequestStatus = useLinkedThreadPullRequest(
1049 thread.environmentId,1066 thread.environmentId,
1050 thread.linkedPullRequest ?? thread.branchPullRequest,1067 thread.linkedPullRequest,
1051 leaseLiveStatus,1068 leaseLiveStatus,
1069 thread.pullRequests,
1070 thread.branchPullRequest,
1052 );1071 );
1053 const gitStatus = useEnvironmentQuery(1072 const gitStatus = useEnvironmentQuery(
1054 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null1073 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
...@@ -1063,6 +1082,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1063,6 +1082,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1063 gitStatus.data,1082 gitStatus.data,
1064 );1083 );
1065 const pr = linkedPullRequestStatus?.pr ?? null;1084 const pr = linkedPullRequestStatus?.pr ?? null;
1085 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
1086 const currentLinkedPr = supportsMultiplePullRequests
1087 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
1088 : null;
10661089
1067 // Same semantics as the legacy sidebar (never-visited counts as read):1090 // Same semantics as the legacy sidebar (never-visited counts as read):
1068 // switching sidebars must not light up every historical thread as unread.1091 // switching sidebars must not light up every historical thread as unread.
...@@ -1339,17 +1362,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1339,17 +1362,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1339 }, [showSnoozeButton]);1362 }, [showSnoozeButton]);
1340 const handlePrClick = useCallback(1363 const handlePrClick = useCallback(
1341 (event: ReactMouseEvent<HTMLAnchorElement>) => {1364 (event: ReactMouseEvent<HTMLAnchorElement>) => {
1342 if (!pr?.url) return;1365 const url = pr?.url ?? currentLinkedPr?.url;
1366 if (!url) return;
1343 const openedInRightPanel = openPrLink(1367 const openedInRightPanel = openPrLink(
1344 event,1368 event,
1345 pr.url,1369 url,
1346 openPullRequestsInRightPanel ? threadRef : undefined,1370 openPullRequestsInRightPanel ? threadRef : undefined,
1347 );1371 );
1348 if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) {1372 if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) {
1349 onThreadActivate(threadRef);1373 onThreadActivate(threadRef);
1350 }1374 }
1351 },1375 },
1352 [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef],1376 [
1377 onThreadActivate,
1378 openPrLink,
1379 openPullRequestsInRightPanel,
1380 pr,
1381 currentLinkedPr,
1382 props.isActive,
1383 threadRef,
1384 ],
1353 );1385 );
13541386
1355 // All sidebar rows share one surface model. Live threads used to look1387 // All sidebar rows share one surface model. Live threads used to look
...@@ -1456,10 +1488,43 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1456,10 +1488,43 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1456 </span>1488 </span>
1457 );1489 );
14581490
1459 // A real link so cmd/ctrl+click and middle-click open the host in the1491 // One badge shape for every thread: the glyph says stack or not, the number is the current
1460 // browser. A plain click still opens T3's pull request view.1492 // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and
1493 // middle-click open the host in the browser; a plain click opens T3's pull request view.
1494 const prBadgeShape = supportsMultiplePullRequests
1495 ? resolveThreadPullRequestBadge(thread.pullRequests)
1496 : null;
1497 const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) =>
1498 cn(
1499 // Sidebar chrome follows the interface font; tabular digits keep the number from
1500 // reflowing as PR states stream in. A border rather than text-decoration, so the line
1501 // runs under the glyph as well as the number.
1502 "text-xs tabular-nums",
1503 variant === "slim" && variantAction === "unsettle"
1504 ? props.isActive
1505 ? "text-secondary-label"
1506 : cn("text-secondary-label transition-colors", settledPrHoverColorClass(state))
1507 : colorClass,
1508 );
1509 const handlePrStackClick = useCallback(() => {
1510 useRightPanelStore.getState().open(threadRef, "pull-requests");
1511 if (!props.isActive) onThreadActivate(threadRef);
1512 }, [onThreadActivate, props.isActive, threadRef]);
1461 const prBadge =1513 const prBadge =
1462 prStatus && pr ? (1514 prBadgeShape?.kind === "stack" ? (
1515 // A stack is one thing with N layers; naming one of them would misrepresent it, so the
1516 // badge counts layers and opens the thread's pull-requests surface.
1517 <InlineButton
1518 underline
1519 onPointerDown={(event) => event.stopPropagation()}
1520 onClick={handlePrStackClick}
1521 className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])}
1522 aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`}
1523 >
1524 <ThreadPullRequestBadgeIcon icon="stack" />
1525 {prBadgeShape.layers}
1526 </InlineButton>
1527 ) : prStatus && pr ? (
1463 <a1528 <a
1464 href={pr.url}1529 href={pr.url}
1465 target="_blank"1530 target="_blank"
...@@ -1469,14 +1534,38 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1469,14 +1534,38 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1469 className={cn(1534 className={cn(
1470 // Sidebar chrome follows the interface font; tabular digits keep the1535 // Sidebar chrome follows the interface font; tabular digits keep the
1471 // number from reflowing as PR states stream in.1536 // number from reflowing as PR states stream in.
1472 "shrink-0 text-xs tabular-nums hover:underline",1537 "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline",
1473 variant === "slim" && variantAction === "unsettle"1538 variant === "slim" && variantAction === "unsettle"
1474 ? cn("text-secondary-label transition-colors", settledPrHoverClass)1539 ? cn("text-secondary-label transition-colors", settledPrHoverClass)
1475 : prStatus.colorClass,1540 : prStatus.colorClass,
1476 )}1541 )}
1477 aria-label={prStatus.tooltip}1542 aria-label={
1543 prBadgeShape && prBadgeShape.kind === "pull-request" && prBadgeShape.others > 0
1544 ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked`
1545 : prStatus.tooltip
1546 }
1478 >1547 >
1479 #{pr.number}1548 <ThreadPullRequestBadgeIcon icon="pull-request" />
1549 {pr.number}
1550 {prBadgeShape && prBadgeShape.others > 0 ? (
1551 <span className="opacity-70">+{prBadgeShape.others}</span>
1552 ) : null}
1553 </a>
1554 ) : currentLinkedPr ? (
1555 <a
1556 href={currentLinkedPr.url}
1557 target="_blank"
1558 rel="noopener noreferrer"
1559 onPointerDown={(event) => event.stopPropagation()}
1560 onClick={handlePrClick}
1561 className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline"
1562 aria-label={`PR #${currentLinkedPr.number}, status pending`}
1563 >
1564 <ThreadPullRequestBadgeIcon icon="pull-request" />
1565 {currentLinkedPr.number}
1566 {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? (
1567 <span>+{prBadgeShape.others}</span>
1568 ) : null}
1480 </a>1569 </a>
1481 ) : null;1570 ) : null;
1482 const terminalStatusIcon = terminalStatus ? (1571 const terminalStatusIcon = terminalStatus ? (
...@@ -1589,6 +1678,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1589,6 +1678,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1589 remain visible AND clickable while the row is hovered. Only1678 remain visible AND clickable while the row is hovered. Only
1590 the time/jump label yields to the settle affordance. */}1679 the time/jump label yields to the settle affordance. */}
1591 {prBadge}1680 {prBadge}
1681 {prBadge &&
1682 pr &&
1683 (supportsMultiplePullRequests
1684 ? visibleThreadPullRequests(thread.pullRequests).length === 0
1685 : thread.linkedPullRequest == null) ? (
1686 <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} />
1687 ) : null}
1592 {sortable?.isDragging ? (1688 {sortable?.isDragging ? (
1593 dragDestination1689 dragDestination
1594 ) : (1690 ) : (
...@@ -1889,6 +1985,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1889,6 +1985,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1889 )}1985 )}
1890 {terminalStatusIcon}1986 {terminalStatusIcon}
1891 {prBadge}1987 {prBadge}
1988 {prBadge &&
1989 pr &&
1990 (supportsMultiplePullRequests
1991 ? visibleThreadPullRequests(thread.pullRequests).length === 0
1992 : thread.linkedPullRequest == null) ? (
1993 <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} />
1994 ) : null}
1892 {diff ? (1995 {diff ? (
1893 <span className="shrink-0 font-mono">1996 <span className="shrink-0 font-mono">
1894 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}1997 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
apps/web/src/components/ThreadStatusIndicators.test.tsx+45-2
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import { ThreadId } from "@t3tools/contracts";1import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators";5import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators";
66
7describe("ThreadWorktreeIndicator", () => {7describe("ThreadWorktreeIndicator", () => {
8 it("renders the worktree folder and branch in an accessible label", () => {8 it("renders the worktree folder and branch in an accessible label", () => {
...@@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => {...@@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => {
37 expect(markup).toBe("");37 expect(markup).toBe("");
38 });38 });
39});39});
40
41describe("linked pull request snapshots", () => {
42 const link: ThreadPullRequestLink = {
43 host: "gitlab.example.com",
44 repository: "acme/web",
45 number: 42,
46 url: "https://gitlab.example.com/acme/web/-/merge_requests/42",
47 source: "manual",
48 linkedAt: "2026-01-01T00:00:00Z",
49 stack: null,
50 snapshot: null,
51 };
52 it("keeps unsynced links unknown", () => {
53 expect(linkedPullRequestSnapshotStatus(link)).toBeNull();
54 });
55 it("uses the snapshot state and branches with the linked identity", () => {
56 const result = linkedPullRequestSnapshotStatus({
57 ...link,
58 snapshot: {
59 state: "merged",
60 title: "Change",
61 headBranch: "feature",
62 baseBranch: "main",
63 isDraft: false,
64 updatedAt: "2026-01-02T00:00:00Z",
65 syncedAt: "2026-01-03T00:00:00Z",
66 },
67 });
68 expect(result).toEqual({
69 pr: {
70 number: 42,
71 url: link.url,
72 title: "Change",
73 state: "merged",
74 isDraft: false,
75 headRef: "feature",
76 baseRef: "main",
77 updatedAt: "2026-01-02T00:00:00Z",
78 },
79 sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" },
80 });
81 });
82});
apps/web/src/components/ThreadStatusIndicators.tsx+161-28
...@@ -1,25 +1,35 @@...@@ -1,25 +1,35 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
1import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";2import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";3import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
3import {4import {
4 type EnvironmentId,
5 resolveEnvironmentMachineKind,5 resolveEnvironmentMachineKind,
6 type EnvironmentId,
6 type ThreadLinkedPullRequest,7 type ThreadLinkedPullRequest,
8 type ThreadPullRequestLink,
7 type VcsStatusResult,9 type VcsStatusResult,
8} from "@t3tools/contracts";10} from "@t3tools/contracts";
9import { FolderGit2Icon, TerminalIcon } from "lucide-react";11import {
12 resolveThreadCurrentPullRequestLink,
13 resolveThreadPullRequestChains,
14 visibleThreadPullRequests,
15} from "@t3tools/shared/threadPullRequests";
16import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react";
10import { useMemo } from "react";17import { useMemo } from "react";
18import { cn } from "../lib/utils";
11import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";19import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
12import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";20import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
21import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
13import { useEnvironmentQuery } from "../state/query";22import { useEnvironmentQuery } from "../state/query";
14import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";23import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
15import { useThreadRunningTerminalIds } from "../state/terminalSessions";24import { useThreadRunningTerminalIds } from "../state/terminalSessions";
16import { useUiStateStore } from "../uiStateStore";25import { useUiStateStore } from "../uiStateStore";
17import { resolveChangeRequestPresentation } from "../sourceControlPresentation";26import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
18import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";27import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
19import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
20import type { SidebarThreadSummary } from "../types";28import type { SidebarThreadSummary } from "../types";
21import { formatWorktreePathForDisplay } from "../worktreeCleanup";29import { formatWorktreePathForDisplay } from "../worktreeCleanup";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";30import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
31import { pullRequestListLines } from "./pullRequest/pullRequestListLines";
32import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
2333
24export interface PrStatusIndicator {34export interface PrStatusIndicator {
25 label: string;35 label: string;
...@@ -43,42 +53,151 @@ export interface LinkedThreadPullRequestStatus {...@@ -43,42 +53,151 @@ export interface LinkedThreadPullRequestStatus {
43 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;53 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
44}54}
4555
46/** Keep cached summaries visible when an offscreen row stops live queries. */56/** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */
47export function useLinkedThreadPullRequest(57export function useLinkedThreadPullRequest(
48 environmentId: EnvironmentId | null,58 environmentId: EnvironmentId | null,
49 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,59 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
50 enabled = true,60 enabled = true,
61 pullRequests?: ReadonlyArray<ThreadPullRequestLink>,
62 branchPullRequest?: ThreadLinkedPullRequest | null,
51): LinkedThreadPullRequestStatus | null {63): LinkedThreadPullRequestStatus | null {
64 const supportsLinks = useSupportsMultiplePullRequests(environmentId);
65 const current = useMemo(
66 () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null),
67 [pullRequests, supportsLinks],
68 );
69 const fallback =
70 current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null;
71 const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host;
72 const reference =
73 fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) };
52 const queried = useEnvironmentQuery(74 const queried = useEnvironmentQuery(
53 !enabled || environmentId === null || linkedPullRequest == null75 !enabled || environmentId === null || reference === null
54 ? null76 ? null
55 : linkedPullRequestDetailAtom({77 : linkedPullRequestDetailAtom({ environmentId, input: reference }),
56 environmentId,
57 input: {
58 projectId: linkedPullRequest.projectId,
59 repository: linkedPullRequest.repository,
60 number: linkedPullRequest.number,
61 },
62 }),
63 ).data;78 ).data;
64 const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried);79 const detail = useSharedPullRequestSummary(environmentId, reference, queried);
80
81 return useMemo(() => {
82 if (current !== null) return linkedPullRequestSnapshotStatus(current);
83 return detail === null
84 ? null
85 : {
86 pr: pullRequestDetailToVcsStatus(detail),
87 sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" },
88 };
89 }, [current, detail]);
90}
6591
66 return useMemo(92export function linkedPullRequestSnapshotStatus(
93 link: ThreadPullRequestLink,
94): LinkedThreadPullRequestStatus | null {
95 const snapshot = link.snapshot;
96 if (snapshot === null) return null;
97 const kind = link.url.includes("/-/merge_requests/")
98 ? "gitlab"
99 : link.url.includes("/pullrequest/")
100 ? "azure-devops"
101 : link.url.includes("/pull-requests/")
102 ? "bitbucket"
103 : "github";
104 return {
105 pr: {
106 number: link.number,
107 url: link.url,
108 title: snapshot.title,
109 state: snapshot.state,
110 isDraft: snapshot.isDraft,
111 headRef: snapshot.headBranch,
112 baseRef: snapshot.baseBranch,
113 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
114 },
115 sourceControlProvider: { kind, name: kind, baseUrl: "" },
116 };
117}
118
119export {
120 resolveThreadPullRequestBadge,
121 type ThreadPullRequestBadge,
122} from "@t3tools/shared/threadPullRequests";
123
124/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */
125export function ThreadPullRequestBadgeIcon({
126 icon,
127 className,
128}: {
129 icon: "stack" | "pull-request";
130 className?: string | undefined;
131}) {
132 const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon;
133 return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;
134}
135
136/**
137 * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation,
138 * so the hover answers "what is in here" without opening the surface.
139 */
140export function ThreadPullRequestsMiniList({
141 pullRequests,
142}: {
143 pullRequests: ReadonlyArray<ThreadPullRequestLink>;
144}) {
145 const lines = useMemo(
67 () =>146 () =>
68 detail === null147 pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))),
69 ? null148 [pullRequests],
70 : {149 );
71 pr: pullRequestDetailToVcsStatus(detail),150 if (lines.length === 0) return null;
72 sourceControlProvider: {151 return (
73 kind: detail.provider,152 <ul className="flex flex-col gap-1">
74 name: detail.provider,153 {lines.map((line) => {
75 baseUrl: "",154 const snapshot = line.link.snapshot;
76 },155 const presentation =
77 },156 snapshot === null
78 [detail],157 ? null
158 : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft });
159 return (
160 <li
161 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
162 className="flex min-w-0 items-center gap-2"
163 // Capped like the panel: past a few layers the indent only repeats "still in the
164 // stack", and sixteen of them would walk the titles off the popover.
165 style={{ paddingLeft: `${Math.min(line.depth, 3) * 0.75}rem` }}
166 >
167 {presentation ? (
168 <presentation.Icon
169 aria-hidden
170 className={cn("size-3 shrink-0", presentation.toneClassName)}
171 />
172 ) : (
173 <GitPullRequestArrowIcon
174 aria-hidden
175 className="size-3 shrink-0 stroke-muted-foreground"
176 />
177 )}
178 <span className="shrink-0 font-mono tabular-nums">#{line.link.number}</span>
179 <span className="min-w-0 truncate text-foreground/75">
180 {snapshot?.title ?? line.link.repository}
181 </span>
182 {line.stack ? (
183 <span className="ml-auto shrink-0 pl-1 text-[10px]">
184 {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size}
185 </span>
186 ) : null}
187 </li>
188 );
189 })}
190 </ul>
79 );191 );
80}192}
81193
194/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */
195export const PR_STATE_COLOR_CLASS: Record<NonNullable<ThreadPr>["state"], string> = {
196 open: "text-emerald-600 dark:text-emerald-300/90",
197 merged: "text-violet-600 dark:text-violet-300/90",
198 closed: "text-red-600 dark:text-red-300/90",
199};
200
82export function settledPrHoverColorClass(201export function settledPrHoverColorClass(
83 state: NonNullable<ThreadPr>["state"],202 state: NonNullable<ThreadPr>["state"],
84 isDraft = false,203 isDraft = false,
...@@ -282,7 +401,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -282,7 +401,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
282 );401 );
283 const pullRequest = useLinkedThreadPullRequest(402 const pullRequest = useLinkedThreadPullRequest(
284 thread.environmentId,403 thread.environmentId,
285 thread.linkedPullRequest ?? thread.branchPullRequest,404 thread.linkedPullRequest,
405 true,
406 thread.pullRequests,
407 thread.branchPullRequest,
286 );408 );
287 const pr = pullRequest?.pr ?? null;409 const pr = pullRequest?.pr ?? null;
288 const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider);410 const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider);
...@@ -293,7 +415,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -293,7 +415,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
293 },415 },
294 });416 });
295417
296 if (!prStatus && !threadStatus) {418 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
419 const pendingLink =
420 pr === null && supportsMultiplePullRequests
421 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
422 : null;
423 if (!prStatus && !threadStatus && !pendingLink) {
297 return null;424 return null;
298 }425 }
299426
...@@ -316,6 +443,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -316,6 +443,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
316 </TooltipPopup>443 </TooltipPopup>
317 </Tooltip>444 </Tooltip>
318 ) : null}445 ) : null}
446 {pendingLink ? (
447 <GitPullRequestArrowIcon
448 className="size-3 text-muted-foreground"
449 aria-label={`PR #${pendingLink.number}, status pending`}
450 />
451 ) : null}
319 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}452 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
320 </span>453 </span>
321 );454 );
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+1
...@@ -390,6 +390,7 @@ describe("streaming row projection", () => {...@@ -390,6 +390,7 @@ describe("streaming row projection", () => {
390 runtimeMode: "full-access",390 runtimeMode: "full-access",
391 interactionMode: "default",391 interactionMode: "default",
392 branch: null,392 branch: null,
393 pullRequests: [],
393 worktreePath: null,394 worktreePath: null,
394 latestTurn: {395 latestTurn: {
395 ...initial.input.latestTurn,396 ...initial.input.latestTurn,
apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx created+54
...@@ -0,0 +1,54 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { Link2 } from "lucide-react";
3import { useState } from "react";
4import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
5import { Button } from "../ui/button";
6import { toastManager } from "../ui/toast";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8
9/** Adopts a branch discovery as a durable link, even after the thread changes branches. */
10export function LinkBranchPullRequestButton({
11 threadRef,
12 url,
13}: {
14 threadRef: ScopedThreadRef;
15 url: string;
16}) {
17 const linking = usePullRequestLinking(threadRef.environmentId);
18 const [pending, setPending] = useState(false);
19 if (!linking.canLink(url)) return null;
20 return (
21 <Tooltip>
22 <TooltipTrigger
23 render={
24 <Button
25 size="icon-tiny"
26 variant="ghost-muted"
27 aria-label="Link this PR"
28 disabled={pending}
29 onPointerDown={(event) => event.stopPropagation()}
30 onClick={async (event) => {
31 event.preventDefault();
32 event.stopPropagation();
33 setPending(true);
34 try {
35 await linking.changeLink(threadRef, url, true);
36 } catch (error) {
37 toastManager.add({
38 type: "error",
39 title: "Could not link pull request",
40 description: error instanceof Error ? error.message : String(error),
41 });
42 } finally {
43 setPending(false);
44 }
45 }}
46 >
47 <Link2 className="size-3" />
48 </Button>
49 }
50 />
51 <TooltipPopup>Link this PR to keep it with this thread</TooltipPopup>
52 </Tooltip>
53 );
54}
apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts created+136
...@@ -0,0 +1,136 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog";
4
5const project = {
6 host: "github.com",
7 repository: "acme/web",
8 webUrl: (number: number) => changeRequestWebUrl("github", "github.com", "acme/web", number),
9};
10
11describe("resolveLinkPullRequestInput", () => {
12 it.each([
13 ["https://bitbucket.org/acme/web/pull-requests/42", "bitbucket.org"],
14 ["https://github.acme.test/acme/web/pull/42", "github.acme.test"],
15 ["https://git.acme.test/acme/web/-/merge_requests/42", "git.acme.test"],
16 ])("links supported host URL %s without a thread project", (url, host) => {
17 expect(
18 resolveLinkPullRequestInput({
19 reference: ` ${url} `,
20 project: null,
21 hasProject: (candidate) => candidate.host === host,
22 }),
23 ).toEqual({ link: { host, repository: "acme/web", number: 42, url } });
24 });
25
26 it("validates the full Azure repository when resolving a browser URL", () => {
27 const hasProject = (reference: { host: string; repository: string }) =>
28 reference.host === "dev.azure.com" && reference.repository === "org-a/project/_git/web";
29 expect(
30 resolveLinkPullRequestInput({
31 reference: "https://dev.azure.com/org-a/project/_git/web/pullrequest/42",
32 project: null,
33 hasProject,
34 }),
35 ).toMatchObject({ link: { repository: "org-a/project/_git/web", number: 42 } });
36 expect(
37 resolveLinkPullRequestInput({
38 reference: "https://dev.azure.com/org-b/project/_git/web/pullrequest/42",
39 project: null,
40 hasProject,
41 }),
42 ).toMatchObject({ error: expect.stringContaining("org-b/project/_git/web") });
43 });
44
45 it("resolves bare Azure numbers into canonical browser URLs", () => {
46 expect(
47 resolveLinkPullRequestInput({
48 reference: "#42",
49 project: {
50 host: "ssh.dev.azure.com",
51 repository: "v3/org/project/web",
52 webUrl: (number) =>
53 changeRequestWebUrl("azure-devops", "ssh.dev.azure.com", "v3/org/project/web", number),
54 },
55 hasProject: () => true,
56 }),
57 ).toMatchObject({
58 link: {
59 host: "dev.azure.com",
60 repository: "org/project/_git/web",
61 number: 42,
62 url: "https://dev.azure.com/org/project/_git/web/pullrequest/42",
63 },
64 });
65 });
66
67 it("returns null for input that is not a reference", () => {
68 expect(
69 resolveLinkPullRequestInput({ reference: "hello", project, hasProject: () => true }),
70 ).toBeNull();
71 });
72
73 it("resolves a bare number against the thread's own repository", () => {
74 expect(
75 resolveLinkPullRequestInput({ reference: "#42", project, hasProject: () => true }),
76 ).toEqual({
77 link: {
78 host: "github.com",
79 repository: "acme/web",
80 number: 42,
81 url: "https://github.com/acme/web/pull/42",
82 },
83 });
84 });
85
86 it("links a URL from another repository on a host with a project", () => {
87 expect(
88 resolveLinkPullRequestInput({
89 reference: "https://github.com/acme/api/pull/7",
90 project,
91 hasProject: (reference) => reference.host === "github.com",
92 }),
93 ).toEqual({
94 link: {
95 host: "github.com",
96 repository: "acme/api",
97 number: 7,
98 url: "https://github.com/acme/api/pull/7",
99 },
100 });
101 });
102
103 it("refuses a URL on a host nothing is checked out from", () => {
104 const result = resolveLinkPullRequestInput({
105 reference: "https://gitlab.com/acme/api/-/merge_requests/7",
106 project,
107 hasProject: () => false,
108 });
109 expect(result).toMatchObject({ error: expect.stringContaining("gitlab.com") });
110 });
111
112 it("asks for a URL when a bare number has no project to resolve against", () => {
113 expect(
114 resolveLinkPullRequestInput({ reference: "12", project: null, hasProject: () => true }),
115 ).toMatchObject({ error: expect.stringContaining("full URL") });
116 });
117
118 it("accepts a checkout command as a reference", () => {
119 expect(
120 resolveLinkPullRequestInput({
121 reference: "gh pr checkout https://github.com/acme/web/pull/3",
122 project,
123 hasProject: () => true,
124 }),
125 ).toMatchObject({ link: { number: 3, repository: "acme/web" } });
126 });
127});
128
129describe("changeRequestWebUrl", () => {
130 it("knows the four hosts and nothing else", () => {
131 expect(changeRequestWebUrl("gitlab", "gitlab.com", "g/sub/repo", 5)).toBe(
132 "https://gitlab.com/g/sub/repo/-/merge_requests/5",
133 );
134 expect(changeRequestWebUrl("unknown", "x", "a/b", 1)).toBeNull();
135 });
136});
apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx created+254
...@@ -0,0 +1,254 @@
1import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";
2export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";
3import {
4 pullRequestHostOf,
5 type ScopedThreadRef,
6 type SourceControlProviderKind,
7} from "@t3tools/contracts";
8import { useAtomValue } from "@effect/atom-react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
11import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
12import { parsePullRequestReference } from "~/pullRequestReference";
13import { useProjects, useThreadShell } from "~/state/entities";
14import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
15import { appAtomRegistry } from "~/rpc/atomRegistry";
16import { Atom } from "effect/unstable/reactivity";
17import { Button } from "../ui/button";
18import {
19 Dialog,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogPanel,
24 DialogPopup,
25 DialogTitle,
26} from "../ui/dialog";
27import { Input } from "../ui/input";
28
29/**
30 * Which thread has the link dialog open, set by whichever entry point asked (command palette,
31 * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives
32 * a palette that closes the moment its command runs.
33 */
34const linkPullRequestDialogThreadAtom = Atom.make<ScopedThreadRef | null>(null).pipe(
35 Atom.keepAlive,
36 Atom.withLabel("pull-requests:link-dialog-thread"),
37);
38
39export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void {
40 appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef);
41}
42
43interface LinkPullRequestDialogProps {
44 open: boolean;
45 threadRef: ScopedThreadRef;
46 /** The thread's own project: bare numbers resolve against its repository. */
47 projectId: string | null;
48 onOpenChange: (open: boolean) => void;
49}
50
51/** Mounted once per chat view; shows the dialog for whichever thread asked for it. */
52export function LinkPullRequestDialogHost() {
53 const threadRef = useAtomValue(linkPullRequestDialogThreadAtom);
54 const thread = useThreadShell(threadRef);
55 const linking = usePullRequestLinking(threadRef?.environmentId);
56 if (threadRef === null || linking.mode === "unsupported") return null;
57 return (
58 <LinkPullRequestDialog
59 open
60 threadRef={threadRef}
61 projectId={thread?.projectId ?? null}
62 onOpenChange={(open) => {
63 if (!open) appAtomRegistry.set(linkPullRequestDialogThreadAtom, null);
64 }}
65 />
66 );
67}
68
69interface ResolvedLink {
70 readonly host: string;
71 readonly repository: string;
72 readonly number: number;
73 readonly url: string;
74}
75
76/**
77 * Which pull request an input names, or why it cannot. A URL carries its own host and
78 * repository and may point at any repository on a host this environment has a project for; a
79 * bare `#123` can only mean the thread's own repository.
80 */
81export function resolveLinkPullRequestInput(input: {
82 readonly reference: string;
83 readonly project: {
84 readonly host: string;
85 readonly repository: string;
86 readonly webUrl: (number: number) => string | null;
87 } | null;
88 readonly hasProject: (reference: ResolvedLink) => boolean;
89}): { link: ResolvedLink } | { error: string } | null {
90 const parsed =
91 parseChangeRequestUrl(input.reference.trim()) !== null
92 ? input.reference.trim()
93 : parsePullRequestReference(input.reference);
94 if (parsed === null) return null;
95 const url = parseChangeRequestUrl(parsed);
96 if (url !== null) {
97 if (!input.hasProject({ ...url, url: parsed })) {
98 return { error: `No project in this environment can read ${url.host}/${url.repository}.` };
99 }
100 return {
101 link: { host: url.host, repository: url.repository, number: url.number, url: parsed },
102 };
103 }
104 const number = Number(parsed);
105 if (!Number.isSafeInteger(number) || number < 1) return null;
106 if (input.project === null) {
107 return { error: "Paste a full URL to link a pull request from another repository." };
108 }
109 const webUrl = input.project.webUrl(number);
110 const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl);
111 if (webUrl === null || webReference === null) {
112 return { error: "Paste a full URL; this project's host has no known pull request URL." };
113 }
114 return {
115 link: { ...webReference, url: webUrl },
116 };
117}
118
119function LinkPullRequestDialog({
120 open,
121 threadRef,
122 projectId,
123 onOpenChange,
124}: LinkPullRequestDialogProps) {
125 const inputRef = useRef<HTMLInputElement>(null);
126 const [reference, setReference] = useState("");
127 const [dirty, setDirty] = useState(false);
128 const [submitError, setSubmitError] = useState<string | null>(null);
129 const projects = useProjects();
130 const environmentProjects = useMemo(
131 () => projects.filter((project) => project.environmentId === threadRef.environmentId),
132 [projects, threadRef.environmentId],
133 );
134 const ownProject = useMemo(() => {
135 const project = environmentProjects.find((candidate) => candidate.id === projectId);
136 const identity = project?.repositoryIdentity;
137 if (!project || !identity) return null;
138 const repository =
139 identity.displayName ??
140 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
141 if (repository === null) return null;
142 const kind = identity.provider as SourceControlProviderKind;
143 const host = pullRequestHostOf(identity, kind);
144 return {
145 host,
146 repository,
147 webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number),
148 };
149 }, [environmentProjects, projectId]);
150 const linking = usePullRequestLinking(threadRef.environmentId);
151 const [pending, setPending] = useState(false);
152
153 useEffect(() => {
154 if (!open) return;
155 setReference("");
156 setDirty(false);
157 setSubmitError(null);
158 const frame = window.requestAnimationFrame(() => inputRef.current?.focus());
159 return () => window.cancelAnimationFrame(frame);
160 }, [open]);
161
162 const resolved = useMemo(
163 () =>
164 resolveLinkPullRequestInput({
165 reference,
166 project: ownProject,
167 hasProject: (reference) => linking.canLink(reference.url),
168 }),
169 [linking, ownProject, reference],
170 );
171
172 const submit = useCallback(async () => {
173 setDirty(true);
174 if (resolved === null || "error" in resolved) return;
175 setSubmitError(null);
176 setPending(true);
177 try {
178 await linking.changeLink(threadRef, resolved.link.url, true);
179 } catch (error) {
180 setSubmitError(error instanceof Error ? error.message : "Could not link the pull request.");
181 return;
182 } finally {
183 setPending(false);
184 }
185 onOpenChange(false);
186 }, [linking, onOpenChange, resolved, threadRef]);
187
188 const validation = !dirty
189 ? null
190 : reference.trim().length === 0
191 ? "Paste a pull request URL or enter 123 / #123."
192 : resolved === null
193 ? "Use a pull request URL, 123, or #123."
194 : "error" in resolved
195 ? resolved.error
196 : null;
197
198 return (
199 <Dialog open={open} onOpenChange={(next) => (pending ? undefined : onOpenChange(next))}>
200 <DialogPopup className="max-w-xl">
201 <DialogHeader>
202 <DialogTitle>Link pull request</DialogTitle>
203 <DialogDescription>
204 Attach a pull request to this thread. A full URL can point at any repository on a host
205 this environment has a project for.
206 </DialogDescription>
207 </DialogHeader>
208 <DialogPanel className="space-y-3">
209 <Input
210 ref={inputRef}
211 placeholder="Pull request URL or #42"
212 value={reference}
213 onChange={(event) => {
214 setDirty(true);
215 setReference(event.target.value);
216 }}
217 onKeyDown={(event) => {
218 if (event.key !== "Enter") return;
219 event.preventDefault();
220 void submit();
221 }}
222 />
223 {resolved !== null && "link" in resolved ? (
224 <p className="truncate text-muted-foreground text-xs">
225 {resolved.link.host}/{resolved.link.repository} #{resolved.link.number}
226 </p>
227 ) : null}
228 {(validation ?? submitError) ? (
229 <p className="text-destructive text-xs">{validation ?? submitError}</p>
230 ) : null}
231 </DialogPanel>
232 <DialogFooter>
233 <Button
234 type="button"
235 variant="outline"
236 size="sm"
237 onClick={() => onOpenChange(false)}
238 disabled={pending}
239 >
240 Cancel
241 </Button>
242 <Button
243 type="button"
244 size="sm"
245 onClick={() => void submit()}
246 disabled={pending || resolved === null || "error" in resolved}
247 >
248 {pending ? "Linking..." : "Link"}
249 </Button>
250 </DialogFooter>
251 </DialogPopup>
252 </Dialog>
253 );
254}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+116-8
...@@ -66,16 +66,15 @@ import { cn } from "~/lib/utils";...@@ -66,16 +66,15 @@ import { cn } from "~/lib/utils";
66import { readLocalApi } from "~/localApi";66import { readLocalApi } from "~/localApi";
67import type { ReviewCommentContext } from "~/reviewCommentContext";67import type { ReviewCommentContext } from "~/reviewCommentContext";
68import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";68import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
69import { useProjects } from "~/state/entities";69import { useProjects, useServerConfigs } from "~/state/entities";
70import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";70import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
71import { useEnvironmentQuery } from "~/state/query";71import { useEnvironmentQuery } from "~/state/query";
72import { useLiveRefresh } from "~/hooks/useLiveRefresh";72import { useLiveRefresh } from "~/hooks/useLiveRefresh";
73import {73import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests";
74 pullRequestEnvironment,74import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests";
75 usePullRequestTurnRefresh,
76 useSharedPullRequestSummary,
77} from "~/state/pullRequests";
78import { useAtomCommand } from "~/state/use-atom-command";75import { useAtomCommand } from "~/state/use-atom-command";
76import { PullRequestStackMap } from "./PullRequestStackMap";
77import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
79import { vcsEnvironment } from "~/state/vcs";78import { vcsEnvironment } from "~/state/vcs";
80import { formatRelativeTimeLabel } from "~/timestampFormat";79import { formatRelativeTimeLabel } from "~/timestampFormat";
81import { useUiStateStore } from "~/uiStateStore";80import { useUiStateStore } from "~/uiStateStore";
...@@ -452,13 +451,14 @@ function PullRequestBaseFreshnessWarning({...@@ -452,13 +451,14 @@ function PullRequestBaseFreshnessWarning({
452export function PullRequestDetailPanel({451export function PullRequestDetailPanel({
453 environmentId,452 environmentId,
454 threadRef = null,453 threadRef = null,
455 reference,454 reference: requestedReference,
456 listEntry = null,455 listEntry = null,
457 refreshToken: forcedRefreshToken = 0,456 refreshToken: forcedRefreshToken = 0,
458 onActed,457 onActed,
459 onClose,458 onClose,
460 context = "page",459 context = "page",
461 composerDraftTarget,460 composerDraftTarget,
461 onBack,
462}: {462}: {
463 environmentId: EnvironmentId;463 environmentId: EnvironmentId;
464 /**464 /**
...@@ -495,14 +495,35 @@ export function PullRequestDetailPanel({...@@ -495,14 +495,35 @@ export function PullRequestDetailPanel({
495 * land here instead of opening a new thread — the branch is already under the reader's feet.495 * land here instead of opening a new thread — the branch is already under the reader's feet.
496 */496 */
497 composerDraftTarget?: ScopedThreadRef | DraftId;497 composerDraftTarget?: ScopedThreadRef | DraftId;
498 /**
499 * Beside a thread, the way back to that thread's list of pull requests. The tab strip can
500 * close this surface, but closing is not going back: the reader came from the list and
501 * expects to land on it, with this one still open behind.
502 */
503 onBack?: (() => void) | undefined;
498}) {504}) {
499 const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`;505 const environmentConfigs = useServerConfigs();
506 const supportsThreadPullRequests =
507 environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true;
508 const reference = useMemo(
509 () =>
510 supportsThreadPullRequests
511 ? requestedReference
512 : {
513 projectId: requestedReference.projectId,
514 repository: requestedReference.repository,
515 number: requestedReference.number,
516 },
517 [requestedReference, supportsThreadPullRequests],
518 );
519 const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`;
500 const matchingListEntry =520 const matchingListEntry =
501 listEntry?.projectId === reference.projectId &&521 listEntry?.projectId === reference.projectId &&
502 listEntry.repository.toLowerCase() === reference.repository.toLowerCase() &&522 listEntry.repository.toLowerCase() === reference.repository.toLowerCase() &&
503 listEntry.number === reference.number523 listEntry.number === reference.number
504 ? listEntry524 ? listEntry
505 : null;525 : null;
526 const [threadPickerOpen, setThreadPickerOpen] = useState(false);
506 const [tab, setTab] = useState<DetailTab>("summary");527 const [tab, setTab] = useState<DetailTab>("summary");
507 const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");528 const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");
508 const [codeCommitScope, setCodeCommitScope] = useState<{529 const [codeCommitScope, setCodeCommitScope] = useState<{
...@@ -631,6 +652,11 @@ export function PullRequestDetailPanel({...@@ -631,6 +652,11 @@ export function PullRequestDetailPanel({
631 : {652 : {
632 ...resolvedCoreDetail,653 ...resolvedCoreDetail,
633 ...sharedSummary,654 ...sharedSummary,
655 author: sharedSummary.author ?? resolvedCoreDetail.author,
656 additions: sharedSummary.additions ?? resolvedCoreDetail.additions,
657 deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions,
658 changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles,
659 mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability,
634 closedAt:660 closedAt:
635 sharedSummary.closedAt === undefined661 sharedSummary.closedAt === undefined
636 ? resolvedCoreDetail.closedAt662 ? resolvedCoreDetail.closedAt
...@@ -702,6 +728,13 @@ export function PullRequestDetailPanel({...@@ -702,6 +728,13 @@ export function PullRequestDetailPanel({
702 const isStackedPullRequest =728 const isStackedPullRequest =
703 detail !== null &&729 detail !== null &&
704 isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);730 isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);
731 // The host's own stack, where it keeps one. Only asked for once the detail has landed so a
732 // pull request nobody can read costs one request rather than two.
733 const nativeStack = useEnvironmentQuery(
734 detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests
735 ? null
736 : pullRequestStackAtom({ environmentId, input: reference }),
737 ).data;
705 const activityPending = activityQuery.isPending && activity === null;738 const activityPending = activityQuery.isPending && activity === null;
706 const activityError = activity === null ? activityQuery.error : null;739 const activityError = activity === null ? activityQuery.error : null;
707 const refreshDetail = useCallback(() => {740 const refreshDetail = useCallback(() => {
...@@ -1379,6 +1412,17 @@ export function PullRequestDetailPanel({...@@ -1379,6 +1412,17 @@ export function PullRequestDetailPanel({
13791412
1380 return (1413 return (
1381 <div className="flex h-full min-h-0 w-full flex-col bg-background">1414 <div className="flex h-full min-h-0 w-full flex-col bg-background">
1415 {threadPickerOpen && detail ? (
1416 <PullRequestThreadLinks
1417 key={`${environmentId}:${detail.url}`}
1418 display="picker"
1419 environmentId={environmentId}
1420 reference={reference}
1421 url={detail.url}
1422 threadRef={null}
1423 onPickerOpenChange={setThreadPickerOpen}
1424 />
1425 ) : null}
1382 <div className="@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">1426 <div className="@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
1383 <div className="ml-4 grid h-7 min-w-0 items-center overflow-hidden">1427 <div className="ml-4 grid h-7 min-w-0 items-center overflow-hidden">
1384 <div1428 <div
...@@ -1393,6 +1437,24 @@ export function PullRequestDetailPanel({...@@ -1393,6 +1437,24 @@ export function PullRequestDetailPanel({
1393 >1437 >
1394 {detail && statePresentation ? (1438 {detail && statePresentation ? (
1395 <>1439 <>
1440 {onBack ? (
1441 <Tooltip>
1442 <TooltipTrigger
1443 render={
1444 <Button
1445 size="icon-micro"
1446 variant="ghost-muted"
1447 onClick={onBack}
1448 className="-ml-1.5"
1449 aria-label="Back to this thread's pull requests"
1450 >
1451 <ArrowLeftIcon aria-hidden className="size-3.5" />
1452 </Button>
1453 }
1454 />
1455 <TooltipPopup side="top">Back to pull requests</TooltipPopup>
1456 </Tooltip>
1457 ) : null}
1396 <Tooltip>1458 <Tooltip>
1397 <TooltipTrigger1459 <TooltipTrigger
1398 render={1460 render={
...@@ -1450,6 +1512,25 @@ export function PullRequestDetailPanel({...@@ -1450,6 +1512,25 @@ export function PullRequestDetailPanel({
1450 >1512 >
1451 {detail && statePresentation ? (1513 {detail && statePresentation ? (
1452 <>1514 <>
1515 {onBack ? (
1516 <Tooltip>
1517 <TooltipTrigger
1518 render={
1519 <Button
1520 size="icon-micro"
1521 variant="ghost-muted"
1522 tabIndex={condensed ? 0 : -1}
1523 onClick={onBack}
1524 className="-ml-1.5"
1525 aria-label="Back to this thread's pull requests"
1526 >
1527 <ArrowLeftIcon aria-hidden className="size-3.5" />
1528 </Button>
1529 }
1530 />
1531 <TooltipPopup side="top">Back to pull requests</TooltipPopup>
1532 </Tooltip>
1533 ) : null}
1453 <Tooltip>1534 <Tooltip>
1454 <TooltipTrigger1535 <TooltipTrigger
1455 render={1536 render={
...@@ -1488,6 +1569,15 @@ export function PullRequestDetailPanel({...@@ -1488,6 +1569,15 @@ export function PullRequestDetailPanel({
1488 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">1569 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
1489 {detail ? (1570 {detail ? (
1490 <>1571 <>
1572 {context === "page" ? (
1573 <PullRequestThreadLinks
1574 display="count"
1575 environmentId={environmentId}
1576 reference={reference}
1577 url={detail.url}
1578 threadRef={null}
1579 />
1580 ) : null}
1491 {/* Checking a pull request out is the reason to open one here at all, so it is a1581 {/* Checking a pull request out is the reason to open one here at all, so it is a
1492 button of its own rather than a side effect of asking an agent for something.1582 button of its own rather than a side effect of asking an agent for something.
1493 It asks where, because the two answers are not interchangeable: one leaves your1583 It asks where, because the two answers are not interchangeable: one leaves your
...@@ -1714,6 +1804,17 @@ export function PullRequestDetailPanel({...@@ -1714,6 +1804,17 @@ export function PullRequestDetailPanel({
1714 <MoreHorizontalIcon className="size-4" />1804 <MoreHorizontalIcon className="size-4" />
1715 </MenuTrigger>1805 </MenuTrigger>
1716 <MenuPopup align="end" side="bottom" className="min-w-72">1806 <MenuPopup align="end" side="bottom" className="min-w-72">
1807 <PullRequestThreadLinks
1808 display="menu-item"
1809 environmentId={environmentId}
1810 reference={reference}
1811 url={detail.url}
1812 threadRef={
1813 threadRef ??
1814 (typeof composerDraftTarget === "object" ? composerDraftTarget : null)
1815 }
1816 onPickerOpenChange={setThreadPickerOpen}
1817 />
1717 <MenuItem1818 <MenuItem
1718 disabled={isInvalidating || detailQuery.isPending}1819 disabled={isInvalidating || detailQuery.isPending}
1719 onClick={() => void refreshFromHost()}1820 onClick={() => void refreshFromHost()}
...@@ -2001,6 +2102,13 @@ export function PullRequestDetailPanel({...@@ -2001,6 +2102,13 @@ export function PullRequestDetailPanel({
2001 />2102 />
2002 </span>2103 </span>
2003 </div>2104 </div>
2105 {nativeStack ? (
2106 <PullRequestStackMap
2107 stack={nativeStack}
2108 currentNumber={detail.number}
2109 className="mt-1"
2110 />
2111 ) : null}
2004 </div>2112 </div>
2005 ) : null}2113 ) : null}
2006 </div>2114 </div>
apps/web/src/components/pullRequest/PullRequestStackMap.tsx created+87
...@@ -0,0 +1,87 @@
1import type { PullRequestStack } from "@t3tools/contracts";
2import { GitPullRequestArrowIcon } from "lucide-react";
3
4import { InlineButton } from "../ui/button";
5import { cn } from "~/lib/utils";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { resolvePullRequestState } from "./pullRequestPresentation";
8
9/**
10 * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors
11 * the map GitHub draws above a stacked pull request so a reader who came from there finds the
12 * same shape here.
13 */
14export function PullRequestStackMap({
15 stack,
16 currentNumber,
17 onSelect,
18 className,
19}: {
20 stack: PullRequestStack;
21 currentNumber: number;
22 /** Opens another layer in the same panel; absent where the panel cannot swap references. */
23 onSelect?: ((number: number) => void) | undefined;
24 className?: string;
25}) {
26 return (
27 <div
28 className={cn(
29 "flex min-w-0 items-center gap-1 overflow-x-auto text-[11px] text-muted-foreground",
30 className,
31 )}
32 aria-label={`Stack ${stack.number} on ${stack.base}, ${stack.layers.length} layers`}
33 >
34 <Tooltip>
35 <TooltipTrigger
36 render={<span className="inline-flex shrink-0 items-center gap-1 font-mono" />}
37 >
38 <GitPullRequestArrowIcon aria-hidden className="size-3" />
39 <code className="truncate">{stack.base}</code>
40 </TooltipTrigger>
41 <TooltipPopup side="top">
42 Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it.
43 </TooltipPopup>
44 </Tooltip>
45 {stack.layers.map((layer) => {
46 const presentation = resolvePullRequestState({ state: layer.state, isDraft: false });
47 const isCurrent = layer.number === currentNumber;
48 const chip = (
49 <span
50 className={cn(
51 "inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono tabular-nums",
52 isCurrent
53 ? "border-foreground/40 bg-accent text-foreground"
54 : "border-border/70 hover:bg-accent/60",
55 )}
56 >
57 <presentation.Icon aria-hidden className={cn("size-3", presentation.toneClassName)} />#
58 {layer.number}
59 </span>
60 );
61 return (
62 <span key={layer.number} className="inline-flex shrink-0 items-center gap-1">
63 <span aria-hidden className="text-muted-foreground/50">
64 →
65 </span>
66 <Tooltip>
67 <TooltipTrigger
68 render={
69 onSelect && !isCurrent ? (
70 <InlineButton onClick={() => onSelect(layer.number)} />
71 ) : (
72 <span aria-current={isCurrent ? "true" : undefined} />
73 )
74 }
75 >
76 {chip}
77 </TooltipTrigger>
78 <TooltipPopup side="top">
79 #{layer.number} · {layer.headBranch} · {presentation.label}
80 </TooltipPopup>
81 </Tooltip>
82 </span>
83 );
84 })}
85 </div>
86 );
87}
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+11-4
...@@ -314,12 +314,14 @@ function Section({...@@ -314,12 +314,14 @@ function Section({
314314
315function CommentComposer({315function CommentComposer({
316 environmentId,316 environmentId,
317 reference,
317 detail,318 detail,
318 actionPending,319 actionPending,
319 onCommentAction,320 onCommentAction,
320 onCommented,321 onCommented,
321}: {322}: {
322 environmentId: EnvironmentId;323 environmentId: EnvironmentId;
324 reference: PullRequestRef;
323 detail: PullRequestDetailView;325 detail: PullRequestDetailView;
324 actionPending: boolean;326 actionPending: boolean;
325 onCommentAction: (327 onCommentAction: (
...@@ -355,9 +357,7 @@ function CommentComposer({...@@ -355,9 +357,7 @@ function CommentComposer({
355 const result = await postComment({357 const result = await postComment({
356 environmentId,358 environmentId,
357 input: {359 input: {
358 projectId: detail.projectId,360 ...reference,
359 repository: detail.repository,
360 number: detail.number,
361 body: trimmed,361 body: trimmed,
362 },362 },
363 });363 });
...@@ -996,7 +996,14 @@ export function PullRequestSummaryTab({...@@ -996,7 +996,14 @@ export function PullRequestSummaryTab({
996 {/* Posting is a core capability and remains usable even if the activity read failed. */}996 {/* Posting is a core capability and remains usable even if the activity read failed. */}
997 {detail.capabilities.comment && detail.viewerPermissions.comment ? (997 {detail.capabilities.comment && detail.viewerPermissions.comment ? (
998 <CommentComposer998 <CommentComposer
999 key={`${environmentId}:${detail.projectId}/${detail.repository}#${detail.number}`}999 reference={reference}
1000 key={JSON.stringify([
1001 environmentId,
1002 reference.projectId,
1003 reference.host,
1004 reference.repository,
1005 reference.number,
1006 ])}
1000 environmentId={environmentId}1007 environmentId={environmentId}
1001 detail={detail}1008 detail={detail}
1002 actionPending={actionPending}1009 actionPending={actionPending}
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx created+255
...@@ -0,0 +1,255 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { Link } from "@tanstack/react-router";
3import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
4import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react";
5import { useState } from "react";
6import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
7import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
8
9import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
10import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";
11import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useEnvironmentQuery } from "~/state/query";
13import { appAtomRegistry } from "~/rpc/atomRegistry";
14import { buildThreadRouteParams } from "~/threadRoutes";
15import { Button } from "../ui/button";
16import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";
17import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
18import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
19import { toastManager } from "../ui/toast";
20
21interface PullRequestThreadLinksProps {
22 environmentId: EnvironmentId;
23 reference: PullRequestRef;
24 url: string;
25 threadRef: ScopedThreadRef | null;
26 display: "count" | "menu-item" | "picker";
27 onPickerOpenChange?: (open: boolean) => void;
28}
29
30/** Thread relations belong to the detail environment, including when another environment is active. */
31export function PullRequestThreadLinks(props: PullRequestThreadLinksProps) {
32 const configs = useServerConfigs();
33 if (
34 threadPullRequestLinkMode(configs.get(props.environmentId)?.environment.capabilities) ===
35 "unsupported"
36 ) {
37 return null;
38 }
39 return <EnabledPullRequestThreadLinks key={`${props.environmentId}:${props.url}`} {...props} />;
40}
41
42function EnabledPullRequestThreadLinks({
43 environmentId,
44 reference,
45 url,
46 threadRef,
47 display,
48 onPickerOpenChange,
49}: PullRequestThreadLinksProps) {
50 const parsed = parseChangeRequestUrl(url);
51 const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null;
52 const thread = useThreadShell(currentThreadRef);
53 const linking = usePullRequestLinking(environmentId);
54 const linkedHere = linking.isLinked(thread, url);
55 const relations = useEnvironmentQuery(
56 linking.mode === "multiple" && display !== "menu-item"
57 ? pullRequestEnvironment.linkedThreads({
58 environmentId,
59 input: parsed === null ? reference : { ...reference, ...parsed },
60 })
61 : null,
62 );
63 // Refreshes can briefly clear the query value. Keep the last response so polling
64 // does not unmount an open menu or move its highlighted thread.
65 const [lastRelations, setLastRelations] = useState(relations.data);
66 if (relations.data !== null && relations.data !== lastRelations) {
67 setLastRelations(relations.data);
68 }
69 const [pending, setPending] = useState(false);
70
71 const changeLink = async (threadId: ThreadId, remove: boolean) => {
72 if (parsed === null || pending) return;
73 setPending(true);
74 try {
75 await linking.changeLink(scopeThreadRef(environmentId, threadId), url, !remove);
76 } catch (error) {
77 toastManager.add({
78 type: "error",
79 title: remove ? "Could not unlink the pull request" : "Could not link the pull request",
80 description: error instanceof Error ? error.message : String(error),
81 });
82 return;
83 } finally {
84 setPending(false);
85 }
86 if (linking.mode === "multiple") {
87 appAtomRegistry.refresh(
88 pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }),
89 );
90 }
91 onPickerOpenChange?.(false);
92 };
93
94 if (parsed === null || (!linkedHere && !linking.canLink(url))) return null;
95 const linkedThreads =
96 linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : [];
97 const linkedThreadsLabel =
98 linkedThreads.length > 0
99 ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}`
100 : "Linked threads";
101 return (
102 <>
103 {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? (
104 <Menu>
105 <MenuTrigger
106 render={
107 <Button
108 size="xs"
109 variant="ghost"
110 aria-label={linkedThreadsLabel}
111 title={linkedThreadsLabel}
112 />
113 }
114 >
115 <MessageSquareIcon aria-hidden className="size-3.5" />
116 <span className="@max-[40rem]/pr-header:hidden">{linkedThreadsLabel}</span>
117 <span aria-hidden className="hidden @max-[40rem]/pr-header:inline">
118 {linkedThreads.length || "?"}
119 </span>
120 </MenuTrigger>
121 <MenuPopup align="end" className="max-h-72 max-w-80 overflow-y-auto">
122 {relations.error !== null ? (
123 <MenuItem onClick={relations.refresh}>Could not load linked threads. Retry</MenuItem>
124 ) : null}
125 {linkedThreads.map((linkedThread) => (
126 <MenuItem
127 key={linkedThread.id}
128 render={
129 <Link
130 to="/$environmentId/$threadId"
131 params={buildThreadRouteParams(scopeThreadRef(environmentId, linkedThread.id))}
132 />
133 }
134 >
135 <span className="min-w-0 flex-1 truncate">
136 {linkedThread.title || "Untitled thread"}
137 </span>
138 {linkedThread.archivedAt !== null ? (
139 <span className="text-xs text-muted-foreground">Archived</span>
140 ) : null}
141 </MenuItem>
142 ))}
143 </MenuPopup>
144 </Menu>
145 ) : null}
146 {display === "menu-item" ? (
147 <MenuItem
148 disabled={pending}
149 onClick={() => {
150 if (currentThreadRef !== null) {
151 void changeLink(currentThreadRef.threadId, linkedHere);
152 } else {
153 onPickerOpenChange?.(true);
154 }
155 }}
156 >
157 {linkedHere ? (
158 <UnlinkIcon aria-hidden className="size-3.5" />
159 ) : (
160 <LinkIcon aria-hidden className="size-3.5" />
161 )}
162 {linkedHere
163 ? "Unlink from this thread"
164 : currentThreadRef
165 ? "Link to this thread"
166 : "Link to thread"}
167 </MenuItem>
168 ) : null}
169 {display === "picker" ? (
170 <Dialog open onOpenChange={onPickerOpenChange}>
171 <DialogPopup className="max-w-md" showCloseButton={false}>
172 <DialogTitle className="sr-only">Link pull request to a thread</DialogTitle>
173 <ThreadPicker
174 environmentId={environmentId}
175 url={url}
176 pending={pending}
177 onSelect={(threadId) => void changeLink(threadId, false)}
178 />
179 </DialogPopup>
180 </Dialog>
181 ) : null}
182 </>
183 );
184}
185
186function ThreadPicker({
187 environmentId,
188 url,
189 pending,
190 onSelect,
191}: {
192 environmentId: EnvironmentId;
193 url: string;
194 pending: boolean;
195 onSelect: (threadId: ThreadId) => void;
196}) {
197 const threads = useThreadShells();
198 const linking = usePullRequestLinking(environmentId);
199 const projects = useProjects();
200 const [query, setQuery] = useState("");
201 const projectNames = new Map(
202 projects
203 .filter((project) => project.environmentId === environmentId)
204 .map((project) => [project.id, project.title]),
205 );
206 const search = query.trim().toLocaleLowerCase();
207 const candidates = threads
208 .filter(
209 (thread) =>
210 thread.environmentId === environmentId &&
211 thread.archivedAt === null &&
212 `${thread.title} ${projectNames.get(thread.projectId) ?? ""}`
213 .toLocaleLowerCase()
214 .includes(search),
215 )
216 .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt));
217 return (
218 <Command mode="none" value={query} onValueChange={setQuery} aria-label="Choose a thread">
219 <CommandInput placeholder="Search threads or projects..." disabled={pending} />
220 <CommandList className="max-h-80 overflow-y-auto">
221 {candidates.length === 0 ? (
222 <div className="px-3 py-6 text-center text-sm text-muted-foreground">
223 No active threads found.
224 </div>
225 ) : (
226 candidates.map((thread) => {
227 const linked = linking.isLinked(thread, url);
228 return (
229 <CommandItem
230 key={thread.id}
231 value={thread.id}
232 disabled={pending || linked}
233 onClick={() => onSelect(thread.id)}
234 >
235 <MessageSquareIcon aria-hidden className="size-4 shrink-0" />
236 <span className="flex min-w-0 flex-1 flex-col">
237 <span className="truncate">{thread.title || "Untitled thread"}</span>
238 <span className="truncate text-xs text-muted-foreground">
239 {projectNames.get(thread.projectId)}
240 </span>
241 </span>
242 {linked ? (
243 <>
244 <CheckIcon aria-hidden className="size-3.5" />
245 <span className="text-xs text-muted-foreground">Linked</span>
246 </>
247 ) : null}
248 </CommandItem>
249 );
250 })
251 )}
252 </CommandList>
253 </Command>
254 );
255}
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx created+296
...@@ -0,0 +1,296 @@
1import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
2import {
3 resolveThreadPullRequestChains,
4 visibleThreadPullRequests,
5} from "@t3tools/shared/threadPullRequests";
6import {
7 GitPullRequestArrow,
8 LayersIcon,
9 LinkIcon,
10 MoreHorizontalIcon,
11 PlusIcon,
12} from "lucide-react";
13import { useCallback, useMemo } from "react";
14
15import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
16import { useOpenPrLink } from "~/lib/openPullRequestLink";
17import { cn } from "~/lib/utils";
18import { useServerConfigs, useThreadShell } from "~/state/entities";
19import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
20import { threadEnvironment } from "~/state/threads";
21import { useAtomCommand } from "~/state/use-atom-command";
22import { formatRelativeTimeLabel } from "~/timestampFormat";
23import { Button } from "../ui/button";
24import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
25import { ScrollArea } from "../ui/scroll-area";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
27import { openLinkPullRequestDialog } from "./LinkPullRequestDialog";
28import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines";
29import {
30 PullRequestActorAvatar,
31 PullRequestDiffStat,
32 PullRequestStateGlyph,
33 pullRequestChecksStatePresentation,
34} from "./pullRequestPresentation";
35
36const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
37 manual: "Linked by you",
38 created: "Created from this thread",
39 agent: "Linked by the agent",
40 stack: "Found in the stack",
41 "stack-dismissed": "Dismissed",
42};
43
44function ChecksGlyph({
45 state,
46}: {
47 state: NonNullable<ThreadPullRequestLink["snapshot"]>["checksState"] & string;
48}) {
49 const presentation = pullRequestChecksStatePresentation(state);
50 return (
51 <Tooltip>
52 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
53 <presentation.Icon
54 role="img"
55 aria-label={presentation.label}
56 className={cn("size-3.5", presentation.toneClassName)}
57 />
58 </TooltipTrigger>
59 <TooltipPopup>{presentation.label}</TooltipPopup>
60 </Tooltip>
61 );
62}
63
64function LinkRow({
65 line,
66 threadRef,
67 onUnlink,
68}: {
69 line: PullRequestListLine;
70 threadRef: ScopedThreadRef;
71 onUnlink: (link: ThreadPullRequestLink) => void;
72}) {
73 const openPrLink = useOpenPrLink(threadRef);
74 const { link, depth, stack } = line;
75 const snapshot = link.snapshot;
76 return (
77 <div
78 className="group/pr-row flex items-center gap-2 rounded-md py-1 pr-1 hover:bg-accent/60"
79 // Each layer steps in under the one it targets. The step is capped: beyond a few layers
80 // the indent only says "still in the stack", which the connector line already does, and
81 // a sixteen-layer stack would otherwise stair-step off the right edge.
82 style={{ paddingLeft: `${0.5 + Math.min(depth, 3) * 1.25}rem` }}
83 >
84 {depth > 0 ? <span aria-hidden className="-ml-2 h-6 w-px shrink-0 bg-border/70" /> : null}
85 {snapshot === null ? (
86 <GitPullRequestArrow
87 aria-label="Waiting for host state"
88 className="size-4 shrink-0 text-muted-foreground"
89 />
90 ) : (
91 <PullRequestStateGlyph state={snapshot.state} isDraft={snapshot.isDraft} />
92 )}
93 <a
94 href={link.url}
95 onClick={(event) => openPrLink(event, link.url, threadRef)}
96 className="min-w-0 flex-1"
97 >
98 <span className="flex min-w-0 items-center gap-1.5">
99 <Tooltip>
100 <TooltipTrigger
101 render={
102 <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground" />
103 }
104 >
105 #{link.number}
106 </TooltipTrigger>
107 <TooltipPopup>
108 {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)}
109 </TooltipPopup>
110 </Tooltip>
111 <span className="min-w-0 flex-1 truncate text-sm">
112 {snapshot?.title ?? link.repository}
113 </span>
114 {/* Right-aligned signals, in the order a reviewer scans them: are checks green,
115 has someone ruled, how big is it. Each is absent rather than neutral when the
116 host said nothing, so a row without them reads as unknown, not as fine. */}
117 <span className="ml-auto flex shrink-0 items-center gap-1.5 text-[11px]">
118 {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}
119 {snapshot?.state === "open" &&
120 (snapshot.reviewDecision === "approved" ||
121 snapshot.reviewDecision === "changes-requested") ? (
122 <span
123 className={cn(
124 snapshot.reviewDecision === "approved"
125 ? "text-emerald-600/90 dark:text-emerald-400/80"
126 : "text-amber-600/90 dark:text-amber-400/80",
127 )}
128 >
129 {snapshot.reviewDecision === "approved" ? "Approved" : "Changes requested"}
130 </span>
131 ) : null}
132 {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? (
133 <span className="text-destructive">Conflicts</span>
134 ) : null}
135 <PullRequestDiffStat
136 additions={snapshot?.additions ?? 0}
137 deletions={snapshot?.deletions ?? 0}
138 className="font-mono"
139 />
140 </span>
141 </span>
142 <span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
143 {stack ? (
144 <Tooltip>
145 <TooltipTrigger
146 render={
147 <span className="inline-flex shrink-0 items-center gap-0.5 text-foreground/70" />
148 }
149 >
150 {stack.kind === "native" ? (
151 <LayersIcon aria-hidden className="size-3" />
152 ) : (
153 <GitPullRequestArrow aria-hidden className="size-3" />
154 )}
155 {stack.size}
156 </TooltipTrigger>
157 <TooltipPopup>
158 {stack.kind === "native"
159 ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.`
160 : `${stack.size} pull requests chained by base branch.`}
161 </TooltipPopup>
162 </Tooltip>
163 ) : null}
164 {snapshot?.author ? (
165 <span className="inline-flex shrink-0 items-center gap-1">
166 <PullRequestActorAvatar actor={snapshot.author} className="size-3.5" />
167 <span className="max-w-28 truncate">{snapshot.author.login}</span>
168 </span>
169 ) : null}
170 <span className="truncate font-mono">
171 {snapshot !== null
172 ? `${snapshot.headBranch} → ${snapshot.baseBranch}`
173 : `${link.host}/${link.repository}`}
174 </span>
175 {snapshot?.updatedAt ? (
176 <span className="shrink-0">· {formatRelativeTimeLabel(snapshot.updatedAt)}</span>
177 ) : null}
178 </span>
179 </a>
180 <Menu>
181 <MenuTrigger
182 render={
183 <Button
184 variant="ghost"
185 size="icon-xs"
186 aria-label={`Actions for #${link.number}`}
187 className={cn(
188 "opacity-0 group-hover/pr-row:opacity-100 data-[popup-open]:opacity-100",
189 )}
190 >
191 <MoreHorizontalIcon className="size-3.5" />
192 </Button>
193 }
194 />
195 <MenuPopup align="end" side="bottom">
196 <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>Copy link</MenuItem>
197 <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>Open</MenuItem>
198 <MenuItem onClick={() => onUnlink(link)}>
199 {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}
200 </MenuItem>
201 </MenuPopup>
202 </Menu>
203 </div>
204 );
205}
206
207export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
208 const configs = useServerConfigs();
209 if (configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true) {
210 return (
211 <PullRequestsUnavailableState
212 title="Linked pull requests unavailable"
213 error="This environment does not support multiple linked pull requests."
214 />
215 );
216 }
217 return <EnabledThreadPullRequestsPanel threadRef={threadRef} />;
218}
219
220function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
221 const thread = useThreadShell(threadRef);
222 const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);
223 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });
224 const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);
225 const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);
226 const handleUnlink = useCallback(
227 (link: ThreadPullRequestLink) => {
228 void unlink({
229 environmentId: threadRef.environmentId,
230 input: {
231 threadId: threadRef.threadId,
232 host: link.host,
233 repository: link.repository,
234 number: link.number,
235 },
236 });
237 },
238 [threadRef, unlink],
239 );
240 const openCount = useMemo(
241 () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length,
242 [links],
243 );
244 const lastSynced = useMemo(() => {
245 let latest: string | null = null;
246 for (const link of links) {
247 const at = link.snapshot?.syncedAt;
248 if (at !== undefined && (latest === null || at > latest)) latest = at;
249 }
250 return latest;
251 }, [links]);
252
253 if (links.length === 0) {
254 return (
255 <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
256 <LinkIcon aria-hidden className="size-6 text-muted-foreground/60" />
257 <p className="text-sm font-medium">No linked pull requests</p>
258 <p className="max-w-60 text-xs text-muted-foreground">
259 Pull requests the agent opens from this thread land here. Link one yourself from a URL or
260 a number.
261 </p>
262 <Button size="sm" variant="outline" onClick={openLinkDialog}>
263 <PlusIcon className="size-3.5" />
264 Link pull request
265 </Button>
266 </div>
267 );
268 }
269
270 return (
271 <div className="flex h-full min-h-0 flex-col">
272 <ScrollArea className="min-h-0 flex-1">
273 <div className="flex flex-col p-1.5">
274 {lines.map((line) => (
275 <LinkRow
276 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
277 line={line}
278 threadRef={threadRef}
279 onUnlink={handleUnlink}
280 />
281 ))}
282 </div>
283 </ScrollArea>
284 <footer className="flex items-center justify-between border-t border-border/60 px-2 py-1.5 text-[.7rem] text-muted-foreground">
285 <span>
286 {openCount} open · {links.length} linked
287 {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""}
288 </span>
289 <Button size="xs" variant="ghost" onClick={openLinkDialog}>
290 <PlusIcon className="size-3.5" />
291 Link
292 </Button>
293 </footer>
294 </div>
295 );
296}
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+36
...@@ -1420,6 +1420,42 @@ describe("cached pull request detail", () => {...@@ -1420,6 +1420,42 @@ describe("cached pull request detail", () => {
1420 expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull();1420 expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull();
1421 });1421 });
14221422
1423 it("isolates stored and displayed details between hosts with the same repository and number", () => {
1424 const storage = makeStorage();
1425 const publicRef = { ...reference, host: "github.com" };
1426 const enterpriseRef = { ...reference, host: "github.example.com" };
1427 const publicDetail = detail();
1428 const enterpriseDetail = detail({
1429 title: "Enterprise change",
1430 url: "https://github.example.com/acme/web/pull/7",
1431 });
1432 writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail);
1433 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
1434 writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, enterpriseDetail);
1435 expect(readPullRequestDetailSnapshot(storage, "env-1", publicRef)?.title).toBe(
1436 publicDetail.title,
1437 );
1438 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)?.title).toBe(
1439 enterpriseDetail.title,
1440 );
1441 expect(
1442 resolveDisplayedPullRequestDetail({
1443 live: null,
1444 cached: publicDetail,
1445 reference: enterpriseRef,
1446 }),
1447 ).toBeNull();
1448 expect(
1449 resolveDisplayedPullRequestDetail({
1450 live: null,
1451 cached: enterpriseDetail,
1452 reference: enterpriseRef,
1453 }),
1454 ).toBe(enterpriseDetail);
1455 writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, publicDetail);
1456 expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull();
1457 });
1458
1423 it("shrugs off corrupt storage and no storage at all", () => {1459 it("shrugs off corrupt storage and no storage at all", () => {
1424 const storage = makeStorage();1460 const storage = makeStorage();
1425 storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json");1461 storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json");
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+11-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
23
3import {4import {
4 PullRequestDetail,5 PullRequestDetail,
...@@ -1028,6 +1029,7 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo...@@ -1028,6 +1029,7 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo
1028type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;1029type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;
10291030
1030export interface PullRequestDetailSnapshotRef {1031export interface PullRequestDetailSnapshotRef {
1032 readonly host?: string | undefined;
1031 readonly projectId: string;1033 readonly projectId: string;
1032 readonly repository: string;1034 readonly repository: string;
1033 readonly number: number;1035 readonly number: number;
...@@ -1037,7 +1039,9 @@ const pullRequestDetailSnapshotKey = (...@@ -1037,7 +1039,9 @@ const pullRequestDetailSnapshotKey = (
1037 environmentId: string,1039 environmentId: string,
1038 reference: PullRequestDetailSnapshotRef,1040 reference: PullRequestDetailSnapshotRef,
1039) =>1041) =>
1040 `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`;1042 reference.host
1043 ? `t3.pullRequests.detail:${JSON.stringify([environmentId, reference.projectId, reference.host.toLowerCase(), reference.repository.toLowerCase(), reference.number])}`
1044 : `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`;
10411045
1042const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail);1046const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail);
10431047
...@@ -1056,7 +1060,9 @@ export function readPullRequestDetailSnapshot(...@@ -1056,7 +1060,9 @@ export function readPullRequestDetailSnapshot(
1056 const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference));1060 const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference));
1057 if (!raw) return null;1061 if (!raw) return null;
1058 const decoded = decodeDetailSnapshot(JSON.parse(raw));1062 const decoded = decodeDetailSnapshot(JSON.parse(raw));
1059 return decoded._tag === "Some" ? decoded.value : null;1063 return decoded._tag === "Some"
1064 ? resolveDisplayedPullRequestDetail({ live: null, cached: decoded.value, reference })
1065 : null;
1060 } catch {1066 } catch {
1061 return null;1067 return null;
1062 }1068 }
...@@ -1090,7 +1096,9 @@ export function resolveDisplayedPullRequestDetail(input: {...@@ -1090,7 +1096,9 @@ export function resolveDisplayedPullRequestDetail(input: {
1090 input.cached !== null &&1096 input.cached !== null &&
1091 input.cached.projectId === input.reference.projectId &&1097 input.cached.projectId === input.reference.projectId &&
1092 input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() &&1098 input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() &&
1093 input.cached.number === input.reference.number1099 input.cached.number === input.reference.number &&
1100 (input.reference.host === undefined ||
1101 parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase())
1094 ) {1102 ) {
1095 return input.cached;1103 return input.cached;
1096 }1104 }
apps/web/src/components/pullRequest/pullRequestListLines.test.ts created+76
...@@ -0,0 +1,76 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
2import { resolveThreadPullRequestChains } from "@t3tools/shared/threadPullRequests";
3import { describe, expect, it } from "vite-plus/test";
4
5import { pullRequestListLines } from "./pullRequestListLines";
6
7function link(
8 number: number,
9 head: string,
10 base: string,
11 updatedAt: string,
12 stack: ThreadPullRequestLink["stack"] = null,
13): ThreadPullRequestLink {
14 return {
15 host: "github.com",
16 repository: "acme/web",
17 number,
18 url: `https://github.com/acme/web/pull/${number}`,
19 source: "manual",
20 linkedAt: "2026-01-01T00:00:00.000Z",
21 snapshot: {
22 state: "open",
23 title: `PR ${number}`,
24 headBranch: head,
25 baseBranch: base,
26 isDraft: false,
27 updatedAt,
28 syncedAt: updatedAt,
29 },
30 stack,
31 };
32}
33
34describe("pullRequestListLines", () => {
35 it("orders newest first and keeps a stack together under its base layer", () => {
36 const lines = pullRequestListLines(
37 resolveThreadPullRequestChains([
38 link(1, "a", "main", "2026-01-01T10:00:00Z"),
39 link(2, "b", "a", "2026-01-01T12:00:00Z"),
40 link(9, "solo", "main", "2026-01-01T11:00:00Z"),
41 link(5, "old", "main", "2026-01-01T09:00:00Z"),
42 ]),
43 );
44 expect(lines.map((line) => [line.link.number, line.depth, line.stack?.size ?? null])).toEqual([
45 // The stack's newest layer is #2 at 12:00, so the whole stack outranks #9 at 11:00.
46 [1, 0, 2],
47 [2, 1, null],
48 [9, 0, null],
49 [5, 0, null],
50 ]);
51 });
52
53 it("marks native stacks on their base layer", () => {
54 const stack = {
55 kind: "native" as const,
56 id: "1",
57 number: 1,
58 url: "https://github.com/acme/web/stacks/1",
59 base: "main",
60 layers: [
61 { number: 3, headBranch: "x", state: "open" as const },
62 { number: 4, headBranch: "y", state: "open" as const },
63 ],
64 };
65 const lines = pullRequestListLines(
66 resolveThreadPullRequestChains([
67 link(4, "y", "x", "2026-01-01T10:00:00Z", stack),
68 link(3, "x", "main", "2026-01-01T10:00:00Z", stack),
69 ]),
70 );
71 expect(lines.map((line) => [line.link.number, line.depth, line.stack?.kind ?? null])).toEqual([
72 [3, 0, "native"],
73 [4, 1, null],
74 ]);
75 });
76});
apps/web/src/components/pullRequest/pullRequestListLines.ts created+49
...@@ -0,0 +1,49 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
2import type { ThreadPullRequestChain } from "@t3tools/shared/threadPullRequests";
3
4/** One line of a thread's pull-request list: a link plus how deep it sits in its stack. */
5export interface PullRequestListLine {
6 readonly link: ThreadPullRequestLink;
7 /** 0 for a pull request on the base branch; each layer above steps in by one. */
8 readonly depth: number;
9 /** Which chain the line belongs to, so callers can tell one stack's lines from another's. */
10 readonly chainKey: string;
11 /** Set on the bottom layer of a multi-layer stack, so that row can name the whole stack. */
12 readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null;
13}
14
15function activityAt(link: ThreadPullRequestLink): number {
16 const ms = Date.parse(link.snapshot?.updatedAt ?? link.linkedAt);
17 return Number.isNaN(ms) ? 0 : ms;
18}
19
20function chainKeyOf(chain: ThreadPullRequestChain): string {
21 const bottom = chain.layers[0]!;
22 return `${bottom.host}/${bottom.repository}#${bottom.number}`;
23}
24
25/**
26 * Flattens chains into indented lines, newest first. A stack sorts by its most recent layer and
27 * then reads bottom to top beneath that slot, so the layer you would review first is at the
28 * bottom of the indent and a fresh push anywhere in the stack floats the whole stack up.
29 */
30export function pullRequestListLines(
31 chains: ReadonlyArray<ThreadPullRequestChain>,
32): ReadonlyArray<PullRequestListLine> {
33 const ordered = [...chains].sort(
34 (left, right) =>
35 Math.max(...right.layers.map(activityAt)) - Math.max(...left.layers.map(activityAt)),
36 );
37 return ordered.flatMap((chain) => {
38 const chainKey = chainKeyOf(chain);
39 return chain.layers.map((link, depth) => ({
40 link,
41 depth,
42 chainKey,
43 stack:
44 depth === 0 && chain.layers.length > 1
45 ? { kind: chain.kind, size: chain.layers.length }
46 : null,
47 }));
48 });
49}
apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts+30-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { beforeEach, describe, expect, it } from "vite-plus/test";1import { beforeEach, describe, expect, it } from "vite-plus/test";
2import { ProjectId } from "@t3tools/contracts";
23
3import { type PendingReviewComment, usePullRequestReviewStore } from "./pullRequestReviewStore";4import {
5 type PendingReviewComment,
6 pullRequestReviewKey,
7 usePullRequestReviewStore,
8} from "./pullRequestReviewStore";
49
5function comment(id: string, body = id): PendingReviewComment {10function comment(id: string, body = id): PendingReviewComment {
6 return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } };11 return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } };
...@@ -36,6 +41,30 @@ describe("pull request review drafts", () => {...@@ -36,6 +41,30 @@ describe("pull request review drafts", () => {
36 });41 });
37 });42 });
3843
44 it("keeps drafts on different hosts separate when a thread reviews the same repository and number", () => {
45 const reference = {
46 projectId: ProjectId.make("project-a"),
47 repository: "owner/repo",
48 number: 7,
49 };
50 const publicKey = pullRequestReviewKey({ ...reference, host: "github.com" });
51 const enterpriseKey = pullRequestReviewKey({ ...reference, host: "github.example.com" });
52 const store = usePullRequestReviewStore.getState();
53 store.addComment(publicKey, comment("public"));
54 store.setSummary(publicKey, "Public review");
55
56 expect(usePullRequestReviewStore.getState().drafts[enterpriseKey]).toBeUndefined();
57 expect(usePullRequestReviewStore.getState().summaries[enterpriseKey]).toBeUndefined();
58
59 store.addComment(enterpriseKey, comment("enterprise"));
60 store.setSummary(enterpriseKey, "Enterprise review");
61 store.clear(enterpriseKey);
62 store.clearSummary(enterpriseKey, "Enterprise review");
63
64 expect(usePullRequestReviewStore.getState().drafts[publicKey]).toEqual([comment("public")]);
65 expect(usePullRequestReviewStore.getState().summaries[publicKey]).toBe("Public review");
66 });
67
39 it("does not clear a summary revised while submission is in flight", () => {68 it("does not clear a summary revised while submission is in flight", () => {
40 const store = usePullRequestReviewStore.getState();69 const store = usePullRequestReviewStore.getState();
41 store.setSummary("review-a", "Submitted body");70 store.setSummary("review-a", "Submitted body");
apps/web/src/components/pullRequest/pullRequestReviewStore.ts+11-8
...@@ -6,7 +6,7 @@...@@ -6,7 +6,7 @@
6 * hosts that have no pending review of their own. That also means a draft lives only as long6 * hosts that have no pending review of their own. That also means a draft lives only as long
7 * as the tab does, which is why this is deliberately not persisted.7 * as the tab does, which is why this is deliberately not persisted.
8 */8 */
9import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";9import type { PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";
10import { create } from "zustand";10import { create } from "zustand";
1111
12export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string };12export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string };
...@@ -24,9 +24,14 @@ export function nextPendingReviewCommentId(): string {...@@ -24,9 +24,14 @@ export function nextPendingReviewCommentId(): string {
24 return `pending-review-comment-${pendingCommentSequence}`;24 return `pending-review-comment-${pendingCommentSequence}`;
25}25}
2626
27/** One pull request's draft, scoped by project as well as repository: a repository can be checked out twice. */27/** A project's thread can review the same repository path and number on different hosts. */
28export function pullRequestReviewKey(reference: PullRequestRef): string {28export function pullRequestReviewKey(reference: PullRequestRef): string {
29 return `${reference.projectId}/${reference.repository}#${reference.number}`;29 return JSON.stringify([
30 reference.projectId,
31 reference.host?.toLowerCase() ?? null,
32 reference.repository.toLowerCase(),
33 reference.number,
34 ]);
30}35}
3136
32interface PullRequestReviewStoreState {37interface PullRequestReviewStoreState {
...@@ -81,11 +86,9 @@ export const usePullRequestReviewStore = create<PullRequestReviewStoreState>()((...@@ -81,11 +86,9 @@ export const usePullRequestReviewStore = create<PullRequestReviewStoreState>()((
81}));86}));
8287
83/** The comments a pull request's draft holds, stable across renders while it is empty. */88/** The comments a pull request's draft holds, stable across renders while it is empty. */
84export function usePendingReviewComments(reference: {89export function usePendingReviewComments(
85 readonly projectId: ProjectId;90 reference: PullRequestRef,
86 readonly repository: string;91): ReadonlyArray<PendingReviewComment> {
87 readonly number: number;
88}): ReadonlyArray<PendingReviewComment> {
89 return usePullRequestReviewStore(92 return usePullRequestReviewStore(
90 (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY,93 (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY,
91 );94 );
apps/web/src/components/ui/button.tsx+21
...@@ -23,6 +23,7 @@ const buttonVariants = cva(...@@ -23,6 +23,7 @@ const buttonVariants = cva(
23 "icon-lg": "size-10 sm:size-9",23 "icon-lg": "size-10 sm:size-9",
24 "icon-micro":24 "icon-micro":
25 "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3",25 "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3",
26 "icon-tiny": "size-4 p-0 [&_svg:not([class*='size-'])]:size-3",
26 "icon-sm": "size-8 sm:size-7",27 "icon-sm": "size-8 sm:size-7",
27 "icon-xl":28 "icon-xl":
28 "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",29 "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
...@@ -85,3 +86,23 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {...@@ -85,3 +86,23 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {
85}86}
8687
87export { Button, buttonVariants };88export { Button, buttonVariants };
89
90/** An inline action that keeps the geometry of surrounding text or a graph node. */
91export function InlineButton({
92 className,
93 underline = false,
94 ...props
95}: React.ComponentProps<"button"> & { underline?: boolean }) {
96 return (
97 <button
98 type="button"
99 data-slot="inline-button"
100 className={cn(
101 "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64",
102 underline && "border-b border-transparent hover:border-current",
103 className,
104 )}
105 {...props}
106 />
107 );
108}
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+5
...@@ -11,6 +11,7 @@ import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanel...@@ -11,6 +11,7 @@ import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanel
11import { useProject } from "../state/entities";11import { useProject } from "../state/entities";
12import { pullRequestEnvironment } from "../state/pullRequests";12import { pullRequestEnvironment } from "../state/pullRequests";
13import { useEnvironmentQuery } from "../state/query";13import { useEnvironmentQuery } from "../state/query";
14import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests";
1415
15export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {16export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
16 const surface = useRightPanelStore((state) =>17 const surface = useRightPanelStore((state) =>
...@@ -20,6 +21,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {...@@ -20,6 +21,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
20 const environmentId = reference?.environmentId21 const environmentId = reference?.environmentId
21 ? EnvironmentId.make(reference.environmentId)22 ? EnvironmentId.make(reference.environmentId)
22 : threadRef?.environmentId;23 : threadRef?.environmentId;
24 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null);
23 const project = useProject(25 const project = useProject(
24 reference && environmentId26 reference && environmentId
25 ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId))27 ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId))
...@@ -31,6 +33,9 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {...@@ -31,6 +33,9 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
31 environmentId,33 environmentId,
32 input: {34 input: {
33 projectId: ProjectId.make(reference.projectId),35 projectId: ProjectId.make(reference.projectId),
36 ...(supportsMultiplePullRequests && reference.host !== undefined
37 ? { host: reference.host }
38 : {}),
34 repository: reference.repository,39 repository: reference.repository,
35 number: reference.number,40 number: reference.number,
36 },41 },
apps/web/src/hooks/usePullRequestLinking.ts created+104
...@@ -0,0 +1,104 @@
1import { useMemo } from "react";
2import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
3import type {
4 EnvironmentId,
5 ScopedThreadRef,
6 ThreadLinkedPullRequest,
7 ThreadPullRequestLink,
8} from "@t3tools/contracts";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
13import {
14 planThreadPullRequestMutation,
15 threadPullRequestLinkMode,
16} from "@t3tools/client-runtime/thread-pull-request-compatibility";
17import {
18 threadPullRequestKeysEqual,
19 visibleThreadPullRequests,
20} from "@t3tools/shared/threadPullRequests";
21import {
22 findProjectForChangeRequest,
23 findProjectOnChangeRequestHost,
24 matchesLinkedPullRequestUrl,
25 parseChangeRequestUrl,
26} from "~/lib/openPullRequestLink";
27import { useProjects, useServerConfigs } from "~/state/entities";
28import { threadEnvironment } from "~/state/threads";
29import { useAtomCommand } from "~/state/use-atom-command";
30
31/** Routes link actions through the command advertised by this environment. */
32export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {
33 const configs = useServerConfigs();
34 const projects = useProjects();
35 const capabilities =
36 environmentId == null ? undefined : configs.get(environmentId)?.environment.capabilities;
37 const mode = threadPullRequestLinkMode(capabilities);
38 const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false });
39 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: false });
40 const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false });
41 return useMemo(() => {
42 const environmentProjects = projects.filter(
43 (project) => project.environmentId === environmentId,
44 );
45 const canLink = (url: string) => {
46 const parsed = parseChangeRequestUrl(url);
47 if (parsed === null || mode === "unsupported") return false;
48 return (
49 (mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)(
50 environmentProjects,
51 parsed,
52 ) !== undefined
53 );
54 };
55 const isLinked = (
56 thread: {
57 readonly pullRequests?: readonly ThreadPullRequestLink[];
58 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
59 } | null,
60 url: string,
61 ) => {
62 if (thread === null || mode === "unsupported") return false;
63 if (mode !== "multiple")
64 return (
65 thread.linkedPullRequest != null &&
66 matchesLinkedPullRequestUrl(thread.linkedPullRequest, url)
67 );
68 const parsed = parseChangeRequestUrl(url);
69 return (
70 parsed !== null &&
71 visibleThreadPullRequests(thread.pullRequests ?? []).some((entry) =>
72 threadPullRequestKeysEqual(entry, parsed),
73 )
74 );
75 };
76 const changeLink = async (threadRef: ScopedThreadRef, url: string, linked: boolean) => {
77 const parsed = parseChangeRequestUrl(url);
78 if (parsed === null || threadRef.environmentId !== environmentId || (linked && !canLink(url)))
79 throw new Error("The pull request is not available in this environment.");
80 const legacyProject = findProjectForChangeRequest(environmentProjects, parsed);
81 const mutation = planThreadPullRequestMutation({
82 capabilities,
83 threadId: threadRef.threadId,
84 reference: { ...parsed, url },
85 legacyProjectId: legacyProject?.id ?? null,
86 legacyRepository:
87 sourceControlRepositorySelector(legacyProject?.repositoryIdentity) ?? undefined,
88 linked,
89 });
90 if (mutation === null)
91 throw new Error("This environment does not support linking this pull request.");
92 const result = await (mutation.type === "thread.meta.update"
93 ? updateMetadata({ environmentId: threadRef.environmentId, input: mutation.input })
94 : mutation.type === "thread.pull-request.link"
95 ? link({ environmentId: threadRef.environmentId, input: mutation.input })
96 : unlink({ environmentId: threadRef.environmentId, input: mutation.input }));
97 if (result._tag === "Failure") {
98 if (isAtomCommandInterrupted(result)) throw new Error("Link update interrupted.");
99 throw squashAtomCommandFailure(result);
100 }
101 };
102 return { mode, canLink, isLinked, changeLink };
103 }, [capabilities, environmentId, link, mode, projects, unlink, updateMetadata]);
104}
apps/web/src/hooks/useSupportsMultiplePullRequests.ts created+10
...@@ -0,0 +1,10 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { useServerConfigs } from "~/state/entities";
3
4export function useSupportsMultiplePullRequests(environmentId: EnvironmentId | null): boolean {
5 const configs = useServerConfigs();
6 return (
7 environmentId !== null &&
8 configs.get(environmentId)?.environment.capabilities.threadPullRequests === true
9 );
10}
apps/web/src/lib/openPullRequestLink.test.ts+76
...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
3import {3import {
4 changeRequestRepositoryUrl,4 changeRequestRepositoryUrl,
5 findProjectForChangeRequest,5 findProjectForChangeRequest,
6 findProjectOnChangeRequestHost,
6 gitHubPullRequestBrowserUrl,7 gitHubPullRequestBrowserUrl,
7 matchesLinkedPullRequestUrl,8 matchesLinkedPullRequestUrl,
8 parseChangeRequestUrl,9 parseChangeRequestUrl,
...@@ -295,6 +296,81 @@ describe("parseChangeRequestUrl", () => {...@@ -295,6 +296,81 @@ describe("parseChangeRequestUrl", () => {
295 });296 });
296});297});
297298
299describe("findProjectOnChangeRequestHost", () => {
300 const project = (id: string, identity: Record<string, unknown>) =>
301 ({ id, repositoryIdentity: identity }) as never;
302 const frontend = project("frontend", {
303 canonicalKey: "github.com/acme/frontend",
304 provider: "github",
305 owner: "acme",
306 name: "frontend",
307 });
308 const backend = project("backend", {
309 canonicalKey: "github.com/acme/backend",
310 provider: "github",
311 owner: "acme",
312 name: "backend",
313 });
314
315 it.each([
316 "ssh.dev.azure.com/v3/org-a/project/web",
317 "vs-ssh.visualstudio.com/v3/org-a/project/web",
318 "org-a.visualstudio.com/defaultcollection/project/_git/web",
319 "dev.azure.com/org-a/project/_git/web",
320 ])("matches Azure browser references against %s", (canonicalKey) => {
321 const checkout = project("azure", {
322 canonicalKey,
323 provider: "azure-devops",
324 displayName: canonicalKey.split("/").slice(1).join("/"),
325 });
326 const reference = { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 42 };
327 expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout);
328 expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout);
329 expect(
330 findProjectOnChangeRequestHost([checkout], {
331 ...reference,
332 repository: "org-b/project/_git/web",
333 }),
334 ).toBeUndefined();
335 expect(
336 findProjectOnChangeRequestHost([checkout], {
337 ...reference,
338 repository: "org-a/other-project/_git/web",
339 }),
340 ).toBeUndefined();
341 });
342
343 it("prefers the project checked out from the link's own repository", () => {
344 expect(
345 findProjectOnChangeRequestHost([frontend, backend], {
346 host: "github.com",
347 repository: "acme/backend",
348 number: 7,
349 }),
350 ).toBe(backend);
351 });
352
353 it("lends any project on the host to a repository nobody has checked out", () => {
354 expect(
355 findProjectOnChangeRequestHost([frontend], {
356 host: "github.com",
357 repository: "acme/backend",
358 number: 7,
359 }),
360 ).toBe(frontend);
361 });
362
363 it("finds nothing on a host nothing is checked out from", () => {
364 expect(
365 findProjectOnChangeRequestHost([frontend], {
366 host: "gitlab.com",
367 repository: "acme/backend",
368 number: 7,
369 }),
370 ).toBeUndefined();
371 });
372});
373
298describe("findProjectForChangeRequest", () => {374describe("findProjectForChangeRequest", () => {
299 const project = (identity: Record<string, unknown>) =>375 const project = (identity: Record<string, unknown>) =>
300 ({ id: "p1", repositoryIdentity: identity }) as never;376 ({ id: "p1", repositoryIdentity: identity }) as never;
apps/web/src/lib/openPullRequestLink.ts+76-182
...@@ -1,13 +1,13 @@...@@ -1,13 +1,13 @@
1import type {1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
2 EnvironmentId,
3 RepositoryIdentity,
4 ScopedThreadRef,
5 ThreadLinkedPullRequest,
6} from "@t3tools/contracts";
7import { useNavigate } from "@tanstack/react-router";2import { useNavigate } from "@tanstack/react-router";
8import { type MouseEvent, useCallback } from "react";3import { type MouseEvent, useCallback } from "react";
94
10import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";5import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
6import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
7import {
8 canonicalRepositoryKey,
9 sourceControlRepositorySelector,
10} from "@t3tools/shared/sourceControl";
1111
12import { useOpenLink } from "../browser/useOpenLink";12import { useOpenLink } from "../browser/useOpenLink";
13import { stackedThreadToast, toastManager } from "../components/ui/toast";13import { stackedThreadToast, toastManager } from "../components/ui/toast";
...@@ -17,176 +17,14 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";...@@ -17,176 +17,14 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
17import { useProjects, useServerConfigs } from "../state/entities";17import { useProjects, useServerConfigs } from "../state/entities";
18import { usePrimaryEnvironmentId } from "../state/environments";18import { usePrimaryEnvironmentId } from "../state/environments";
1919
20/** Builds a GitHub URL that remains available when the pull request API cannot be read. */20export {
21export function gitHubPullRequestBrowserUrl(21 parseChangeRequestUrl,
22 identity: RepositoryIdentity | null | undefined,22 type ChangeRequestLink,
23 repository: string,23 gitHubPullRequestBrowserUrl,
24 number: number,24 pullRequestCandidateUrlFromReferenceAutolink,
25): string | null {25 matchesLinkedPullRequestUrl,
26 if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null;26 changeRequestRepositoryUrl,
27 const repositoryPath = repository.split("/");27} from "@t3tools/shared/changeRequestUrl";
28 if (
29 repositoryPath.length !== 2 ||
30 repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..")
31 ) {
32 return null;
33 }
34
35 let origin: string | null = null;
36 try {
37 const remoteUrl = new URL(identity.locator.remoteUrl.trim());
38 if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") {
39 origin = remoteUrl.origin;
40 }
41 } catch {
42 // SCP-style remotes are read from their normalized identity below.
43 }
44 const hostname = identity.canonicalKey.split("/")[0];
45 if (origin === null && !hostname) return null;
46
47 try {
48 const url = new URL(origin ?? `https://${hostname}`);
49 url.pathname = `/${repositoryPath.join("/")}/pull/${number}`;
50 return url.toString();
51 } catch {
52 return null;
53 }
54}
55
56/**
57 * A change request the page can open, named the way the page names one: the host below which the
58 * repository is addressed, the repository path as that host writes it, and the number.
59 *
60 * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce
61 * from a git remote — lower case, no port, the full path below the host — because the page matches
62 * a link against those. Anything else opens nothing.
63 */
64export interface ChangeRequestLink {
65 readonly host: string;
66 readonly repository: string;
67 readonly number: number;
68}
69
70/** The host itself, one of its subdomains, or an install named after the provider. */
71function isHostOf(hostname: string, apex: string, label?: string): boolean {
72 if (hostname === apex || hostname.endsWith(`.${apex}`)) return true;
73 return label !== undefined && hostname.startsWith(`${label}.`);
74}
75
76/**
77 * The repository and number behind a change request URL on a host the page can read, or null for
78 * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an
79 * ordinary link. Null means the system browser, so a doubtful match is worse than no match: it
80 * takes the reader out of their browser and into a page that cannot find the change request.
81 *
82 * Each host is recognised by the path shape it alone uses, guarded by a hostname it could
83 * plausibly be served from, since self-hosted installs are named whatever their admin chose:
84 * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic
85 * enough that it is only believed from a GitHub-ish host.
86 */
87export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null {
88 let url: URL;
89 try {
90 url = new URL(targetUrl);
91 } catch {
92 return null;
93 }
94 // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open.
95 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
96 // Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test`,
97 // `github.com-evil.test` and the rest are an open set, and blocking spellings of it costs real
98 // hosts (`gitlab.com.br` is a registrable domain, not a disguise). What a claim is worth is
99 // decided where it is used: only a link matching a repository this workspace has checked out
100 // opens the page, and everything else stays the ordinary link it was.
101 const host = url.hostname.toLowerCase();
102
103 // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
104 if (isHostOf(host, "github.com", "github")) {
105 const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
106 return claim(host, match);
107 }
108 // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
109 // separator is GitLab's own, so the hostname is not asked about.
110 const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
111 if (gitlab) return claim(host, gitlab);
112 // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n}
113 if (isHostOf(host, "bitbucket.org", "bitbucket")) {
114 const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
115 return claim(host, match);
116 }
117 // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part
118 // of the repository path there, as it is in the remote URL the identity is read from.
119 if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) {
120 const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname);
121 return claim(host, match);
122 }
123 return null;
124}
125
126/**
127 * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare
128 * reference through `/issues/`, including pull requests, so this only builds a candidate: the
129 * caller must successfully read it as a pull request before treating it as one.
130 */
131export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null {
132 let url: URL;
133 try {
134 url = new URL(targetUrl);
135 } catch {
136 return null;
137 }
138 if (
139 (url.protocol !== "https:" && url.protocol !== "http:") ||
140 !isHostOf(url.hostname.toLowerCase(), "github.com", "github")
141 ) {
142 return null;
143 }
144 const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname);
145 if (match?.[1] === undefined || match[2] === undefined) return null;
146 url.pathname = `/${match[1]}/pull/${match[2]}`;
147 return url.toString();
148}
149
150/** Match a stored PR without requiring its project to remain available. */
151export function matchesLinkedPullRequestUrl(
152 linkedPullRequest: ThreadLinkedPullRequest,
153 targetUrl: string,
154): boolean {
155 const linked = parseChangeRequestUrl(linkedPullRequest.url);
156 const target = parseChangeRequestUrl(targetUrl);
157 return (
158 linked !== null &&
159 target !== null &&
160 linked.host === target.host &&
161 linked.repository === target.repository &&
162 linked.number === target.number
163 );
164}
165
166/** The repository root behind a recognised change-request URL, without PR-specific state. */
167export function changeRequestRepositoryUrl(targetUrl: string): string | null {
168 const changeRequest = parseChangeRequestUrl(targetUrl);
169 if (changeRequest === null) return null;
170 const url = new URL(targetUrl);
171 const repositoryPath =
172 /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
173 /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
174 url.pathname,
175 )?.[1];
176 if (!repositoryPath) return null;
177 url.pathname = repositoryPath;
178 url.search = "";
179 url.hash = "";
180 return url.toString();
181}
182
183function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
184 const repository = match?.[1];
185 const number = Number(match?.[2]);
186 return repository && Number.isSafeInteger(number) && number > 0
187 ? { host, repository: repository.toLowerCase(), number }
188 : null;
189}
19028
191/**29/**
192 * Returns a click handler that opens a pull request URL in the system browser.30 * Returns a click handler that opens a pull request URL in the system browser.
...@@ -210,6 +48,12 @@ export function findProjectForChangeRequest(...@@ -210,6 +48,12 @@ export function findProjectForChangeRequest(
210 if (!identity) return false;48 if (!identity) return false;
211 const kind = identity.provider as SourceControlProviderKind | undefined;49 const kind = identity.provider as SourceControlProviderKind | undefined;
212 if (kind === undefined) return false;50 if (kind === undefined) return false;
51 if (kind === "azure-devops") {
52 return (
53 canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
54 canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase())
55 );
56 }
213 const repository =57 const repository =
214 identity.displayName ??58 identity.displayName ??
215 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);59 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
...@@ -221,6 +65,36 @@ export function findProjectForChangeRequest(...@@ -221,6 +65,36 @@ export function findProjectForChangeRequest(
221 });65 });
222}66}
22367
68/**
69 * Any project checked out from the link's host. Thread links are host-level, so a pull request
70 * from a repository nobody has checked out is still linkable as long as one project on that
71 * host can lend the server its credentials. The link's own project, when it exists, comes first.
72 */
73export function findProjectOnChangeRequestHost(
74 projects: ReadonlyArray<EnvironmentProject>,
75 link: ChangeRequestLink,
76): EnvironmentProject | undefined {
77 const own = findProjectForChangeRequest(projects, link);
78 if (own !== undefined) return own;
79 // Azure CLI reads use the checkout's organization and project, not host-wide credentials.
80 if (
81 canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith(
82 "dev.azure.com/",
83 )
84 )
85 return undefined;
86 return projects.find((project) => {
87 const identity = project.repositoryIdentity;
88 const kind = identity?.provider as SourceControlProviderKind | undefined;
89 return (
90 identity != null &&
91 kind !== undefined &&
92 kind !== "azure-devops" &&
93 pullRequestHostOf(identity, kind) === link.host.toLowerCase()
94 );
95 });
96}
97
224/**98/**
225 * Opens a change request link on the page, and says whether it did. Anything else — another99 * Opens a change request link on the page, and says whether it did. Anything else — another
226 * organisation's repository, a host nothing here is checked out from, a link that merely looks100 * organisation's repository, a host nothing here is checked out from, a link that merely looks
...@@ -284,8 +158,25 @@ export function useOpenChangeRequestLink(...@@ -284,8 +158,25 @@ export function useOpenChangeRequestLink(
284 Number(right.environmentId === primaryEnvironmentId) -158 Number(right.environmentId === primaryEnvironmentId) -
285 Number(left.environmentId === primaryEnvironmentId),159 Number(left.environmentId === primaryEnvironmentId),
286 );160 );
287 const project = findProjectForChangeRequest(projects, parsed);161 const exactProject = findProjectForChangeRequest(projects, parsed);
162 const project =
163 exactProject ??
164 (resolvedPanelRef
165 ? findProjectOnChangeRequestHost(
166 projects.filter(
167 (candidate) =>
168 serverConfigs.get(candidate.environmentId)?.environment.capabilities
169 .threadPullRequests === true,
170 ),
171 parsed,
172 )
173 : undefined);
288 if (project === undefined || !reads(project.environmentId)) return false;174 if (project === undefined || !reads(project.environmentId)) return false;
175 const repository =
176 serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests ===
177 true
178 ? parsed.repository
179 : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository);
289 event.preventDefault();180 event.preventDefault();
290 event.stopPropagation();181 event.stopPropagation();
291 if (resolvedPanelRef) {182 if (resolvedPanelRef) {
...@@ -295,9 +186,12 @@ export function useOpenChangeRequestLink(...@@ -295,9 +186,12 @@ export function useOpenChangeRequestLink(
295 ? {}186 ? {}
296 : { environmentId: project.environmentId }),187 : { environmentId: project.environmentId }),
297 projectId: project.id,188 projectId: project.id,
298 // The identity's own spelling, not the one read out of the URL: the panel asks the189 ...(serverConfigs.get(project.environmentId)?.environment.capabilities
299 // provider for this repository, while matching a link only ever compares lower case.190 .threadPullRequests === true
300 repository: project.repositoryIdentity?.displayName ?? parsed.repository,191 ? { host: parsed.host }
192 : {}),
193 repository,
194 url: targetUrl,
301 number: parsed.number,195 number: parsed.number,
302 });196 });
303 if (!resolvedThreadRef) {197 if (!resolvedThreadRef) {
...@@ -307,7 +201,7 @@ export function useOpenChangeRequestLink(...@@ -307,7 +201,7 @@ export function useOpenChangeRequestLink(
307 ...previous,201 ...previous,
308 involvement: previous.involvement ?? "all",202 involvement: previous.involvement ?? "all",
309 state: previous.state ?? "all",203 state: previous.state ?? "all",
310 repository: project.repositoryIdentity?.displayName ?? parsed.repository,204 repository,
311 number: parsed.number,205 number: parsed.number,
312 selectedProjectId: project.id,206 selectedProjectId: project.id,
313 selectedEnvironmentId: project.environmentId,207 selectedEnvironmentId: project.environmentId,
...@@ -324,7 +218,7 @@ export function useOpenChangeRequestLink(...@@ -324,7 +218,7 @@ export function useOpenChangeRequestLink(
324 // Every state, so the pull request being opened is also in the list behind it whether218 // Every state, so the pull request being opened is also in the list behind it whether
325 // it is open, merged or closed.219 // it is open, merged or closed.
326 state: "all",220 state: "all",
327 repository: parsed.repository,221 repository,
328 number: parsed.number,222 number: parsed.number,
329 selectedProjectId: project.id,223 selectedProjectId: project.id,
330 // Named so the page opens the right one of two servers holding this project.224 // Named so the page opens the right one of two servers holding this project.
apps/web/src/lib/threadSort.test.ts+1
...@@ -34,6 +34,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -34,6 +34,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
34 branch: null,34 branch: null,
35 worktreePath: null,35 worktreePath: null,
36 checkpoints: [],36 checkpoints: [],
37 pullRequests: [],
37 activities: [],38 activities: [],
38 ...overrides,39 ...overrides,
39 };40 };
apps/web/src/rightPanelStore.test.ts+12
...@@ -605,6 +605,18 @@ describe("rightPanelStore", () => {...@@ -605,6 +605,18 @@ describe("rightPanelStore", () => {
605 expect(state.surfaces[1]).not.toHaveProperty("url");605 expect(state.surfaces[1]).not.toHaveProperty("url");
606 });606 });
607607
608 it("keeps matching repository and number on different hosts as separate tabs", () => {
609 const first = { projectId: "project-a", repository: "acme/api", number: 7, host: "github.com" };
610 const second = { ...first, host: "github.example.com" };
611 useRightPanelStore.getState().openPullRequest(refA, first);
612 useRightPanelStore.getState().openPullRequest(refA, second);
613 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
614 expect(state.surfaces).toEqual([pullRequestSurface(first), pullRequestSurface(second)]);
615 expect(pullRequestSurfaceId({ ...first, host: "GITHUB.COM" })).toBe(
616 pullRequestSurfaceId(first),
617 );
618 });
619
608 it("keeps one pull request read from two servers as two tabs", () => {620 it("keeps one pull request read from two servers as two tabs", () => {
609 const local = {621 const local = {
610 environmentId: "local",622 environmentId: "local",
apps/web/src/rightPanelStore.ts+12-1
...@@ -26,6 +26,7 @@ const RIGHT_PANEL_KINDS = [...@@ -26,6 +26,7 @@ const RIGHT_PANEL_KINDS = [
26 "preview",26 "preview",
27 "terminal",27 "terminal",
28 "pull-request",28 "pull-request",
29 "pull-requests",
29 "agents",30 "agents",
30] as const;31] as const;
31export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];32export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
...@@ -68,10 +69,13 @@ export type RightPanelSurface =...@@ -68,10 +69,13 @@ export type RightPanelSurface =
68 */69 */
69 environmentId?: string;70 environmentId?: string;
70 projectId: string;71 projectId: string;
72 host?: string;
71 repository: string;73 repository: string;
72 number: number;74 number: number;
73 url?: string;75 url?: string;
74 }76 }
77 /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */
78 | { id: "pull-requests"; kind: "pull-requests" }
75 | { id: "agents"; kind: "agents" };79 | { id: "agents"; kind: "agents" };
7680
77const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";81const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
...@@ -124,6 +128,7 @@ interface RightPanelStoreState {...@@ -124,6 +128,7 @@ interface RightPanelStoreState {
124 target: {128 target: {
125 environmentId?: string;129 environmentId?: string;
126 projectId: string;130 projectId: string;
131 host?: string;
127 repository: string;132 repository: string;
128 number: number;133 number: number;
129 url?: string;134 url?: string;
...@@ -169,6 +174,8 @@ const singletonSurface = (...@@ -169,6 +174,8 @@ const singletonSurface = (
169 return { id: "diff", kind };174 return { id: "diff", kind };
170 case "files":175 case "files":
171 return { id: "files", kind };176 return { id: "files", kind };
177 case "pull-requests":
178 return { id: "pull-requests", kind };
172 case "agents":179 case "agents":
173 return { id: "agents", kind };180 return { id: "agents", kind };
174 }181 }
...@@ -213,6 +220,7 @@ export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-reques...@@ -213,6 +220,7 @@ export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-reques
213export function pullRequestSurfaceId(target: {220export function pullRequestSurfaceId(target: {
214 environmentId?: string;221 environmentId?: string;
215 projectId: string;222 projectId: string;
223 host?: string;
216 repository: string;224 repository: string;
217 number: number;225 number: number;
218}): PullRequestSurface["id"] {226}): PullRequestSurface["id"] {
...@@ -220,12 +228,14 @@ export function pullRequestSurfaceId(target: {...@@ -220,12 +228,14 @@ export function pullRequestSurfaceId(target: {
220 // servers is two tabs rather than one tab that changes its mind about which server it is on.228 // servers is two tabs rather than one tab that changes its mind about which server it is on.
221 const scope =229 const scope =
222 target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`;230 target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`;
223 return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;231 const host = target.host === undefined ? "" : `${encodeURIComponent(target.host.toLowerCase())}:`;
232 return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${host}${encodeURIComponent(target.repository)}:${target.number}`;
224}233}
225234
226export function pullRequestSurface(target: {235export function pullRequestSurface(target: {
227 environmentId?: string;236 environmentId?: string;
228 projectId: string;237 projectId: string;
238 host?: string;
229 repository: string;239 repository: string;
230 number: number;240 number: number;
231 url?: string;241 url?: string;
...@@ -235,6 +245,7 @@ export function pullRequestSurface(target: {...@@ -235,6 +245,7 @@ export function pullRequestSurface(target: {
235 kind: "pull-request",245 kind: "pull-request",
236 ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }),246 ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }),
237 projectId: target.projectId,247 projectId: target.projectId,
248 ...(typeof target.host === "string" ? { host: target.host.toLowerCase() } : {}),
238 repository: target.repository,249 repository: target.repository,
239 number: target.number,250 number: target.number,
240 ...(typeof target.url === "string" ? { url: target.url } : {}),251 ...(typeof target.url === "string" ? { url: target.url } : {}),
apps/web/src/routes/_chat.pull-requests.tsx+2
...@@ -1923,12 +1923,14 @@ function PullRequestsRouteView() {...@@ -1923,12 +1923,14 @@ function PullRequestsRouteView() {
1923 onAddDiff={() => undefined}1923 onAddDiff={() => undefined}
1924 onAddFiles={() => undefined}1924 onAddFiles={() => undefined}
1925 onAddPullRequest={() => undefined}1925 onAddPullRequest={() => undefined}
1926 onAddPullRequests={() => undefined}
1926 onAddAgents={() => undefined}1927 onAddAgents={() => undefined}
1927 browserAvailable={false}1928 browserAvailable={false}
1928 terminalAvailable={false}1929 terminalAvailable={false}
1929 diffAvailable={false}1930 diffAvailable={false}
1930 filesAvailable={false}1931 filesAvailable={false}
1931 pullRequestAvailable={false}1932 pullRequestAvailable={false}
1933 pullRequestsAvailable={false}
1932 agentsAvailable={false}1934 agentsAvailable={false}
1933 liveAgentCount={0}1935 liveAgentCount={0}
1934 pullRequestStatusSeeds={listedPullRequestTabStatuses}1936 pullRequestStatusSeeds={listedPullRequestTabStatuses}
apps/web/src/state/pullRequests.ts+3
...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 createLinkedPullRequestSummaryAtomFamily,3 createLinkedPullRequestSummaryAtomFamily,
4 createPullRequestEnvironmentAtoms,4 createPullRequestEnvironmentAtoms,
5 createPullRequestStackAtomFamily,
5} from "@t3tools/client-runtime/state/pull-requests";6} from "@t3tools/client-runtime/state/pull-requests";
6import type {7import type {
7 EnvironmentId,8 EnvironmentId,
...@@ -58,6 +59,7 @@ export function useSharedPullRequestSummary(...@@ -58,6 +59,7 @@ export function useSharedPullRequestSummary(
58 : JSON.stringify([59 : JSON.stringify([
59 environmentId,60 environmentId,
60 reference.projectId,61 reference.projectId,
62 reference.host?.toLowerCase() ?? null,
61 reference.repository.toLowerCase(),63 reference.repository.toLowerCase(),
62 reference.number,64 reference.number,
63 ]);65 ]);
...@@ -72,6 +74,7 @@ export function useSharedPullRequestSummary(...@@ -72,6 +74,7 @@ export function useSharedPullRequestSummary(
72 }, [atom, current, environmentId]);74 }, [atom, current, environmentId]);
73 return newestPullRequestSummary(current, observed);75 return newestPullRequestSummary(current, observed);
74}76}
77export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime);
7578
76export interface EnvironmentQueryTarget<Input> {79export interface EnvironmentQueryTarget<Input> {
77 readonly environmentId: EnvironmentId;80 readonly environmentId: EnvironmentId;
apps/web/src/state/sourceControlActions.ts+2
...@@ -218,6 +218,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) {...@@ -218,6 +218,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) {
218 commitMessage?: string;218 commitMessage?: string;
219 featureBranch?: boolean;219 featureBranch?: boolean;
220 filePaths?: string[];220 filePaths?: string[];
221 threadId?: ThreadId;
221 onProgress?: (event: GitActionProgressEvent) => void;222 onProgress?: (event: GitActionProgressEvent) => void;
222 }) => {223 }) => {
223 if (resolveScope(scope) === null) {224 if (resolveScope(scope) === null) {
...@@ -237,6 +238,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) {...@@ -237,6 +238,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) {
237 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),238 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
238 ...(input.featureBranch ? { featureBranch: true } : {}),239 ...(input.featureBranch ? { featureBranch: true } : {}),
239 ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}),240 ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}),
241 ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
240 ...(input.onProgress ? { onProgress: input.onProgress } : {}),242 ...(input.onProgress ? { onProgress: input.onProgress } : {}),
241 });243 });
242 },244 },
apps/web/src/worktreeCleanup.test.ts+1
...@@ -21,6 +21,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -21,6 +21,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
21 session: null,21 session: null,
22 messages: [],22 messages: [],
23 checkpoints: [],23 checkpoints: [],
24 pullRequests: [],
24 activities: [],25 activities: [],
25 proposedPlans: [],26 proposedPlans: [],
26 createdAt: "2026-02-13T00:00:00.000Z",27 createdAt: "2026-02-13T00:00:00.000Z",
docs/internals/glossary.md+8
...@@ -45,3 +45,11 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain...@@ -45,3 +45,11 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain
45| Checkpoint | A saved workspace state used for diffs and restore, stored as a hidden Git ref. |45| Checkpoint | A saved workspace state used for diffs and restore, stored as a hidden Git ref. |
46| Checkpoint baseline | The workspace state captured before the work being compared. |46| Checkpoint baseline | The workspace state captured before the work being compared. |
47| Turn diff | The workspace changes attributed to one turn. |47| Turn diff | The workspace changes attributed to one turn. |
48
49## Pull requests
50
51| Term | Meaning |
52| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53| Pull request link | A persisted thread association identified by host, repository, and number. Links can cross projects within an environment and carry a server-maintained snapshot. |
54| Pull request sync | The reactor that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. |
55| Current pull request | The link used by single-review controls and older clients. Open work takes precedence; a completed single chain points at its top layer. Unrelated terminal links use the latest update. |
docs/internals/overview.md+19
...@@ -18,6 +18,25 @@ versioned clients and servers. Subscriptions send the state a client needs, so a...@@ -18,6 +18,25 @@ versioned clients and servers. Subscriptions send the state a client needs, so a
18thread does not pay for every thread's history. Authentication of a socket does not authorize every18thread does not pay for every thread's history. Authentication of a socket does not authorize every
19method on it. See [environment auth](./environment-auth.md).19method on it. See [environment auth](./environment-auth.md).
2020
21### Pull request linking compatibility
22
23Web, desktop, mobile, and environments upgrade independently. Negotiate linking through the
24environment descriptor, never through a client version or an assumed coordinated release:
25
26| Environment capability | Client behavior |
27| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
28| `threadPullRequests: true` | Use persisted `pullRequests[]`, multi-link commands, stack UI, and reverse thread lookup. |
29| Only `threadPullRequestLinking: true` | Use `linkedPullRequest` and the existing `thread.meta.update` single-link operation. Do not call multi-link RPCs. |
30| Neither flag | Hide linking actions; existing branch-discovered PR display remains available. |
31
32New environments continue advertising the legacy flag, accepting legacy metadata commands, and
33emitting the derived `linkedPullRequest` field for older clients. That hostless field includes only
34links in the thread project's own repository; cross-host and cross-repository links require the
35multi-link protocol. New clients accept snapshots that
36omit `pullRequests`. Retain the legacy wire fields, projection column, and replay support; this feature
37does not schedule their removal. Missing new capabilities must also override cached multi-link data
38after an environment downgrade.
39
21Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands40Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands
22and events, so adding a provider should not require branches throughout the domain or clients.41and events, so adding a provider should not require branches throughout the domain or clients.
23See [provider constraints](./providers.md).42See [provider constraints](./providers.md).
docs/user/source-control.md+24
...@@ -91,3 +91,27 @@ reopening a declined pull request....@@ -91,3 +91,27 @@ reopening a declined pull request.
91 remotes can require separate setup from the hosting provider's API access.91 remotes can require separate setup from the hosting provider's API access.
92- **A review cannot load:** open it on the host website while resolving connectivity, permissions,92- **A review cannot load:** open it on the host website while resolving connectivity, permissions,
93 or rate limits.93 or rate limits.
94
95## Linked pull requests
96
97A thread can hold several pull requests, including reviews from another repository on the same host.
98Use **Link pull request** in the command palette or **Linked pull requests** panel, or right-click a
99pull request link in the conversation. Creating a pull request from Git actions links it automatically.
100Agents can link their pull requests with the `link_pull_request` tool.
101
102Use **Link this PR** in a branch-detected badge's tooltip to keep it with the thread. From a review
103on the Pull Requests page, **Link to thread** lets you search for an active thread. The review header
104also lists the threads that link to it, including archived threads, so you can return to their context.
105
106Thread badges show a stack's layer count or the current review number with a count of additional
107links. On mobile, the Git overview lists linked reviews and their stacks; tap a review to open it.
108Linking and unlinking are available in the web and desktop clients.
109
110The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its
111row menu. An unlinked stack layer stays out of later syncs. Open linked reviews refresh on the server;
112closed reviews refresh periodically so reopening one on the host is detected. Merged reviews refresh
113when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked
114review is terminal. An open or unsynced link keeps it active.
115
116Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
117out from the matching organization and repository.
packages/client-runtime/package.json+4
...@@ -230,6 +230,10 @@...@@ -230,6 +230,10 @@
230 "./work-log/scroll-anchor": {230 "./work-log/scroll-anchor": {
231 "types": "./src/work-log/scrollAnchor.ts",231 "types": "./src/work-log/scrollAnchor.ts",
232 "default": "./src/work-log/scrollAnchor.ts"232 "default": "./src/work-log/scrollAnchor.ts"
233 },
234 "./thread-pull-request-compatibility": {
235 "types": "./src/threadPullRequestCompatibility.ts",
236 "default": "./src/threadPullRequestCompatibility.ts"
233 }237 }
234 },238 },
235 "scripts": {239 "scripts": {
packages/client-runtime/src/operations/commands.ts+20
...@@ -44,6 +44,8 @@ export type UnpinThreadInput = CommandInput<"thread.unpin">;...@@ -44,6 +44,8 @@ export type UnpinThreadInput = CommandInput<"thread.unpin">;
44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
45export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;45export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
46export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;46export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
47export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">;
48export type UnlinkThreadPullRequestInput = CommandInput<"thread.pull-request.unlink">;
47export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;49export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
48export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;50export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
49export type StartThreadTurnInput = CommandInput<"thread.turn.start">;51export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
...@@ -252,6 +254,24 @@ export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => Command...@@ -252,6 +254,24 @@ export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => Command
252 });254 });
253});255});
254256
257export const linkThreadPullRequest: (input: LinkThreadPullRequestInput) => CommandEffect =
258 Effect.fn("EnvironmentCommands.linkThreadPullRequest")(function* (input) {
259 return yield* dispatch({
260 ...input,
261 type: "thread.pull-request.link",
262 commandId: yield* commandId(input),
263 });
264 });
265
266export const unlinkThreadPullRequest: (input: UnlinkThreadPullRequestInput) => CommandEffect =
267 Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")(function* (input) {
268 return yield* dispatch({
269 ...input,
270 type: "thread.pull-request.unlink",
271 commandId: yield* commandId(input),
272 });
273 });
274
255export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn(275export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn(
256 "EnvironmentCommands.setThreadRuntimeMode",276 "EnvironmentCommands.setThreadRuntimeMode",
257)(function* (input) {277)(function* (input) {
packages/client-runtime/src/state/entities.test.ts+1
...@@ -99,6 +99,7 @@ const THREAD_SHELL = {...@@ -99,6 +99,7 @@ const THREAD_SHELL = {
99 archivedAt: null,99 archivedAt: null,
100 settledOverride: null,100 settledOverride: null,
101 settledAt: null,101 settledAt: null,
102 pullRequests: [],
102 session: null,103 session: null,
103 latestUserMessageAt: null,104 latestUserMessageAt: null,
104 hasPendingApprovals: false,105 hasPendingApprovals: false,
packages/client-runtime/src/state/environmentHttpAuth.test.ts+1
...@@ -84,6 +84,7 @@ const THREAD = {...@@ -84,6 +84,7 @@ const THREAD = {
84 runtimeMode: "full-access",84 runtimeMode: "full-access",
85 interactionMode: "default",85 interactionMode: "default",
86 branch: null,86 branch: null,
87 pullRequests: [],
87 worktreePath: null,88 worktreePath: null,
88 latestTurn: null,89 latestTurn: null,
89 createdAt: "2026-09-04T00:00:00.000Z",90 createdAt: "2026-09-04T00:00:00.000Z",
packages/client-runtime/src/state/pullRequests.test.ts+89-38
...@@ -41,6 +41,93 @@ function session(client: WsRpcProtocolClient): RpcSession {...@@ -41,6 +41,93 @@ function session(client: WsRpcProtocolClient): RpcSession {
41 };41 };
42}42}
4343
44const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcProtocolClient) {
45 const connectionState: SupervisorConnectionState = {
46 ...AVAILABLE_CONNECTION_STATE,
47 desired: true,
48 network: "online",
49 phase: "connected",
50 attempt: 1,
51 generation: 1,
52 };
53 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
54 target: TARGET,
55 state: yield* SubscriptionRef.make(connectionState),
56 session: yield* SubscriptionRef.make(Option.some(session(client))),
57 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
58 connect: Effect.void,
59 disconnect: Effect.void,
60 retryNow: Effect.void,
61 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
62 const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
63 run: (_environmentId, effect) =>
64 Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
65 runStream: (_environmentId, stream) =>
66 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
67 followStream: (_environmentId, stream) =>
68 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
69 } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
70 const runtime = Atom.runtime(
71 Layer.merge(
72 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
73 Layer.succeed(
74 PullRequestDiffLoader,
75 PullRequestDiffLoader.of({ load: () => Effect.die("unused") }),
76 ),
77 ),
78 );
79 const atoms = createPullRequestEnvironmentAtoms(runtime);
80 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
81 Effect.sync(() => registry.dispose()),
82 );
83 return { atoms, registry };
84});
85
86it.effect("keeps concurrent diff file reads on different hosts separate", () =>
87 Effect.scoped(
88 Effect.gen(function* () {
89 const release = yield* Latch.make();
90 const started = yield* Latch.make();
91 const calls: string[] = [];
92 const client = {
93 [WS_METHODS.pullRequestsDiffFileContents]: (input: { readonly host: string }) =>
94 Effect.gen(function* () {
95 calls.push(input.host);
96 yield* started.open;
97 yield* release.await;
98 return { oldContents: "", newContents: input.host };
99 }),
100 } as unknown as WsRpcProtocolClient;
101 const { atoms, registry } = yield* makeTestRuntime(client);
102 const input = {
103 projectId: ProjectId.make("project-1"),
104 repository: "acme/web",
105 number: 1,
106 changeType: "change",
107 oldPath: "src/app.ts",
108 newPath: "src/app.ts",
109 } as const;
110 const first = atoms.diffFileContents.run(registry, {
111 environmentId: TARGET.environmentId,
112 input: { ...input, host: "github.com" },
113 });
114 yield* started.await;
115 const second = atoms.diffFileContents.run(registry, {
116 environmentId: TARGET.environmentId,
117 input: { ...input, host: "github.example.com" },
118 });
119 yield* release.open;
120
121 const results = yield* Effect.promise(() => Promise.all([first, second]));
122 expect(results).toMatchObject([
123 { _tag: "Success", value: { newContents: "github.com" } },
124 { _tag: "Success", value: { newContents: "github.example.com" } },
125 ]);
126 expect(calls).toEqual(["github.com", "github.example.com"]);
127 }),
128 ),
129);
130
44it.effect("refreshes pull request activity after a comment is updated", () =>131it.effect("refreshes pull request activity after a comment is updated", () =>
45 Effect.scoped(132 Effect.scoped(
46 Effect.gen(function* () {133 Effect.gen(function* () {
...@@ -76,46 +163,10 @@ it.effect("refreshes pull request activity after a comment is updated", () =>...@@ -76,46 +163,10 @@ it.effect("refreshes pull request activity after a comment is updated", () =>
76 commentBody = input.body;163 commentBody = input.body;
77 }),164 }),
78 } as unknown as WsRpcProtocolClient;165 } as unknown as WsRpcProtocolClient;
79 const connectionState: SupervisorConnectionState = {166 const { atoms, registry } = yield* makeTestRuntime(client);
80 ...AVAILABLE_CONNECTION_STATE,
81 desired: true,
82 network: "online",
83 phase: "connected",
84 attempt: 1,
85 generation: 1,
86 };
87 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
88 target: TARGET,
89 state: yield* SubscriptionRef.make(connectionState),
90 session: yield* SubscriptionRef.make(Option.some(session(client))),
91 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
92 connect: Effect.void,
93 disconnect: Effect.void,
94 retryNow: Effect.void,
95 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
96 const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
97 run: (_environmentId, effect) =>
98 Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
99 runStream: (_environmentId, stream) =>
100 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
101 followStream: (_environmentId, stream) =>
102 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
103 } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
104 const runtime = Atom.runtime(
105 Layer.merge(
106 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
107 Layer.succeed(
108 PullRequestDiffLoader,
109 PullRequestDiffLoader.of({ load: () => Effect.die("unused") }),
110 ),
111 ),
112 );
113 const atoms = createPullRequestEnvironmentAtoms(runtime);
114 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
115 Effect.sync(() => registry.dispose()),
116 );
117 const reference = {167 const reference = {
118 projectId: ProjectId.make("project-1"),168 projectId: ProjectId.make("project-1"),
169 host: "github.example.com",
119 repository: "acme/web",170 repository: "acme/web",
120 number: 1,171 number: 1,
121 } as const;172 } as const;
packages/client-runtime/src/state/pullRequests.ts+27-2
...@@ -60,6 +60,18 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(...@@ -60,6 +60,18 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(
60 });60 });
61}61}
6262
63/** The host-native stack a pull request belongs to; null where it is not stacked. */
64export function createPullRequestStackAtomFamily<R, E>(
65 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
66) {
67 return createEnvironmentRpcQueryAtomFamily(runtime, {
68 label: "environment-data:pull-requests:stack",
69 tag: WS_METHODS.pullRequestsStack,
70 staleTimeMs: 60_000,
71 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
72 });
73}
74
63export function pullRequestDetailToVcsStatus(75export function pullRequestDetailToVcsStatus(
64 detail: PullRequestDetail | PullRequestSummary,76 detail: PullRequestDetail | PullRequestSummary,
65): NonNullable<VcsStatusResult["pr"]> {77): NonNullable<VcsStatusResult["pr"]> {
...@@ -97,6 +109,13 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -97,6 +109,13 @@ export function createPullRequestEnvironmentAtoms<R, E>(
97 });109 });
98 return {110 return {
99 refreshes,111 refreshes,
112 linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, {
113 label: "environment-data:pull-requests:linked-threads",
114 tag: WS_METHODS.pullRequestsLinkedThreads,
115 staleTimeMs: 0,
116 refreshIntervalMs: 10_000,
117 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
118 }),
100 list: createEnvironmentRpcQueryAtomFamily(runtime, {119 list: createEnvironmentRpcQueryAtomFamily(runtime, {
101 label: "environment-data:pull-requests:list",120 label: "environment-data:pull-requests:list",
102 tag: WS_METHODS.pullRequestsList,121 tag: WS_METHODS.pullRequestsList,
...@@ -159,6 +178,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -159,6 +178,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
159 JSON.stringify([178 JSON.stringify([
160 environmentId,179 environmentId,
161 input.projectId,180 input.projectId,
181 input.host?.toLowerCase() ?? null,
162 input.repository,182 input.repository,
163 input.number,183 input.number,
164 input.commit ?? null,184 input.commit ?? null,
...@@ -191,9 +211,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -191,9 +211,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(
191 tag: WS_METHODS.pullRequestsUpdateComment,211 tag: WS_METHODS.pullRequestsUpdateComment,
192 scheduler: commandScheduler,212 scheduler: commandScheduler,
193 concurrency: serialPerEnvironment,213 concurrency: serialPerEnvironment,
194 onSuccess: ({ environmentId, input: { projectId, repository, number } }, registry) =>214 onSuccess: ({ environmentId, input: { projectId, host, repository, number } }, registry) =>
195 Effect.sync(() =>215 Effect.sync(() =>
196 registry.refresh(activity({ environmentId, input: { projectId, repository, number } })),216 registry.refresh(
217 activity({
218 environmentId,
219 input: { projectId, ...(host === undefined ? {} : { host }), repository, number },
220 }),
221 ),
197 ),222 ),
198 }),223 }),
199 submitReview: createEnvironmentRpcCommand(runtime, {224 submitReview: createEnvironmentRpcCommand(runtime, {
packages/client-runtime/src/state/shellReducer.test.ts+1
...@@ -38,6 +38,7 @@ const stubThread = {...@@ -38,6 +38,7 @@ const stubThread = {
38 archivedAt: null,38 archivedAt: null,
39 settledOverride: null,39 settledOverride: null,
40 settledAt: null,40 settledAt: null,
41 pullRequests: [],
41 latestUserMessageAt: null,42 latestUserMessageAt: null,
42 hasPendingApprovals: false,43 hasPendingApprovals: false,
43 hasPendingUserInput: false,44 hasPendingUserInput: false,
packages/client-runtime/src/state/threadCommands.ts+18
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 type CreateThreadInput,12 type CreateThreadInput,
13 type DeleteThreadInput,13 type DeleteThreadInput,
14 type InterruptThreadTurnInput,14 type InterruptThreadTurnInput,
15 type LinkThreadPullRequestInput,
15 type RespondToThreadApprovalInput,16 type RespondToThreadApprovalInput,
16 type RespondToThreadUserInputInput,17 type RespondToThreadUserInputInput,
17 type DismissThreadUserInputInput,18 type DismissThreadUserInputInput,
...@@ -26,6 +27,7 @@ import {...@@ -26,6 +27,7 @@ import {
26 type StartThreadTurnInput,27 type StartThreadTurnInput,
27 type StopThreadSessionInput,28 type StopThreadSessionInput,
28 type UnarchiveThreadInput,29 type UnarchiveThreadInput,
30 type UnlinkThreadPullRequestInput,
29 type UnpinThreadInput,31 type UnpinThreadInput,
30 type UnsettleThreadInput,32 type UnsettleThreadInput,
31 type UnsnoozeThreadInput,33 type UnsnoozeThreadInput,
...@@ -34,6 +36,7 @@ import {...@@ -34,6 +36,7 @@ import {
34 createThread,36 createThread,
35 deleteThread,37 deleteThread,
36 interruptThreadTurn,38 interruptThreadTurn,
39 linkThreadPullRequest,
37 respondToThreadApproval,40 respondToThreadApproval,
38 respondToThreadUserInput,41 respondToThreadUserInput,
39 dismissThreadUserInput,42 dismissThreadUserInput,
...@@ -48,6 +51,7 @@ import {...@@ -48,6 +51,7 @@ import {
48 startThreadTurn,51 startThreadTurn,
49 stopThreadSession,52 stopThreadSession,
50 unarchiveThread,53 unarchiveThread,
54 unlinkThreadPullRequest,
51 unpinThread,55 unpinThread,
52 unsettleThread,56 unsettleThread,
53 unsnoozeThread,57 unsnoozeThread,
...@@ -60,6 +64,7 @@ export type {...@@ -60,6 +64,7 @@ export type {
60 CreateThreadInput,64 CreateThreadInput,
61 DeleteThreadInput,65 DeleteThreadInput,
62 InterruptThreadTurnInput,66 InterruptThreadTurnInput,
67 LinkThreadPullRequestInput,
63 RespondToThreadApprovalInput,68 RespondToThreadApprovalInput,
64 RespondToThreadUserInputInput,69 RespondToThreadUserInputInput,
65 DismissThreadUserInputInput,70 DismissThreadUserInputInput,
...@@ -74,6 +79,7 @@ export type {...@@ -74,6 +79,7 @@ export type {
74 StartThreadTurnInput,79 StartThreadTurnInput,
75 StopThreadSessionInput,80 StopThreadSessionInput,
76 UnarchiveThreadInput,81 UnarchiveThreadInput,
82 UnlinkThreadPullRequestInput,
77 UnpinThreadInput,83 UnpinThreadInput,
78 UnsettleThreadInput,84 UnsettleThreadInput,
79 UnsnoozeThreadInput,85 UnsnoozeThreadInput,
...@@ -168,6 +174,18 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -168,6 +174,18 @@ export function createThreadEnvironmentAtoms<R, E>(
168 scheduler,174 scheduler,
169 concurrency,175 concurrency,
170 }),176 }),
177 linkPullRequest: createEnvironmentCommand(runtime, {
178 label: "environment-data:commands:thread:link-pull-request",
179 execute: (input: LinkThreadPullRequestInput) => linkThreadPullRequest(input),
180 scheduler,
181 concurrency,
182 }),
183 unlinkPullRequest: createEnvironmentCommand(runtime, {
184 label: "environment-data:commands:thread:unlink-pull-request",
185 execute: (input: UnlinkThreadPullRequestInput) => unlinkThreadPullRequest(input),
186 scheduler,
187 concurrency,
188 }),
171 setRuntimeMode: createEnvironmentCommand(runtime, {189 setRuntimeMode: createEnvironmentCommand(runtime, {
172 label: "environment-data:commands:thread:set-runtime-mode",190 label: "environment-data:commands:thread:set-runtime-mode",
173 execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input),191 execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input),
packages/client-runtime/src/state/threadReducer.test.ts+119
...@@ -37,6 +37,7 @@ const baseThread: OrchestrationThread = {...@@ -37,6 +37,7 @@ const baseThread: OrchestrationThread = {
37 archivedAt: null,37 archivedAt: null,
38 settledOverride: null,38 settledOverride: null,
39 settledAt: null,39 settledAt: null,
40 pullRequests: [],
40 deletedAt: null,41 deletedAt: null,
41 messages: [],42 messages: [],
42 proposedPlans: [],43 proposedPlans: [],
...@@ -406,6 +407,124 @@ describe("applyThreadDetailEvent", () => {...@@ -406,6 +407,124 @@ describe("applyThreadDetailEvent", () => {
406 );407 );
407 });408 });
408409
410 describe("thread pull request links", () => {
411 const link = {
412 host: "github.com",
413 repository: "pingdotgg/t3code",
414 number: 42,
415 url: "https://github.com/pingdotgg/t3code/pull/42",
416 source: "manual" as const,
417 linkedAt: "2026-04-01T05:00:00.000Z",
418 snapshot: null,
419 stack: null,
420 };
421 const key = { host: "github.com", repository: "pingdotgg/t3code", number: 42 };
422 const linkEvent = (sequence: number) =>
423 ({
424 ...baseEventFields,
425 sequence,
426 occurredAt: "2026-04-01T05:00:00.000Z",
427 aggregateKind: "thread",
428 aggregateId: ThreadId.make("thread-1"),
429 type: "thread.pull-request-linked",
430 payload: {
431 threadId: ThreadId.make("thread-1"),
432 link,
433 updatedAt: "2026-04-01T05:00:00.000Z",
434 },
435 }) as const;
436
437 it("links without inventing a legacy route and replaces by key", () => {
438 const linked = applyThreadDetailEvent(baseThread, linkEvent(5));
439 expect(linked.kind).toBe("updated");
440 if (linked.kind !== "updated") return;
441 expect(linked.thread.pullRequests).toEqual([link]);
442 expect(linked.thread.linkedPullRequest).toBeNull();
443
444 const relinked = applyThreadDetailEvent(linked.thread, {
445 ...linkEvent(6),
446 payload: {
447 threadId: ThreadId.make("thread-1"),
448 link: { ...link, host: "GitHub.com", source: "agent" },
449 updatedAt: "2026-04-01T06:00:00.000Z",
450 },
451 });
452 if (relinked.kind !== "updated") throw new Error("expected update");
453 expect(relinked.thread.pullRequests).toHaveLength(1);
454 expect(relinked.thread.pullRequests[0]?.source).toBe("agent");
455 });
456
457 it("syncs snapshot and stack onto the matching link only", () => {
458 const linked = applyThreadDetailEvent(baseThread, linkEvent(5));
459 if (linked.kind !== "updated") throw new Error("expected update");
460 const snapshot = {
461 state: "merged" as const,
462 title: "Ship it",
463 headBranch: "feature",
464 baseBranch: "main",
465 isDraft: false,
466 updatedAt: "2026-04-02T00:00:00.000Z",
467 syncedAt: "2026-04-02T00:01:00.000Z",
468 };
469 const synced = applyThreadDetailEvent(linked.thread, {
470 ...baseEventFields,
471 sequence: 6,
472 occurredAt: "2026-04-02T00:01:00.000Z",
473 aggregateKind: "thread",
474 aggregateId: ThreadId.make("thread-1"),
475 type: "thread.pull-request-synced",
476 payload: {
477 threadId: ThreadId.make("thread-1"),
478 ...key,
479 snapshot,
480 stack: null,
481 updatedAt: "2026-04-02T00:01:00.000Z",
482 },
483 });
484 if (synced.kind !== "updated") throw new Error("expected update");
485 expect(synced.thread.pullRequests[0]?.snapshot).toEqual(snapshot);
486
487 const unknown = applyThreadDetailEvent(synced.thread, {
488 ...baseEventFields,
489 sequence: 7,
490 occurredAt: "2026-04-02T00:02:00.000Z",
491 aggregateKind: "thread",
492 aggregateId: ThreadId.make("thread-1"),
493 type: "thread.pull-request-synced",
494 payload: {
495 threadId: ThreadId.make("thread-1"),
496 ...key,
497 number: 99,
498 snapshot,
499 stack: null,
500 updatedAt: "2026-04-02T00:02:00.000Z",
501 },
502 });
503 expect(unknown.kind).toBe("unchanged");
504 });
505
506 it("unlinks and clears the compat field", () => {
507 const linked = applyThreadDetailEvent(baseThread, linkEvent(5));
508 if (linked.kind !== "updated") throw new Error("expected update");
509 const unlinked = applyThreadDetailEvent(linked.thread, {
510 ...baseEventFields,
511 sequence: 6,
512 occurredAt: "2026-04-01T06:00:00.000Z",
513 aggregateKind: "thread",
514 aggregateId: ThreadId.make("thread-1"),
515 type: "thread.pull-request-unlinked",
516 payload: {
517 threadId: ThreadId.make("thread-1"),
518 ...key,
519 updatedAt: "2026-04-01T06:00:00.000Z",
520 },
521 });
522 if (unlinked.kind !== "updated") throw new Error("expected update");
523 expect(unlinked.thread.pullRequests).toEqual([]);
524 expect(unlinked.thread.linkedPullRequest).toBeNull();
525 });
526 });
527
409 describe("thread.message-sent", () => {528 describe("thread.message-sent", () => {
410 it("appends a new message", () => {529 it("appends a new message", () => {
411 const result = applyThreadDetailEvent(baseThread, {530 const result = applyThreadDetailEvent(baseThread, {
packages/client-runtime/src/state/threadReducer.ts+60
...@@ -10,8 +10,10 @@ import type {...@@ -10,8 +10,10 @@ import type {
10 OrchestrationSession,10 OrchestrationSession,
11 OrchestrationThread,11 OrchestrationThread,
12 OrchestrationThreadActivity,12 OrchestrationThreadActivity,
13 ThreadPullRequestLink,
13 TurnId,14 TurnId,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequests";
15import { isImportedAgentSessionMessageId } from "@t3tools/contracts";17import { isImportedAgentSessionMessageId } from "@t3tools/contracts";
16import { compareDateTimeStrings } from "@t3tools/shared/dateTime";18import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1719
...@@ -20,6 +22,29 @@ export type ThreadDetailReducerResult =...@@ -20,6 +22,29 @@ export type ThreadDetailReducerResult =
20 | { readonly kind: "deleted" }22 | { readonly kind: "deleted" }
21 | { readonly kind: "unchanged" };23 | { readonly kind: "unchanged" };
2224
25/** Keep only a legacy route supplied by the server; detail events cannot resolve project hosts. */
26function withPullRequests(
27 thread: OrchestrationThread,
28 pullRequests: ReadonlyArray<ThreadPullRequestLink>,
29 updatedAt: string,
30): ThreadDetailReducerResult {
31 return {
32 kind: "updated",
33 thread: {
34 ...thread,
35 pullRequests,
36 linkedPullRequest:
37 thread.linkedPullRequest &&
38 pullRequests.some(
39 (link) => link.source !== "stack-dismissed" && link.url === thread.linkedPullRequest?.url,
40 )
41 ? thread.linkedPullRequest
42 : null,
43 updatedAt,
44 },
45 };
46}
47
23const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(48const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
24 O.mapInput(O.String, (p) => p.createdAt),49 O.mapInput(O.String, (p) => p.createdAt),
25 O.mapInput(O.String, (p) => p.id),50 O.mapInput(O.String, (p) => p.id),
...@@ -109,6 +134,7 @@ export function applyThreadDetailEvent(...@@ -109,6 +134,7 @@ export function applyThreadDetailEvent(
109 snoozedUntil: null,134 snoozedUntil: null,
110 snoozedAt: null,135 snoozedAt: null,
111 deletedAt: null,136 deletedAt: null,
137 pullRequests: [],
112 messages: [],138 messages: [],
113 proposedPlans: [],139 proposedPlans: [],
114 activities: [],140 activities: [],
...@@ -253,6 +279,40 @@ export function applyThreadDetailEvent(...@@ -253,6 +279,40 @@ export function applyThreadDetailEvent(
253 },279 },
254 };280 };
255281
282 case "thread.pull-request-linked": {
283 const link = event.payload.link;
284 const others = thread.pullRequests.filter(
285 (existing) => !threadPullRequestKeysEqual(existing, link),
286 );
287 return withPullRequests(thread, [...others, link], event.payload.updatedAt);
288 }
289
290 case "thread.pull-request-unlinked":
291 return withPullRequests(
292 thread,
293 thread.pullRequests.filter(
294 (existing) => !threadPullRequestKeysEqual(existing, event.payload),
295 ),
296 event.payload.updatedAt,
297 );
298
299 case "thread.pull-request-synced": {
300 if (
301 !thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, event.payload))
302 ) {
303 return { kind: "unchanged" };
304 }
305 return withPullRequests(
306 thread,
307 thread.pullRequests.map((existing) =>
308 threadPullRequestKeysEqual(existing, event.payload)
309 ? { ...existing, snapshot: event.payload.snapshot, stack: event.payload.stack }
310 : existing,
311 ),
312 event.payload.updatedAt,
313 );
314 }
315
256 case "thread.runtime-mode-set":316 case "thread.runtime-mode-set":
257 return {317 return {
258 kind: "updated",318 kind: "updated",
packages/client-runtime/src/state/threads-atoms.test.ts+1
...@@ -65,6 +65,7 @@ const THREAD: OrchestrationThread = {...@@ -65,6 +65,7 @@ const THREAD: OrchestrationThread = {
65 runtimeMode: "full-access",65 runtimeMode: "full-access",
66 interactionMode: "default",66 interactionMode: "default",
67 branch: "main",67 branch: "main",
68 pullRequests: [],
68 worktreePath: null,69 worktreePath: null,
69 latestTurn: null,70 latestTurn: null,
70 createdAt: "2026-04-01T00:00:00.000Z",71 createdAt: "2026-04-01T00:00:00.000Z",
packages/client-runtime/src/state/threads-pagination.test.ts+1
...@@ -104,6 +104,7 @@ const BASE_THREAD: OrchestrationThread = {...@@ -104,6 +104,7 @@ const BASE_THREAD: OrchestrationThread = {
104 archivedAt: null,104 archivedAt: null,
105 settledOverride: null,105 settledOverride: null,
106 settledAt: null,106 settledAt: null,
107 pullRequests: [],
107 deletedAt: null,108 deletedAt: null,
108 messages: [RECENT_MESSAGE],109 messages: [RECENT_MESSAGE],
109 proposedPlans: [],110 proposedPlans: [],
packages/client-runtime/src/state/threads-sync.test.ts+1
...@@ -75,6 +75,7 @@ const BASE_THREAD: OrchestrationThread = {...@@ -75,6 +75,7 @@ const BASE_THREAD: OrchestrationThread = {
75 archivedAt: null,75 archivedAt: null,
76 settledOverride: null,76 settledOverride: null,
77 settledAt: null,77 settledAt: null,
78 pullRequests: [],
78 deletedAt: null,79 deletedAt: null,
79 messages: [],80 messages: [],
80 proposedPlans: [],81 proposedPlans: [],
packages/client-runtime/src/state/vcsAction.test.ts+11-2
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 ThreadId,
3 WS_METHODS,4 WS_METHODS,
4 type GitActionProgressEvent,5 type GitActionProgressEvent,
6 type GitRunStackedActionInput,
5 type GitRunStackedActionResult,7 type GitRunStackedActionResult,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";9import { describe, expect, it } from "@effect/vitest";
...@@ -589,9 +591,10 @@ describe("vcsActionState", () => {...@@ -589,9 +591,10 @@ describe("vcsActionState", () => {
589 successfulActionId,591 successfulActionId,
590 );592 );
591 const failedTransportActionId = createVcsActionTransportId(targetKey, failedActionId);593 const failedTransportActionId = createVcsActionTransportId(targetKey, failedActionId);
594 const rpcInputs = new Array<GitRunStackedActionInput>();
592 const client = {595 const client = {
593 [WS_METHODS.gitRunStackedAction]: (input: { readonly actionId: string }) =>596 [WS_METHODS.gitRunStackedAction]: (input: GitRunStackedActionInput) =>
594 input.actionId === successfulTransportActionId597 (rpcInputs.push(input), input.actionId === successfulTransportActionId)
595 ? Stream.make(598 ? Stream.make(
596 progress({599 progress({
597 kind: "action_finished",600 kind: "action_finished",
...@@ -652,16 +655,22 @@ describe("vcsActionState", () => {...@@ -652,16 +655,22 @@ describe("vcsActionState", () => {
652 const state = vcsRefsCacheStateAtom({ environmentId });655 const state = vcsRefsCacheStateAtom({ environmentId });
653656
654 expect(registry.get(state).revision).toBe(0);657 expect(registry.get(state).revision).toBe(0);
658 const threadId = ThreadId.make("thread-stacked-action");
655 const successfulResult = yield* Effect.promise(() =>659 const successfulResult = yield* Effect.promise(() =>
656 manager.runStackedAction(targetKey).run(registry, {660 manager.runStackedAction(targetKey).run(registry, {
657 actionId: successfulActionId,661 actionId: successfulActionId,
658 action,662 action,
663 threadId,
659 }),664 }),
660 );665 );
661666
662 expect(AsyncResult.isSuccess(successfulResult)).toBe(true);667 expect(AsyncResult.isSuccess(successfulResult)).toBe(true);
663 expect(registry.get(state).revision).toBe(1);668 expect(registry.get(state).revision).toBe(1);
664 expect(removed).toEqual([`${environmentId}:*`]);669 expect(removed).toEqual([`${environmentId}:*`]);
670 // The server links a created pull request to this thread, so the id must ride along.
671 expect(rpcInputs).toEqual([
672 { actionId: successfulTransportActionId, cwd, action, threadId },
673 ]);
665674
666 const failedResult = yield* Effect.promise(() =>675 const failedResult = yield* Effect.promise(() =>
667 manager.runStackedAction(targetKey).run(registry, {676 manager.runStackedAction(targetKey).run(registry, {
packages/client-runtime/src/state/vcsAction.ts+4
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 type GitRunStackedActionInput,6 type GitRunStackedActionInput,
7 type GitRunStackedActionResult,7 type GitRunStackedActionResult,
8 GitStackedAction,8 GitStackedAction,
9 type ThreadId,
9 WS_METHODS,10 WS_METHODS,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import * as Cause from "effect/Cause";12import * as Cause from "effect/Cause";
...@@ -76,6 +77,8 @@ export interface RunVcsStackedActionInput {...@@ -76,6 +77,8 @@ export interface RunVcsStackedActionInput {
76 readonly commitMessage?: string;77 readonly commitMessage?: string;
77 readonly featureBranch?: boolean;78 readonly featureBranch?: boolean;
78 readonly filePaths?: ReadonlyArray<string>;79 readonly filePaths?: ReadonlyArray<string>;
80 /** The thread the action runs beside; the server links a pull request it creates to it. */
81 readonly threadId?: ThreadId;
79 readonly onProgress?: (event: GitActionProgressEvent) => void;82 readonly onProgress?: (event: GitActionProgressEvent) => void;
80}83}
8184
...@@ -463,6 +466,7 @@ export function createVcsActionManager<R, E>(...@@ -463,6 +466,7 @@ export function createVcsActionManager<R, E>(
463 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),466 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
464 ...(input.featureBranch ? { featureBranch: true } : {}),467 ...(input.featureBranch ? { featureBranch: true } : {}),
465 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),468 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
469 ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
466 };470 };
467 return consumeVcsActionProgress(471 return consumeVcsActionProgress(
468 runStreamInEnvironment(472 runStreamInEnvironment(
packages/client-runtime/src/threadPullRequestCompatibility.test.ts created+115
...@@ -0,0 +1,115 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import {
4 planThreadPullRequestMutation,
5 threadPullRequestLinkMode,
6} from "./threadPullRequestCompatibility.ts";
7
8const reference = {
9 host: "github.example",
10 repository: "team/repo",
11 number: 7,
12 url: "https://github.example/team/repo/pull/7",
13};
14const input = {
15 threadId: ThreadId.make("thread"),
16 reference,
17 legacyProjectId: ProjectId.make("exact-checkout"),
18 linked: true,
19};
20
21describe("thread pull request capability negotiation", () => {
22 it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])(
23 "does not dispatch when linking is unadvertised: %j",
24 (capabilities) => {
25 expect(threadPullRequestLinkMode(capabilities)).toBe("unsupported");
26 expect(planThreadPullRequestMutation({ ...input, capabilities })).toBeNull();
27 },
28 );
29 it("uses metadata updates for old single-link servers, including unlink", () => {
30 const capabilities = { threadPullRequestLinking: true };
31 expect(planThreadPullRequestMutation({ ...input, capabilities })).toEqual({
32 type: "thread.meta.update",
33 input: {
34 threadId: input.threadId,
35 linkedPullRequest: {
36 projectId: input.legacyProjectId,
37 repository: reference.repository,
38 number: 7,
39 url: reference.url,
40 },
41 },
42 });
43 expect(
44 planThreadPullRequestMutation({
45 ...input,
46 capabilities,
47 linked: false,
48 legacyProjectId: null,
49 }),
50 ).toEqual({
51 type: "thread.meta.update",
52 input: { threadId: input.threadId, linkedPullRequest: null },
53 });
54 });
55 it("uses the checkout's Azure selector only for legacy metadata commands", () => {
56 const azure = {
57 ...input,
58 reference: {
59 host: "dev.azure.com",
60 repository: "org/project/_git/web",
61 number: 42,
62 url: "https://dev.azure.com/org/project/_git/web/pullrequest/42",
63 },
64 legacyRepository: "web",
65 };
66 expect(
67 planThreadPullRequestMutation({
68 ...azure,
69 capabilities: { threadPullRequestLinking: true },
70 }),
71 ).toMatchObject({
72 type: "thread.meta.update",
73 input: { linkedPullRequest: { repository: "web", number: 42 } },
74 });
75 expect(
76 planThreadPullRequestMutation({
77 ...azure,
78 capabilities: { threadPullRequests: true },
79 }),
80 ).toMatchObject({
81 type: "thread.pull-request.link",
82 input: { repository: "org/project/_git/web", number: 42 },
83 });
84 });
85
86 it("never sends a same-host route as an exact repository to an old server", () => {
87 expect(
88 planThreadPullRequestMutation({
89 ...input,
90 capabilities: { threadPullRequestLinking: true },
91 legacyProjectId: null,
92 }),
93 ).toBeNull();
94 });
95 it.each([
96 { threadPullRequests: true },
97 { threadPullRequests: true, threadPullRequestLinking: true },
98 ])("prefers multi-link commands when available: %j", (capabilities) => {
99 expect(
100 planThreadPullRequestMutation({ ...input, capabilities, legacyProjectId: null }),
101 ).toEqual({
102 type: "thread.pull-request.link",
103 input: { threadId: input.threadId, ...reference, source: "manual" },
104 });
105 expect(planThreadPullRequestMutation({ ...input, capabilities, linked: false })).toEqual({
106 type: "thread.pull-request.unlink",
107 input: {
108 threadId: input.threadId,
109 host: reference.host,
110 repository: reference.repository,
111 number: reference.number,
112 },
113 });
114 });
115});
packages/client-runtime/src/threadPullRequestCompatibility.ts created+75
...@@ -0,0 +1,75 @@
1import type {
2 ExecutionEnvironmentCapabilities,
3 ProjectId,
4 ThreadId,
5 ThreadPullRequestKey,
6} from "@t3tools/contracts";
7
8type LinkingCapabilities = Pick<
9 ExecutionEnvironmentCapabilities,
10 "threadPullRequests" | "threadPullRequestLinking"
11>;
12
13/** Capability negotiation keeps both single-link and multi-link environments usable. */
14export function threadPullRequestLinkMode(capabilities: LinkingCapabilities | null | undefined) {
15 return capabilities?.threadPullRequests === true
16 ? "multiple"
17 : capabilities?.threadPullRequestLinking === true
18 ? "single"
19 : "unsupported";
20}
21
22export function planThreadPullRequestMutation({
23 capabilities,
24 threadId,
25 reference,
26 legacyProjectId,
27 legacyRepository,
28 linked,
29}: {
30 capabilities: LinkingCapabilities | null | undefined;
31 threadId: ThreadId;
32 reference: ThreadPullRequestKey & { readonly url: string };
33 /** Older servers need the exact repository checkout; same-host routing is not available. */
34 legacyProjectId: ProjectId | null;
35 /** Provider selector for legacy APIs, which omit Azure organization and project paths. */
36 legacyRepository?: string | undefined;
37 linked: boolean;
38}) {
39 switch (threadPullRequestLinkMode(capabilities)) {
40 case "multiple":
41 return linked
42 ? {
43 type: "thread.pull-request.link" as const,
44 input: { threadId, ...reference, source: "manual" as const },
45 }
46 : {
47 type: "thread.pull-request.unlink" as const,
48 input: {
49 threadId,
50 host: reference.host,
51 repository: reference.repository,
52 number: reference.number,
53 },
54 };
55 case "single":
56 if (linked && legacyProjectId === null) return null;
57 return {
58 type: "thread.meta.update" as const,
59 input: {
60 threadId,
61 linkedPullRequest:
62 linked && legacyProjectId !== null
63 ? {
64 projectId: legacyProjectId,
65 repository: legacyRepository ?? reference.repository,
66 number: reference.number,
67 url: reference.url,
68 }
69 : null,
70 },
71 };
72 case "unsupported":
73 return null;
74 }
75}
packages/contracts/src/environment.ts+6-1
...@@ -123,8 +123,13 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -123,8 +123,13 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
123 /** Server understands regenerateTitle on thread.meta.update. Absent on123 /** Server understands regenerateTitle on thread.meta.update. Absent on
124 older servers, so clients hide the action instead of sending it. */124 older servers, so clients hide the action instead of sending it. */
125 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),125 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
126 /** Server persists a pull request reference on thread.meta.update. */126 /** Server supports legacy linkedPullRequest updates through thread.meta.update.
127 Independent of threadPullRequests; servers supporting both advertise both. */
127 threadPullRequestLinking: Schema.optionalKey(Schema.Boolean),128 threadPullRequestLinking: Schema.optionalKey(Schema.Boolean),
129 /** Server understands thread.pull-request.link / .unlink, exposes `pullRequests` on
130 threads, and routes PullRequestRef.host across projects on the same host. Same
131 version-skew contract as threadSettlement. */
132 threadPullRequests: Schema.optionalKey(Schema.Boolean),
128 /** The update path clients should offer for this server. Absent on133 /** The update path clients should offer for this server. Absent on
129 servers that must be relaunched manually (dev checkouts, Windows134 servers that must be relaunched manually (dev checkouts, Windows
130 foreground runs, pre-update servers). */135 foreground runs, pre-update servers). */
packages/contracts/src/git.ts+2
...@@ -119,6 +119,8 @@ export const GitRunStackedActionInput = Schema.Struct({...@@ -119,6 +119,8 @@ export const GitRunStackedActionInput = Schema.Struct({
119 filePaths: Schema.optional(119 filePaths: Schema.optional(
120 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),120 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
121 ),121 ),
122 /** The thread the action runs beside; a pull request it creates is linked to it. */
123 threadId: Schema.optional(ThreadId),
122});124});
123export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;125export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
124126
packages/contracts/src/orchestration.test.ts+161-11
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 OrchestrationMessage,26 OrchestrationMessage,
27 ThreadMessageSentPayload,27 ThreadMessageSentPayload,
28 ThreadMetaUpdatedPayload,28 ThreadMetaUpdatedPayload,
29 ThreadLinkedPullRequest,
29 ThreadTurnStartCommand,30 ThreadTurnStartCommand,
30 ThreadCreatedPayload,31 ThreadCreatedPayload,
31 ThreadTurnDiff,32 ThreadTurnDiff,
...@@ -682,6 +683,116 @@ it.effect("defaults settled fields when decoding historical thread data", () =>...@@ -682,6 +683,116 @@ it.effect("defaults settled fields when decoding historical thread data", () =>
682 assert.strictEqual(thread.settledAt, null);683 assert.strictEqual(thread.settledAt, null);
683 assert.strictEqual(shell.settledOverride, null);684 assert.strictEqual(shell.settledOverride, null);
684 assert.strictEqual(shell.settledAt, null);685 assert.strictEqual(shell.settledAt, null);
686 // Pre-link servers omit the array entirely.
687 assert.deepStrictEqual(thread.pullRequests, []);
688 assert.deepStrictEqual(shell.pullRequests, []);
689
690 const legacyLink = {
691 projectId: ProjectId.make("project-1"),
692 repository: "acme/web",
693 number: 42,
694 url: "https://github.com/acme/web/pull/42",
695 };
696 const oldServerShell = yield* decodeOrchestrationThreadShell({
697 ...common,
698 latestUserMessageAt: null,
699 hasPendingApprovals: false,
700 hasPendingUserInput: false,
701 hasActionableProposedPlan: false,
702 linkedPullRequest: legacyLink,
703 });
704 assert.deepStrictEqual(oldServerShell.pullRequests, []);
705 assert.deepStrictEqual(oldServerShell.linkedPullRequest, legacyLink);
706
707 // A decoder from before the array must still read its single-link field
708 // after a new server encodes the expanded snapshot.
709 const oldLinkFields = Schema.Struct({
710 linkedPullRequest: Schema.optional(ThreadLinkedPullRequest),
711 });
712 const newServerWire = yield* Schema.encodeEffect(OrchestrationThreadShell)({
713 ...oldServerShell,
714 pullRequests: [
715 {
716 host: "github.com",
717 repository: legacyLink.repository,
718 number: legacyLink.number,
719 url: legacyLink.url,
720 source: "agent",
721 linkedAt: common.createdAt,
722 snapshot: null,
723 stack: null,
724 },
725 ],
726 });
727 const oldClientFields = yield* Schema.decodeUnknownEffect(oldLinkFields)(newServerWire);
728 assert.deepStrictEqual(oldClientFields.linkedPullRequest, legacyLink);
729 }),
730);
731
732it.effect("decodes thread pull request links with snapshot and stack", () =>
733 Effect.gen(function* () {
734 const shell = yield* decodeOrchestrationThreadShell({
735 id: "thread-1",
736 projectId: "project-1",
737 title: "Thread",
738 modelSelection: { provider: "codex", model: "gpt-5-codex" },
739 runtimeMode: "full-access",
740 branch: "feature/stack-2",
741 worktreePath: null,
742 latestTurn: null,
743 createdAt: "2026-01-01T00:00:00.000Z",
744 updatedAt: "2026-01-01T00:00:00.000Z",
745 archivedAt: null,
746 session: null,
747 latestUserMessageAt: null,
748 hasPendingApprovals: false,
749 hasPendingUserInput: false,
750 hasActionableProposedPlan: false,
751 pullRequests: [
752 {
753 host: "github.com",
754 repository: "pingdotgg/t3code",
755 number: 42,
756 url: "https://github.com/pingdotgg/t3code/pull/42",
757 source: "agent",
758 linkedAt: "2026-01-01T00:00:00.000Z",
759 snapshot: null,
760 stack: null,
761 },
762 {
763 host: "github.com",
764 repository: "pingdotgg/t3code",
765 number: 43,
766 url: "https://github.com/pingdotgg/t3code/pull/43",
767 source: "stack",
768 linkedAt: "2026-01-01T00:01:00.000Z",
769 snapshot: {
770 state: "open",
771 title: "Layer two",
772 headBranch: "feature/stack-2",
773 baseBranch: "feature/stack-1",
774 isDraft: false,
775 updatedAt: "2026-01-01T00:02:00.000Z",
776 syncedAt: "2026-01-01T00:03:00.000Z",
777 },
778 stack: {
779 kind: "native",
780 id: "7",
781 number: 3,
782 url: "https://github.com/pingdotgg/t3code/stacks/3",
783 base: "main",
784 layers: [
785 { number: 42, headBranch: "feature/stack-1", state: "open" },
786 { number: 43, headBranch: "feature/stack-2", state: "open" },
787 ],
788 },
789 },
790 ],
791 });
792
793 assert.strictEqual(shell.pullRequests.length, 2);
794 assert.strictEqual(shell.pullRequests[1]?.stack?.layers.length, 2);
795 assert.strictEqual(shell.pullRequests[1]?.snapshot?.state, "open");
685 }),796 }),
686);797);
687798
...@@ -966,25 +1077,64 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () =>...@@ -966,25 +1077,64 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () =>
966 }),1077 }),
967);1078);
9681079
969it.effect("accepts a linked pull request in thread.meta.update", () =>1080it.effect("accepts thread.pull-request.link and .unlink commands", () =>
970 Effect.gen(function* () {1081 Effect.gen(function* () {
971 const linkedPullRequest = {1082 const link = yield* decodeOrchestrationCommand({
972 projectId: "project-1",1083 type: "thread.pull-request.link",
1084 commandId: "cmd-link-pull-request",
1085 threadId: "thread-1",
1086 host: "github.com",
973 repository: "pingdotgg/t3code",1087 repository: "pingdotgg/t3code",
974 number: 42,1088 number: 42,
975 url: "https://github.com/pingdotgg/t3code/pull/42",1089 url: "https://github.com/pingdotgg/t3code/pull/42",
976 };1090 source: "manual",
977 const parsed = yield* decodeOrchestrationCommand({1091 });
978 type: "thread.meta.update",1092 assert.strictEqual(link.type, "thread.pull-request.link");
979 commandId: "cmd-link-pull-request",1093 if (link.type === "thread.pull-request.link") {
1094 assert.strictEqual(link.source, "manual");
1095 assert.strictEqual(link.number, 42);
1096 }
1097
1098 const unlink = yield* decodeOrchestrationCommand({
1099 type: "thread.pull-request.unlink",
1100 commandId: "cmd-unlink-pull-request",
980 threadId: "thread-1",1101 threadId: "thread-1",
981 linkedPullRequest,1102 host: "github.com",
1103 repository: "pingdotgg/t3code",
1104 number: 42,
982 });1105 });
1106 assert.strictEqual(unlink.type, "thread.pull-request.unlink");
1107 }),
1108);
9831109
984 assert.strictEqual(parsed.type, "thread.meta.update");1110it.effect("still decodes a persisted thread.meta-updated event carrying linkedPullRequest", () =>
985 if (parsed.type === "thread.meta.update") {1111 Effect.gen(function* () {
986 assert.deepStrictEqual(parsed.linkedPullRequest, linkedPullRequest);1112 const event = yield* decodeOrchestrationEvent({
1113 sequence: 1,
1114 eventId: "event-legacy-link",
1115 aggregateKind: "thread",
1116 aggregateId: "thread-1",
1117 type: "thread.meta-updated",
1118 occurredAt: "2026-01-01T00:00:00.000Z",
1119 commandId: "cmd-legacy-link",
1120 causationEventId: null,
1121 correlationId: "cmd-legacy-link",
1122 metadata: {},
1123 payload: {
1124 threadId: "thread-1",
1125 linkedPullRequest: {
1126 projectId: "project-1",
1127 repository: "pingdotgg/t3code",
1128 number: 42,
1129 url: "https://github.com/pingdotgg/t3code/pull/42",
1130 },
1131 updatedAt: "2026-01-01T00:00:00.000Z",
1132 },
1133 });
1134 if (event.type !== "thread.meta-updated") {
1135 assert.fail(`Expected thread.meta-updated event, received ${event.type}.`);
987 }1136 }
1137 assert.strictEqual(event.payload.linkedPullRequest?.number, 42);
988 }),1138 }),
989);1139);
9901140
packages/contracts/src/orchestration.ts+168
...@@ -23,6 +23,13 @@ import {...@@ -23,6 +23,13 @@ import {
23 TurnId,23 TurnId,
24} from "./baseSchemas.ts";24} from "./baseSchemas.ts";
25import { ProviderInstanceId } from "./providerInstance.ts";25import { ProviderInstanceId } from "./providerInstance.ts";
26import {
27 PullRequestActor,
28 PullRequestChecksState,
29 PullRequestMergeability,
30 PullRequestReviewDecision,
31 PullRequestState,
32} from "./pullRequest.ts";
2633
27export const ORCHESTRATION_WS_METHODS = {34export const ORCHESTRATION_WS_METHODS = {
28 dispatchCommand: "orchestration.dispatchCommand",35 dispatchCommand: "orchestration.dispatchCommand",
...@@ -599,6 +606,11 @@ export const ThreadTitleRegeneration = Schema.Struct({...@@ -599,6 +606,11 @@ export const ThreadTitleRegeneration = Schema.Struct({
599});606});
600export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;607export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;
601608
609/**
610 * Legacy single-PR link. Still emitted as the thread's derived current pull
611 * request (see `@t3tools/shared/threadPullRequests`) so clients from before
612 * `pullRequests` keep working independently of their release schedule.
613 */
602export const ThreadLinkedPullRequest = Schema.Struct({614export const ThreadLinkedPullRequest = Schema.Struct({
603 projectId: ProjectId,615 projectId: ProjectId,
604 repository: TrimmedNonEmptyString,616 repository: TrimmedNonEmptyString,
...@@ -607,6 +619,80 @@ export const ThreadLinkedPullRequest = Schema.Struct({...@@ -607,6 +619,80 @@ export const ThreadLinkedPullRequest = Schema.Struct({
607});619});
608export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type;620export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type;
609621
622/** Who created a thread ↔ pull request link. `stack-dismissed` is a tombstone
623 * for a native-stack member the user unlinked, so the sync reactor does not
624 * re-add it; clients hide it. */
625export const ThreadPullRequestLinkSource = Schema.Literals([
626 "manual",
627 "created",
628 "agent",
629 "stack",
630 "stack-dismissed",
631]);
632export type ThreadPullRequestLinkSource = typeof ThreadPullRequestLinkSource.Type;
633
634/**
635 * Host state persisted on a link by the sync reactor; null until first sync. The overview
636 * fields are optional: a host whose cheap read lacks them leaves them out, and snapshots
637 * written before they existed still decode.
638 */
639export const ThreadPullRequestSnapshot = Schema.Struct({
640 state: PullRequestState,
641 title: TrimmedNonEmptyString,
642 headBranch: TrimmedNonEmptyString,
643 baseBranch: TrimmedNonEmptyString,
644 isDraft: Schema.Boolean,
645 updatedAt: Schema.NullOr(IsoDateTime),
646 syncedAt: IsoDateTime,
647 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
648 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
649 author: Schema.optional(Schema.NullOr(PullRequestActor)),
650 additions: Schema.optional(NonNegativeInt),
651 deletions: Schema.optional(NonNegativeInt),
652 changedFiles: Schema.optional(NonNegativeInt),
653 reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)),
654 checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)),
655 mergeability: Schema.optional(PullRequestMergeability),
656});
657export type ThreadPullRequestSnapshot = typeof ThreadPullRequestSnapshot.Type;
658
659export const ThreadPullRequestStackLayer = Schema.Struct({
660 number: PositiveInt,
661 headBranch: TrimmedNonEmptyString,
662 state: PullRequestState,
663});
664export type ThreadPullRequestStackLayer = typeof ThreadPullRequestStackLayer.Type;
665
666/** A host-native stack the pull request belongs to. Layers run bottom to top. */
667export const ThreadPullRequestStack = Schema.Struct({
668 kind: Schema.Literal("native"),
669 id: TrimmedNonEmptyString,
670 number: PositiveInt,
671 url: TrimmedNonEmptyString,
672 base: TrimmedNonEmptyString,
673 layers: Schema.Array(ThreadPullRequestStackLayer),
674});
675export type ThreadPullRequestStack = typeof ThreadPullRequestStack.Type;
676
677/** Identity of a pull request as a thread link sees it: host-level, so the
678 * same PR linked from two projects (or two environments) compares equal. */
679export const ThreadPullRequestKey = Schema.Struct({
680 host: TrimmedNonEmptyString,
681 repository: TrimmedNonEmptyString,
682 number: PositiveInt,
683});
684export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type;
685
686export const ThreadPullRequestLink = Schema.Struct({
687 ...ThreadPullRequestKey.fields,
688 url: TrimmedNonEmptyString,
689 source: ThreadPullRequestLinkSource,
690 linkedAt: IsoDateTime,
691 snapshot: Schema.NullOr(ThreadPullRequestSnapshot),
692 stack: Schema.NullOr(ThreadPullRequestStack),
693});
694export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type;
695
610export const OrchestrationThread = Schema.Struct({696export const OrchestrationThread = Schema.Struct({
611 id: ThreadId,697 id: ThreadId,
612 projectId: ProjectId,698 projectId: ProjectId,
...@@ -619,6 +705,10 @@ export const OrchestrationThread = Schema.Struct({...@@ -619,6 +705,10 @@ export const OrchestrationThread = Schema.Struct({
619 branch: Schema.NullOr(TrimmedNonEmptyString),705 branch: Schema.NullOr(TrimmedNonEmptyString),
620 worktreePath: Schema.NullOr(TrimmedNonEmptyString),706 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
621 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),707 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
708 // Optional so payloads from pre-link servers still decode.
709 pullRequests: Schema.Array(ThreadPullRequestLink).pipe(
710 Schema.withDecodingDefault(Effect.succeed([])),
711 ),
622 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),712 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
623 latestTurn: Schema.NullOr(OrchestrationLatestTurn),713 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
624 createdAt: IsoDateTime,714 createdAt: IsoDateTime,
...@@ -701,6 +791,9 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -701,6 +791,9 @@ export const OrchestrationThreadShell = Schema.Struct({
701 branch: Schema.NullOr(TrimmedNonEmptyString),791 branch: Schema.NullOr(TrimmedNonEmptyString),
702 worktreePath: Schema.NullOr(TrimmedNonEmptyString),792 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
703 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),793 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
794 pullRequests: Schema.Array(ThreadPullRequestLink).pipe(
795 Schema.withDecodingDefault(Effect.succeed([])),
796 ),
704 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),797 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
705 latestTurn: Schema.NullOr(OrchestrationLatestTurn),798 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
706 createdAt: IsoDateTime,799 createdAt: IsoDateTime,
...@@ -1050,6 +1143,22 @@ const ThreadMetaUpdateCommand = Schema.Struct({...@@ -1050,6 +1143,22 @@ const ThreadMetaUpdateCommand = Schema.Struct({
1050 ),1143 ),
1051);1144);
10521145
1146const ThreadPullRequestLinkCommand = Schema.Struct({
1147 type: Schema.Literal("thread.pull-request.link"),
1148 commandId: CommandId,
1149 threadId: ThreadId,
1150 ...ThreadPullRequestKey.fields,
1151 url: TrimmedNonEmptyString,
1152 source: ThreadPullRequestLinkSource,
1153});
1154
1155const ThreadPullRequestUnlinkCommand = Schema.Struct({
1156 type: Schema.Literal("thread.pull-request.unlink"),
1157 commandId: CommandId,
1158 threadId: ThreadId,
1159 ...ThreadPullRequestKey.fields,
1160});
1161
1053const ThreadRuntimeModeSetCommand = Schema.Struct({1162const ThreadRuntimeModeSetCommand = Schema.Struct({
1054 type: Schema.Literal("thread.runtime-mode.set"),1163 type: Schema.Literal("thread.runtime-mode.set"),
1055 commandId: CommandId,1164 commandId: CommandId,
...@@ -1208,6 +1317,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -1208,6 +1317,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
1208 ThreadPinReorderCommand,1317 ThreadPinReorderCommand,
1209 ThreadActiveReorderCommand,1318 ThreadActiveReorderCommand,
1210 ThreadMetaUpdateCommand,1319 ThreadMetaUpdateCommand,
1320 ThreadPullRequestLinkCommand,
1321 ThreadPullRequestUnlinkCommand,
1211 ThreadRuntimeModeSetCommand,1322 ThreadRuntimeModeSetCommand,
1212 ThreadInteractionModeSetCommand,1323 ThreadInteractionModeSetCommand,
1213 ThreadTurnStartCommand,1324 ThreadTurnStartCommand,
...@@ -1238,6 +1349,8 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -1238,6 +1349,8 @@ export const ClientOrchestrationCommand = Schema.Union([
1238 ThreadPinReorderCommand,1349 ThreadPinReorderCommand,
1239 ThreadActiveReorderCommand,1350 ThreadActiveReorderCommand,
1240 ThreadMetaUpdateCommand,1351 ThreadMetaUpdateCommand,
1352 ThreadPullRequestLinkCommand,
1353 ThreadPullRequestUnlinkCommand,
1241 ThreadRuntimeModeSetCommand,1354 ThreadRuntimeModeSetCommand,
1242 ThreadInteractionModeSetCommand,1355 ThreadInteractionModeSetCommand,
1243 ClientThreadTurnStartCommand,1356 ClientThreadTurnStartCommand,
...@@ -1354,8 +1467,19 @@ const ThreadPullRequestSyncCommand = Schema.Struct({...@@ -1354,8 +1467,19 @@ const ThreadPullRequestSyncCommand = Schema.Struct({
1354 linkedPullRequest: Schema.optional(ThreadLinkedPullRequest),1467 linkedPullRequest: Schema.optional(ThreadLinkedPullRequest),
1355});1468});
13561469
1470const ThreadPullRequestLinkSyncCommand = Schema.Struct({
1471 type: Schema.Literal("thread.pull-request-link.sync"),
1472 commandId: CommandId,
1473 threadId: ThreadId,
1474 ...ThreadPullRequestKey.fields,
1475 snapshot: ThreadPullRequestSnapshot,
1476 stack: Schema.NullOr(ThreadPullRequestStack),
1477});
1478
1357const InternalOrchestrationCommand = Schema.Union([1479const InternalOrchestrationCommand = Schema.Union([
1358 ThreadAutoSettleCommand,1480 ThreadAutoSettleCommand,
1481 ThreadPullRequestSyncCommand,
1482 ThreadPullRequestLinkSyncCommand,
1359 ThreadSessionSetCommand,1483 ThreadSessionSetCommand,
1360 ThreadMessageAssistantDeltaCommand,1484 ThreadMessageAssistantDeltaCommand,
1361 ThreadMessageAssistantCompleteCommand,1485 ThreadMessageAssistantCompleteCommand,
...@@ -1366,6 +1490,7 @@ const InternalOrchestrationCommand = Schema.Union([...@@ -1366,6 +1490,7 @@ const InternalOrchestrationCommand = Schema.Union([
1366 ThreadRevertCompleteCommand,1490 ThreadRevertCompleteCommand,
1367 ThreadTitleRegenerationCompleteCommand,1491 ThreadTitleRegenerationCompleteCommand,
1368 ThreadPullRequestSyncCommand,1492 ThreadPullRequestSyncCommand,
1493 ThreadPullRequestLinkSyncCommand,
1369]);1494]);
1370export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;1495export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
13711496
...@@ -1391,6 +1516,9 @@ export const OrchestrationEventType = Schema.Literals([...@@ -1391,6 +1516,9 @@ export const OrchestrationEventType = Schema.Literals([
1391 "thread.unpinned",1516 "thread.unpinned",
1392 "thread.pin-reordered",1517 "thread.pin-reordered",
1393 "thread.meta-updated",1518 "thread.meta-updated",
1519 "thread.pull-request-linked",
1520 "thread.pull-request-unlinked",
1521 "thread.pull-request-synced",
1394 "thread.runtime-mode-set",1522 "thread.runtime-mode-set",
1395 "thread.interaction-mode-set",1523 "thread.interaction-mode-set",
1396 "thread.message-sent",1524 "thread.message-sent",
...@@ -1541,11 +1669,36 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({...@@ -1541,11 +1669,36 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
1541 modelSelection: Schema.optional(ModelSelection),1669 modelSelection: Schema.optional(ModelSelection),
1542 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1670 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1543 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),1671 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
1672 // No longer produced; kept so persisted events from before
1673 // thread.pull-request-linked still decode and replay into the link table.
1544 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),1674 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
1545 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),1675 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
1546 updatedAt: IsoDateTime,1676 updatedAt: IsoDateTime,
1547});1677});
15481678
1679export const ThreadPullRequestLinkedPayload = Schema.Struct({
1680 threadId: ThreadId,
1681 link: ThreadPullRequestLink,
1682 updatedAt: IsoDateTime,
1683});
1684export type ThreadPullRequestLinkedPayload = typeof ThreadPullRequestLinkedPayload.Type;
1685
1686export const ThreadPullRequestUnlinkedPayload = Schema.Struct({
1687 threadId: ThreadId,
1688 ...ThreadPullRequestKey.fields,
1689 updatedAt: IsoDateTime,
1690});
1691export type ThreadPullRequestUnlinkedPayload = typeof ThreadPullRequestUnlinkedPayload.Type;
1692
1693export const ThreadPullRequestSyncedPayload = Schema.Struct({
1694 threadId: ThreadId,
1695 ...ThreadPullRequestKey.fields,
1696 snapshot: ThreadPullRequestSnapshot,
1697 stack: Schema.NullOr(ThreadPullRequestStack),
1698 updatedAt: IsoDateTime,
1699});
1700export type ThreadPullRequestSyncedPayload = typeof ThreadPullRequestSyncedPayload.Type;
1701
1549export const ThreadRuntimeModeSetPayload = Schema.Struct({1702export const ThreadRuntimeModeSetPayload = Schema.Struct({
1550 threadId: ThreadId,1703 threadId: ThreadId,
1551 runtimeMode: RuntimeMode,1704 runtimeMode: RuntimeMode,
...@@ -1759,6 +1912,21 @@ export const OrchestrationEvent = Schema.Union([...@@ -1759,6 +1912,21 @@ export const OrchestrationEvent = Schema.Union([
1759 type: Schema.Literal("thread.meta-updated"),1912 type: Schema.Literal("thread.meta-updated"),
1760 payload: ThreadMetaUpdatedPayload,1913 payload: ThreadMetaUpdatedPayload,
1761 }),1914 }),
1915 Schema.Struct({
1916 ...EventBaseFields,
1917 type: Schema.Literal("thread.pull-request-linked"),
1918 payload: ThreadPullRequestLinkedPayload,
1919 }),
1920 Schema.Struct({
1921 ...EventBaseFields,
1922 type: Schema.Literal("thread.pull-request-unlinked"),
1923 payload: ThreadPullRequestUnlinkedPayload,
1924 }),
1925 Schema.Struct({
1926 ...EventBaseFields,
1927 type: Schema.Literal("thread.pull-request-synced"),
1928 payload: ThreadPullRequestSyncedPayload,
1929 }),
1762 Schema.Struct({1930 Schema.Struct({
1763 ...EventBaseFields,1931 ...EventBaseFields,
1764 type: Schema.Literal("thread.runtime-mode-set"),1932 type: Schema.Literal("thread.runtime-mode-set"),
packages/contracts/src/previewAutomation.ts+21-4
...@@ -633,14 +633,31 @@ export const PreviewAutomationResponse = Schema.Struct({...@@ -633,14 +633,31 @@ export const PreviewAutomationResponse = Schema.Struct({
633});633});
634export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;634export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;
635635
636const McpCapabilityErrorFields = {
637 environmentId: EnvironmentId,
638 threadId: ThreadId,
639 providerSessionId: TrimmedNonEmptyString,
640 providerInstanceId: ProviderInstanceId,
641};
642
636export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()(643export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()(
637 "PreviewAutomationUnavailableError",644 "PreviewAutomationUnavailableError",
638 {645 {
639 capability: Schema.Literal("preview"),646 capability: Schema.Literal("preview"),
640 environmentId: EnvironmentId,647 ...McpCapabilityErrorFields,
641 threadId: ThreadId,648 },
642 providerSessionId: TrimmedNonEmptyString,649) {
643 providerInstanceId: ProviderInstanceId,650 override get message(): string {
651 return `MCP credential does not grant the ${this.capability} capability.`;
652 }
653}
654
655/** A `t3-code` MCP tool was called with a credential that does not carry its capability. */
656export class McpCapabilityUnavailableError extends Schema.TaggedError<McpCapabilityUnavailableError>()(
657 "McpCapabilityUnavailableError",
658 {
659 capability: TrimmedNonEmptyString,
660 ...McpCapabilityErrorFields,
644 },661 },
645) {662) {
646 override get message(): string {663 override get message(): string {
packages/contracts/src/pullRequest.ts+49
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 NonNegativeInt,7 NonNegativeInt,
8 PositiveInt,8 PositiveInt,
9 ProjectId,9 ProjectId,
10 ThreadId,
10 TrimmedNonEmptyString,11 TrimmedNonEmptyString,
11} from "./baseSchemas.ts";12} from "./baseSchemas.ts";
12import { SourceControlProviderKind } from "./sourceControl.ts";13import { SourceControlProviderKind } from "./sourceControl.ts";
...@@ -412,6 +413,11 @@ export const PullRequestCapabilities = Schema.Struct({...@@ -412,6 +413,11 @@ export const PullRequestCapabilities = Schema.Struct({
412 * what every server before this one was.413 * what every server before this one was.
413 */414 */
414 edit: Schema.optional(PullRequestEditCapabilities),415 edit: Schema.optional(PullRequestEditCapabilities),
416 /**
417 * The host keeps stacks of change requests as objects of its own, so a linked thread can show
418 * the stack the host shows. Absent means chains are only ever inferred from base branches.
419 */
420 stacks: Schema.optional(Schema.Boolean),
415 /**421 /**
416 * The repository's labels can be listed, and one put on a change request or taken off it.422 * The repository's labels can be listed, and one put on a change request or taken off it.
417 * Optional for the same reason `edit` is: a server that says nothing about labels has no way423 * Optional for the same reason `edit` is: a server that says nothing about labels has no way
...@@ -615,13 +621,33 @@ export const PullRequestListResult = Schema.Struct({...@@ -615,13 +621,33 @@ export const PullRequestListResult = Schema.Struct({
615});621});
616export type PullRequestListResult = typeof PullRequestListResult.Type;622export type PullRequestListResult = typeof PullRequestListResult.Type;
617623
624/**
625 * Addresses one pull request for reads and writes. `projectId` picks the checkout the host
626 * CLI runs in and, when its repository matches, the credentials; `host` lets the server
627 * route a pull request from another repository through any project on the same host
628 * (a frontend project's thread linking a backend PR). Absent `host` means "the project's
629 * own host", which is every reference from before thread links became host-level.
630 */
618export const PullRequestRef = Schema.Struct({631export const PullRequestRef = Schema.Struct({
619 projectId: ProjectId,632 projectId: ProjectId,
633 host: Schema.optional(TrimmedNonEmptyString),
620 repository: TrimmedNonEmptyString,634 repository: TrimmedNonEmptyString,
621 number: PositiveInt,635 number: PositiveInt,
622});636});
623export type PullRequestRef = typeof PullRequestRef.Type;637export type PullRequestRef = typeof PullRequestRef.Type;
624638
639export const PullRequestLinkedThreadsResult = Schema.Struct({
640 threads: Schema.Array(
641 Schema.Struct({
642 id: ThreadId,
643 projectId: ProjectId,
644 title: Schema.String,
645 archivedAt: Schema.NullOr(IsoDateTime),
646 }),
647 ),
648});
649export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type;
650
625/**651/**
626 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar652 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
627 * status check never loads permissions, repository settings, checks, or base comparison data.653 * status check never loads permissions, repository settings, checks, or base comparison data.
...@@ -641,9 +667,32 @@ export const PullRequestSummary = Schema.Struct({...@@ -641,9 +667,32 @@ export const PullRequestSummary = Schema.Struct({
641 closedAt: Schema.optional(Schema.NullOr(Schema.String)),667 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
642 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),668 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
643 updatedAt: IsoDateTime,669 updatedAt: IsoDateTime,
670 author: Schema.optional(Schema.NullOr(PullRequestActor)),
671 additions: Schema.optional(NonNegativeInt),
672 deletions: Schema.optional(NonNegativeInt),
673 changedFiles: Schema.optional(NonNegativeInt),
674 reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)),
675 checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)),
676 mergeability: Schema.optional(PullRequestMergeability),
644});677});
645export type PullRequestSummary = typeof PullRequestSummary.Type;678export type PullRequestSummary = typeof PullRequestSummary.Type;
646679
680/** The host-native stack a pull request belongs to, in the thread link's shape. */
681export const PullRequestStack = Schema.Struct({
682 id: TrimmedNonEmptyString,
683 number: PositiveInt,
684 url: TrimmedNonEmptyString,
685 base: TrimmedNonEmptyString,
686 layers: Schema.Array(
687 Schema.Struct({
688 number: PositiveInt,
689 headBranch: TrimmedNonEmptyString,
690 state: PullRequestState,
691 }),
692 ),
693});
694export type PullRequestStack = typeof PullRequestStack.Type;
695
647/**696/**
648 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is697 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is
649 * 40-60% of the wall clock of the search that answers the whole page — measured over twelve698 * 40-60% of the wall clock of the search that answers the whole page — measured over twelve
packages/contracts/src/rpc.ts+18
...@@ -115,6 +115,8 @@ import {...@@ -115,6 +115,8 @@ import {
115 PullRequestOperationError,115 PullRequestOperationError,
116 PullRequestReactionInput,116 PullRequestReactionInput,
117 PullRequestRef,117 PullRequestRef,
118 PullRequestStack,
119 PullRequestLinkedThreadsResult,
118 PullRequestSummary,120 PullRequestSummary,
119 PullRequestReviewerCandidateList,121 PullRequestReviewerCandidateList,
120 PullRequestReviewerRequestInput,122 PullRequestReviewerRequestInput,
...@@ -344,6 +346,8 @@ export const WS_METHODS = {...@@ -344,6 +346,8 @@ export const WS_METHODS = {
344 pullRequestsList: "pullRequests.list",346 pullRequestsList: "pullRequests.list",
345 pullRequestsListStats: "pullRequests.listStats",347 pullRequestsListStats: "pullRequests.listStats",
346 pullRequestsSummary: "pullRequests.summary",348 pullRequestsSummary: "pullRequests.summary",
349 pullRequestsStack: "pullRequests.stack",
350 pullRequestsLinkedThreads: "pullRequests.linkedThreads",
347 pullRequestsDetail: "pullRequests.detail",351 pullRequestsDetail: "pullRequests.detail",
348 pullRequestsActivity: "pullRequests.activity",352 pullRequestsActivity: "pullRequests.activity",
349 pullRequestsThreadComments: "pullRequests.threadComments",353 pullRequestsThreadComments: "pullRequests.threadComments",
...@@ -648,6 +652,18 @@ const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {...@@ -648,6 +652,18 @@ const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {
648 error: PullRequestRpcError,652 error: PullRequestRpcError,
649});653});
650654
655const WsPullRequestsStackRpc = Rpc.make(WS_METHODS.pullRequestsStack, {
656 payload: PullRequestRef,
657 success: Schema.NullOr(PullRequestStack),
658 error: PullRequestRpcError,
659});
660
661const WsPullRequestsLinkedThreadsRpc = Rpc.make(WS_METHODS.pullRequestsLinkedThreads, {
662 payload: PullRequestRef,
663 success: PullRequestLinkedThreadsResult,
664 error: PullRequestRpcError,
665});
666
651const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {667const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
652 payload: PullRequestRef,668 payload: PullRequestRef,
653 success: PullRequestDetail,669 success: PullRequestDetail,
...@@ -1221,6 +1237,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1221,6 +1237,8 @@ export const WsRpcGroup = RpcGroup.make(
1221 WsPullRequestsListRpc,1237 WsPullRequestsListRpc,
1222 WsPullRequestsListStatsRpc,1238 WsPullRequestsListStatsRpc,
1223 WsPullRequestsSummaryRpc,1239 WsPullRequestsSummaryRpc,
1240 WsPullRequestsStackRpc,
1241 WsPullRequestsLinkedThreadsRpc,
1224 WsPullRequestsDetailRpc,1242 WsPullRequestsDetailRpc,
1225 WsPullRequestsActivityRpc,1243 WsPullRequestsActivityRpc,
1226 WsPullRequestsThreadCommentsRpc,1244 WsPullRequestsThreadCommentsRpc,
packages/shared/package.json+8
...@@ -175,6 +175,14 @@...@@ -175,6 +175,14 @@
175 "types": "./src/threadReference.ts",175 "types": "./src/threadReference.ts",
176 "import": "./src/threadReference.ts"176 "import": "./src/threadReference.ts"
177 },177 },
178 "./threadPullRequests": {
179 "types": "./src/threadPullRequests.ts",
180 "import": "./src/threadPullRequests.ts"
181 },
182 "./changeRequestUrl": {
183 "types": "./src/changeRequestUrl.ts",
184 "import": "./src/changeRequestUrl.ts"
185 },
178 "./composerTrigger": {186 "./composerTrigger": {
179 "types": "./src/composerTrigger.ts",187 "types": "./src/composerTrigger.ts",
180 "import": "./src/composerTrigger.ts"188 "import": "./src/composerTrigger.ts"
packages/shared/src/changeRequestUrl.test.ts created+142
...@@ -0,0 +1,142 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 changeRequestUrlFor,
5 parseChangeRequestUrl,
6 pullRequestCandidateUrlFromReferenceAutolink,
7 siblingPullRequestUrl,
8} from "./changeRequestUrl.ts";
9
10describe("parseChangeRequestUrl", () => {
11 it("reads a GitHub pull request, lower-casing the repository", () => {
12 expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({
13 host: "github.com",
14 repository: "t3tools/t3code",
15 number: 123,
16 });
17 });
18
19 it("reads a pull request on a GitHub Enterprise host", () => {
20 expect(parseChangeRequestUrl("https://github.acme.test/platform/api/pull/7")).toEqual({
21 host: "github.acme.test",
22 repository: "platform/api",
23 number: 7,
24 });
25 });
26
27 it("reads a supported GitHub host with a middle DNS label", () => {
28 expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({
29 host: "code.github.example.com",
30 repository: "acme/web",
31 number: 42,
32 });
33 expect(
34 pullRequestCandidateUrlFromReferenceAutolink(
35 "https://code.github.example.com/acme/web/issues/42",
36 ),
37 ).toBe("https://code.github.example.com/acme/web/pull/42");
38 });
39
40 it("reads a GitLab merge request on any host, nested groups and all", () => {
41 expect(
42 parseChangeRequestUrl("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42"),
43 ).toEqual({ host: "gitlab.com", repository: "t3tools/platform/t3code", number: 42 });
44 expect(parseChangeRequestUrl("https://code.acme.test/team/project/-/merge_requests/9")).toEqual(
45 { host: "code.acme.test", repository: "team/project", number: 9 },
46 );
47 });
48
49 it("reads Bitbucket and both Azure DevOps URL forms", () => {
50 expect(parseChangeRequestUrl("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({
51 host: "bitbucket.org",
52 repository: "workspace/repo",
53 number: 5,
54 });
55 expect(
56 parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"),
57 ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 });
58 expect(
59 parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"),
60 ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 });
61 });
62
63 it("survives trailing segments, a trailing slash and a query string", () => {
64 expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({
65 host: "github.com",
66 repository: "t3tools/t3code",
67 number: 123,
68 });
69 expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/")).toEqual({
70 host: "github.com",
71 repository: "t3tools/t3code",
72 number: 123,
73 });
74 });
75
76 it("claims nothing it cannot be sure of", () => {
77 for (const link of [
78 "https://github.com/t3tools/t3code/issues/123",
79 "https://github.com/t3tools/t3code/commit/0a1b2c3",
80 "https://github.com/t3tools/t3code",
81 "https://github.com/t3tools/t3code/pull/abc",
82 "https://gitlab.com/t3tools/t3code/-/issues/12",
83 "https://blog.example.test/2026/updates/pull/3",
84 "javascript:alert(1)//github.com/t3tools/t3code/pull/1",
85 "not a url",
86 ]) {
87 expect(parseChangeRequestUrl(link), link).toBeNull();
88 }
89 });
90});
91
92describe("siblingPullRequestUrl", () => {
93 it.each([
94 ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],
95 [
96 "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs",
97 "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/43",
98 ],
99
100 ["https://github.com/acme/web/pull/42#discussion_r123", "https://github.com/acme/web/pull/43"],
101 ["https://github.com/acme/web/pull/42/files?w=1", "https://github.com/acme/web/pull/43"],
102 [
103 "https://github.acme.test:8443/acme/web/pull/42/",
104 "https://github.acme.test:8443/acme/web/pull/43",
105 ],
106 [
107 "https://git.acme.test/acme/web/-/merge_requests/42/diffs",
108 "https://git.acme.test/acme/web/-/merge_requests/43",
109 ],
110 [
111 "https://bitbucket.org/acme/web/pull-requests/42",
112 "https://bitbucket.org/acme/web/pull-requests/43",
113 ],
114 [
115 "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files",
116 "https://dev.azure.com/acme/project/_git/web/pullrequest/43",
117 ],
118 ])("builds a canonical sibling of %s", (url, expected) => {
119 expect(siblingPullRequestUrl(url, 43)).toBe(expected);
120 });
121 it("rejects non-PR URLs and invalid numbers", () => {
122 expect(siblingPullRequestUrl("https://github.com/acme/web/issues/42", 43)).toBeNull();
123 expect(siblingPullRequestUrl("https://github.com/acme/web/pull/42", 0)).toBeNull();
124 });
125});
126
127describe("changeRequestUrlFor", () => {
128 it.each([
129 ["ssh.dev.azure.com", "v3/org/project/web"],
130 ["vs-ssh.visualstudio.com", "v3/org/project/web"],
131 ["org.visualstudio.com", "defaultcollection/project/_git/web"],
132 ["dev.azure.com", "org/project/_git/web"],
133 ])("builds a browser URL from the Azure remote %s/%s", (host, repository) => {
134 const url = changeRequestUrlFor("azure-devops", host, repository, 42);
135 expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42");
136 expect(parseChangeRequestUrl(url!)).toEqual({
137 host: "dev.azure.com",
138 repository: "org/project/_git/web",
139 number: 42,
140 });
141 });
142});
packages/shared/src/changeRequestUrl.ts created+210
...@@ -0,0 +1,210 @@
1import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts";
2import { canonicalRepositoryKey } from "./sourceControl.ts";
3
4/**
5 * A change request named the way a thread link names one: the host below which the repository
6 * is addressed, the repository path as that host writes it, and the number.
7 *
8 * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce
9 * from a git remote — lower case, no port, the full path below the host — because links are
10 * matched against those. Anything else matches nothing.
11 */
12export interface ChangeRequestLink {
13 readonly host: string;
14 readonly repository: string;
15 readonly number: number;
16}
17
18/** The host itself, one of its subdomains, or an install named after the provider. */
19function isHostOf(hostname: string, apex: string, label?: string): boolean {
20 if (hostname === apex || hostname.endsWith(`.${apex}`)) return true;
21 return label !== undefined && hostname.split(".").includes(label);
22}
23
24/**
25 * The repository and number behind a change request URL on a host this can read, or null for
26 * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an
27 * ordinary link. A doubtful match is worse than no match, so nothing here guesses.
28 *
29 * Each host is recognised by the path shape it alone uses, guarded by a hostname it could
30 * plausibly be served from, since self-hosted installs are named whatever their admin chose:
31 * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic
32 * enough that it is only believed from a GitHub-ish host.
33 *
34 * Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test` and
35 * the rest are an open set, and blocking spellings of it costs real hosts (`gitlab.com.br` is a
36 * registrable domain). What a claim is worth is decided where it is used.
37 */
38export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null {
39 let url: URL;
40 try {
41 url = new URL(targetUrl);
42 } catch {
43 return null;
44 }
45 // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open.
46 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
47 const host = url.hostname.toLowerCase();
48
49 // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
50 if (isHostOf(host, "github.com", "github")) {
51 const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
52 return claim(host, match);
53 }
54 // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
55 // separator is GitLab's own, so the hostname is not asked about.
56 const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
57 if (gitlab) return claim(host, gitlab);
58 // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n}
59 if (isHostOf(host, "bitbucket.org", "bitbucket")) {
60 const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname);
61 return claim(host, match);
62 }
63 // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part
64 // of the repository path there, as it is in the remote URL the identity is read from.
65 if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) {
66 const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname);
67 return claim(host, match);
68 }
69 return null;
70}
71
72function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
73 const repository = match?.[1];
74 const number = Number(match?.[2]);
75 return repository && Number.isSafeInteger(number) && number > 0
76 ? { host, repository: repository.toLowerCase(), number }
77 : null;
78}
79
80/** The web URL a host writes for a change request; null when the host shape is unknown. */
81export function changeRequestUrlFor(
82 kind: string | null | undefined,
83 host: string,
84 repository: string,
85 number: number,
86): string | null {
87 switch (kind) {
88 case "github":
89 return `https://${host}/${repository}/pull/${number}`;
90 case "gitlab":
91 return `https://${host}/${repository}/-/merge_requests/${number}`;
92 case "bitbucket":
93 return `https://${host}/${repository}/pull-requests/${number}`;
94 case "azure-devops":
95 return `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`;
96 default:
97 return null;
98 }
99}
100
101/** Builds a GitHub URL that remains available when the pull request API cannot be read. */
102export function gitHubPullRequestBrowserUrl(
103 identity: RepositoryIdentity | null | undefined,
104 repository: string,
105 number: number,
106): string | null {
107 if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null;
108 const repositoryPath = repository.split("/");
109 if (
110 repositoryPath.length !== 2 ||
111 repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..")
112 ) {
113 return null;
114 }
115
116 let origin: string | null = null;
117 try {
118 const remoteUrl = new URL(identity.locator.remoteUrl.trim());
119 if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") {
120 origin = remoteUrl.origin;
121 }
122 } catch {
123 // SCP-style remotes are read from their normalized identity below.
124 }
125 const hostname = identity.canonicalKey.split("/")[0];
126 if (origin === null && !hostname) return null;
127
128 try {
129 const url = new URL(origin ?? `https://${hostname}`);
130 url.pathname = `/${repositoryPath.join("/")}/pull/${number}`;
131 return url.toString();
132 } catch {
133 return null;
134 }
135}
136
137/**
138 * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare
139 * reference through `/issues/`, including pull requests, so this only builds a candidate: the
140 * caller must successfully read it as a pull request before treating it as one.
141 */
142export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null {
143 let url: URL;
144 try {
145 url = new URL(targetUrl);
146 } catch {
147 return null;
148 }
149 if (
150 (url.protocol !== "https:" && url.protocol !== "http:") ||
151 !(
152 url.hostname.toLowerCase() === "github.com" ||
153 url.hostname.toLowerCase().endsWith(".github.com") ||
154 url.hostname.toLowerCase().split(".").includes("github")
155 )
156 ) {
157 return null;
158 }
159 const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname);
160 if (match?.[1] === undefined || match[2] === undefined) return null;
161 url.pathname = `/${match[1]}/pull/${match[2]}`;
162 return url.toString();
163}
164
165/** Match a stored PR without requiring its project to remain available. */
166export function matchesLinkedPullRequestUrl(
167 linkedPullRequest: ThreadLinkedPullRequest,
168 targetUrl: string,
169): boolean {
170 const linked = parseChangeRequestUrl(linkedPullRequest.url);
171 const target = parseChangeRequestUrl(targetUrl);
172 return (
173 linked !== null &&
174 target !== null &&
175 linked.host === target.host &&
176 linked.repository === target.repository &&
177 linked.number === target.number
178 );
179}
180
181/** The repository root behind a recognised change-request URL, without PR-specific state. */
182export function changeRequestRepositoryUrl(targetUrl: string): string | null {
183 const changeRequest = parseChangeRequestUrl(targetUrl);
184 if (changeRequest === null) return null;
185 const url = new URL(targetUrl);
186 const repositoryPath =
187 /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
188 /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
189 url.pathname,
190 )?.[1];
191 if (!repositoryPath) return null;
192 url.pathname = repositoryPath;
193 url.search = "";
194 url.hash = "";
195 return url.toString();
196}
197
198export function siblingPullRequestUrl(url: string, number: number): string | null {
199 const reference = parseChangeRequestUrl(url);
200 if (reference === null || !Number.isSafeInteger(number) || number < 1) return null;
201 const sibling = new URL(url);
202 const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec(
203 sibling.pathname.slice(reference.repository.length + 1),
204 )?.[1];
205 if (route === undefined) return null;
206 sibling.pathname = `/${reference.repository}/${route}/${number}`;
207 sibling.search = "";
208 sibling.hash = "";
209 return sibling.toString();
210}
packages/shared/src/sourceControl.test.ts+32
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 sourceControlRepositorySelector,
4 detectSourceControlProviderFromRemoteUrl,5 detectSourceControlProviderFromRemoteUrl,
5 getChangeRequestTerminologyForKind,6 getChangeRequestTerminologyForKind,
6 isSshRemoteUrl,7 isSshRemoteUrl,
...@@ -160,3 +161,34 @@ describe("isSshRemoteUrl", () => {...@@ -160,3 +161,34 @@ describe("isSshRemoteUrl", () => {
160 expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false);161 expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false);
161 });162 });
162});163});
164
165it("names an Azure DevOps repository by its own name, not its project path", () => {
166 // `az repos pr list --repository` takes a name and detects the organisation and project from
167 // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then
168 // reads as unavailable on the page.
169 const selector = sourceControlRepositorySelector({
170 provider: "azure-devops",
171 displayName: "contoso/payments/_git/checkout",
172 owner: "contoso",
173 name: "checkout",
174 });
175 expect(selector).toBe("checkout");
176});
177
178it("falls back to the path's last segment where an Azure identity has no name", () => {
179 const selector = sourceControlRepositorySelector({
180 provider: "azure-devops",
181 displayName: "contoso/payments/_git/checkout",
182 });
183 expect(selector).toBe("checkout");
184});
185
186it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => {
187 const selector = sourceControlRepositorySelector({
188 provider: "gitlab",
189 displayName: "group/subgroup/service",
190 owner: "group",
191 name: "service",
192 });
193 expect(selector).toBe("group/subgroup/service");
194});
packages/shared/src/sourceControl.ts+45-1
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";1import type {
2 RepositoryIdentity,
3 SourceControlProviderInfo,
4 SourceControlProviderKind,
5} from "@t3tools/contracts";
26
3export interface ChangeRequestPresentation {7export interface ChangeRequestPresentation {
4 readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";8 readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
...@@ -234,3 +238,43 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -234,3 +238,43 @@ export function detectSourceControlProviderFromRemoteUrl(
234 baseUrl: toBaseUrl(host),238 baseUrl: toBaseUrl(host),
235 };239 };
236}240}
241
242/**
243 * The provider-native repository selector. `displayName` is the full path below the host, which
244 * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
245 * recorded before that field existed.
246 *
247 * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and
248 * takes the organisation and project from the checkout it detects — so the recorded
249 * `org/project/_git/repo` path is refused outright and the whole repository reads as
250 * unavailable. Its name is the last segment, which is what this hands over.
251 *
252 * One function because everything downstream is keyed by what it answers: the rows' own
253 * `repository`, the per-repository cursors, and the detail and diff reads a row leads to.
254 */
255export function sourceControlRepositorySelector(
256 identity:
257 | Pick<RepositoryIdentity, "provider" | "displayName" | "owner" | "name">
258 | null
259 | undefined,
260): string | null {
261 if (!identity) return null;
262 if (identity.provider === "azure-devops") {
263 const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git");
264 return identity.name || segments.at(-1) || null;
265 }
266 if (identity.displayName) return identity.displayName;
267 return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
268}
269
270export function canonicalRepositoryKey(key: string): string {
271 return key
272 .replace(
273 /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u,
274 "dev.azure.com/$1/$2/_git/$3",
275 )
276 .replace(
277 /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u,
278 "dev.azure.com/$1/$2/_git/$3",
279 );
280}
packages/shared/src/threadPullRequests.test.ts created+351
...@@ -0,0 +1,351 @@
1import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 legacyLinkedPullRequestOf,
6 legacyThreadPullRequestKey,
7 resolveThreadCurrentPullRequest,
8 resolveThreadPullRequestChains,
9 resolveThreadPullRequestBadge,
10 threadPullRequestKeysEqual,
11} from "./threadPullRequests.ts";
12
13function snapshot(input: Partial<ThreadPullRequestSnapshot> = {}): ThreadPullRequestSnapshot {
14 return {
15 state: "open",
16 title: "Change",
17 headBranch: "feature",
18 baseBranch: "main",
19 isDraft: false,
20 updatedAt: "2026-01-01T00:00:00.000Z",
21 syncedAt: "2026-01-01T00:00:00.000Z",
22 ...input,
23 };
24}
25
26function link(
27 number: number,
28 input: Partial<Omit<ThreadPullRequestLink, "number">> = {},
29): ThreadPullRequestLink {
30 return {
31 host: "github.com",
32 repository: "pingdotgg/t3code",
33 number,
34 url: `https://github.com/pingdotgg/t3code/pull/${number}`,
35 source: "manual",
36 linkedAt: `2026-01-01T00:00:${String(number).padStart(2, "0")}.000Z`,
37 snapshot: null,
38 stack: null,
39 ...input,
40 };
41}
42
43describe("threadPullRequestKeysEqual", () => {
44 it("ignores host and repository case", () => {
45 expect(
46 threadPullRequestKeysEqual(
47 { host: "GitHub.com", repository: "PingDotGG/t3code", number: 1 },
48 { host: "github.com", repository: "pingdotgg/t3code", number: 1 },
49 ),
50 ).toBe(true);
51 expect(
52 threadPullRequestKeysEqual(
53 { host: "github.com", repository: "pingdotgg/t3code", number: 1 },
54 { host: "gitlab.com", repository: "pingdotgg/t3code", number: 1 },
55 ),
56 ).toBe(false);
57 });
58});
59
60describe("resolveThreadCurrentPullRequest", () => {
61 it("returns null with no visible links", () => {
62 expect(resolveThreadCurrentPullRequest([])).toBeNull();
63 expect(resolveThreadCurrentPullRequest([link(1, { source: "stack-dismissed" })])).toBeNull();
64 });
65
66 it("treats an unsynced link as open", () => {
67 expect(resolveThreadCurrentPullRequest([link(1)])).toMatchObject({
68 kind: "single",
69 link: { number: 1 },
70 });
71 });
72
73 it("prefers the single open link over terminal ones", () => {
74 const current = resolveThreadCurrentPullRequest([
75 link(1, { snapshot: snapshot({ state: "merged" }) }),
76 link(2, { snapshot: snapshot({ state: "open" }) }),
77 link(3, { snapshot: snapshot({ state: "closed" }) }),
78 ]);
79 expect(current).toMatchObject({ kind: "single", link: { number: 2 } });
80 });
81
82 it("reports a stack when several links are open and puts the highest layer on top", () => {
83 const stack = {
84 kind: "native" as const,
85 id: "s1",
86 number: 1,
87 url: "https://github.com/pingdotgg/t3code/stacks/1",
88 base: "main",
89 layers: [
90 { number: 10, headBranch: "a", state: "open" as const },
91 { number: 11, headBranch: "b", state: "open" as const },
92 ],
93 };
94 const current = resolveThreadCurrentPullRequest([
95 link(11, { snapshot: snapshot(), stack }),
96 link(10, { snapshot: snapshot(), stack }),
97 ]);
98 expect(current).toMatchObject({ kind: "stack", top: { number: 11 } });
99 if (current?.kind === "stack") {
100 expect(current.open.map((entry) => entry.number)).toEqual([11, 10]);
101 }
102 });
103
104 it("orders an open set without stack data by most recent link", () => {
105 const current = resolveThreadCurrentPullRequest([link(1), link(2)]);
106 expect(current).toMatchObject({ kind: "stack", top: { number: 2 } });
107 });
108
109 it("falls back to the most recently updated terminal link", () => {
110 const current = resolveThreadCurrentPullRequest([
111 link(1, { snapshot: snapshot({ state: "merged", updatedAt: "2026-01-03T00:00:00.000Z" }) }),
112 link(2, { snapshot: snapshot({ state: "closed", updatedAt: "2026-01-02T00:00:00.000Z" }) }),
113 ]);
114 expect(current).toMatchObject({ kind: "single", link: { number: 1 } });
115 });
116});
117
118describe("legacyLinkedPullRequestOf", () => {
119 const identity = {
120 canonicalKey: "github.com/pingdotgg/t3code",
121 provider: "github",
122 displayName: "pingdotgg/t3code",
123 locator: {
124 source: "git-remote" as const,
125 remoteName: "origin",
126 remoteUrl: "https://github.com/pingdotgg/t3code.git",
127 },
128 };
129 it("projects only links the owning project can route without a host", () => {
130 expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, identity)).toEqual({
131 projectId: "project-1",
132 repository: "pingdotgg/t3code",
133 number: 7,
134 url: "https://github.com/pingdotgg/t3code/pull/7",
135 });
136 expect(legacyLinkedPullRequestOf([], "project-1" as never, identity)).toBeNull();
137 });
138 it.each([
139 {
140 host: "github.enterprise.test",
141 url: "https://github.enterprise.test/pingdotgg/t3code/pull/7",
142 },
143 { repository: "acme/other", url: "https://github.com/acme/other/pull/7" },
144 ])("omits an unsafe legacy route %j", (foreign) => {
145 expect(
146 legacyLinkedPullRequestOf([link(7, foreign)], "project-1" as never, identity),
147 ).toBeNull();
148 expect(
149 legacyLinkedPullRequestOf([link(7, foreign), link(8)], "project-1" as never, identity)
150 ?.number,
151 ).toBe(8);
152 });
153 it.each([
154 "dev.azure.com/org-a/project/_git/web",
155 "ssh.dev.azure.com/v3/org-a/project/web",
156 "org-a.visualstudio.com/DefaultCollection/project/_git/web",
157 ])("projects Azure links through the legacy selector for %s", (canonicalKey) => {
158 const azureIdentity = {
159 ...identity,
160 provider: "azure-devops",
161 canonicalKey,
162 displayName: canonicalKey.slice(canonicalKey.indexOf("/") + 1),
163 name: "web",
164 };
165 const own = link(7, {
166 host: "dev.azure.com",
167 repository: "org-a/project/_git/web",
168 url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7",
169 });
170 const foreign = link(7, {
171 host: "dev.azure.com",
172 repository: "web",
173 url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7",
174 linkedAt: "2026-01-02T00:00:00.000Z",
175 });
176 for (const repository of ["web", "org-a/project/_git/web"]) {
177 expect(
178 legacyLinkedPullRequestOf(
179 [{ ...own, repository }, foreign],
180 "project-1" as never,
181 azureIdentity,
182 ),
183 ).toEqual({ projectId: "project-1", repository: "web", number: 7, url: own.url });
184 }
185 expect(legacyLinkedPullRequestOf([foreign], "project-1" as never, azureIdentity)).toBeNull();
186 expect(legacyThreadPullRequestKey({ ...own, repository: "web" })).toEqual({
187 host: "dev.azure.com",
188 repository: "org-a/project/_git/web",
189 number: 7,
190 });
191 expect(
192 threadPullRequestKeysEqual(own, {
193 host: "org-a.visualstudio.com",
194 repository: "DefaultCollection/project/_git/web",
195 number: 7,
196 }),
197 ).toBe(true);
198 });
199 it("does not guess when the project identity is unavailable", () => {
200 expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, null)).toBeNull();
201 });
202});
203
204describe("resolveThreadPullRequestChains", () => {
205 it("chains links by base → head within a repository, bottom to top", () => {
206 const chains = resolveThreadPullRequestChains([
207 link(3, { snapshot: snapshot({ headBranch: "c", baseBranch: "b" }) }),
208 link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "main" }) }),
209 link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
210 link(9, { snapshot: snapshot({ headBranch: "solo", baseBranch: "main" }) }),
211 ]);
212 expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
213 ["derived", [1, 2, 3]],
214 ["derived", [9]],
215 ]);
216 });
217
218 it("uses the native stack order when the host provides one", () => {
219 const stack = {
220 kind: "native" as const,
221 id: "s1",
222 number: 1,
223 url: "https://github.com/pingdotgg/t3code/stacks/1",
224 base: "main",
225 layers: [
226 { number: 5, headBranch: "a", state: "merged" as const },
227 { number: 6, headBranch: "b", state: "open" as const },
228 ],
229 };
230 const chains = resolveThreadPullRequestChains([
231 link(6, { snapshot: snapshot({ headBranch: "b", baseBranch: "main" }), stack }),
232 link(5, {
233 snapshot: snapshot({ state: "merged", headBranch: "a", baseBranch: "main" }),
234 stack,
235 }),
236 link(8),
237 ]);
238 expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([
239 ["native", [5, 6]],
240 ["derived", [8]],
241 ]);
242 });
243});
244
245describe("chain selection and badge state", () => {
246 it.each(["open", "merged", "closed"] as const)(
247 "targets the top of a derived %s chain despite a later bottom update and link",
248 (state) => {
249 const bottom = link(2, {
250 snapshot: snapshot({ state, headBranch: "base", updatedAt: "2026-02-01T00:00:00.000Z" }),
251 });
252 const top = link(1, {
253 snapshot: snapshot({ state, headBranch: "top", baseBranch: "base" }),
254 });
255 expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject(
256 state === "open"
257 ? { kind: "stack", top: { number: 1 }, open: [top, bottom] }
258 : { kind: "single", link: { number: 1 } },
259 );
260 expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({
261 kind: "stack",
262 layers: 2,
263 state,
264 });
265 },
266 );
267
268 it("keeps the top native layer after completion and treats an unsynced layer as open", () => {
269 const stack = {
270 kind: "native" as const,
271 id: "native-1",
272 number: 1,
273 url: "https://github.com/pingdotgg/t3code/stacks/1",
274 base: "main",
275 layers: [
276 { number: 2, headBranch: "base", state: "merged" as const },
277 { number: 1, headBranch: "top", state: "merged" as const },
278 ],
279 };
280 const bottom = link(2, {
281 stack,
282 snapshot: snapshot({ state: "merged", updatedAt: "2026-02-01T00:00:00.000Z" }),
283 });
284 const top = link(1, { stack, snapshot: snapshot({ state: "merged" }) });
285 expect(resolveThreadCurrentPullRequest([bottom, top])).toMatchObject({
286 kind: "single",
287 link: { number: 1 },
288 });
289 expect(resolveThreadPullRequestBadge([bottom, { ...top, snapshot: null }])).toEqual({
290 kind: "stack",
291 layers: 2,
292 state: "open",
293 });
294 });
295
296 it("uses aggregate state and distinguishes unrelated work from a stack", () => {
297 const bottom = link(1, { snapshot: snapshot({ state: "merged", headBranch: "base" }) });
298 const top = link(2, { snapshot: snapshot({ state: "closed", baseBranch: "base" }) });
299 expect(resolveThreadPullRequestBadge([bottom, top])).toEqual({
300 kind: "stack",
301 layers: 2,
302 state: "closed",
303 });
304 expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({
305 kind: "pull-request",
306 others: 2,
307 });
308 expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull();
309 });
310
311 it("keeps branch matching case-sensitive while ignoring repository case", () => {
312 const bottom = link(1, {
313 repository: "PingDotGG/T3code",
314 snapshot: snapshot({ headBranch: "Base" }),
315 });
316 const top = link(2, { snapshot: snapshot({ headBranch: "top", baseBranch: "base" }) });
317 expect(resolveThreadPullRequestChains([bottom, top])).toHaveLength(2);
318 expect(
319 resolveThreadPullRequestChains([
320 bottom,
321 { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) },
322 ])[0]?.layers,
323 ).toEqual([bottom, { ...top, snapshot: snapshot({ headBranch: "top", baseBranch: "Base" }) }]);
324 });
325
326 it("preserves cyclic links without presenting a guessed stack order", () => {
327 const links = [
328 link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "b" }) }),
329 link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }),
330 ];
331 expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual(
332 links.map((entry) => [entry]),
333 );
334 expect(resolveThreadCurrentPullRequest(links)).toMatchObject({
335 kind: "stack",
336 top: { number: 2 },
337 });
338 expect(resolveThreadPullRequestBadge(links)).toEqual({ kind: "pull-request", others: 1 });
339 });
340
341 it("does not guess a parent when a head branch was reused", () => {
342 const links = [
343 link(1, { snapshot: snapshot({ state: "merged", headBranch: "reused" }) }),
344 link(2, { snapshot: snapshot({ headBranch: "reused" }) }),
345 link(3, { snapshot: snapshot({ headBranch: "top", baseBranch: "reused" }) }),
346 ];
347 expect(resolveThreadPullRequestChains(links).map((chain) => chain.layers)).toEqual(
348 links.map((entry) => [entry]),
349 );
350 });
351});
packages/shared/src/threadPullRequests.ts created+271
...@@ -0,0 +1,271 @@
1import type {
2 RepositoryIdentity,
3 SourceControlProviderKind,
4 ThreadLinkedPullRequest,
5 ThreadPullRequestKey,
6 ThreadPullRequestLink,
7} from "@t3tools/contracts";
8
9import { pullRequestHostOf } from "@t3tools/contracts";
10import { parseChangeRequestUrl } from "./changeRequestUrl.ts";
11import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts";
12
13/** Normalize stored link identity, including Azure's SSH and browser host aliases. */
14export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey {
15 const canonical = canonicalRepositoryKey(
16 `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
17 );
18 const separator = canonical.indexOf("/");
19 return {
20 host: canonical.slice(0, separator),
21 repository: canonical.slice(separator + 1),
22 number: key.number,
23 };
24}
25
26/** Legacy Azure selectors omit the organization and project; recover those from the PR URL. */
27export function legacyThreadPullRequestKey(
28 linked: Pick<ThreadLinkedPullRequest, "repository" | "number" | "url">,
29 fallbackHost?: string,
30): ThreadPullRequestKey {
31 const parsed = parseChangeRequestUrl(linked.url);
32 if (parsed !== null && parsed.number === linked.number) {
33 const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`);
34 if (canonical.startsWith("dev.azure.com/")) {
35 return normalizeThreadPullRequestKey(parsed);
36 }
37 }
38 let host = fallbackHost;
39 if (host === undefined) {
40 try {
41 host = new URL(linked.url).hostname;
42 } catch {
43 host = "unknown";
44 }
45 }
46 return {
47 host: host.trim().toLowerCase() || "unknown",
48 repository: linked.repository.trim().toLowerCase(),
49 number: linked.number,
50 };
51}
52
53/** Identity comparison for links: host-level, case-insensitive on host and repository. */
54export function threadPullRequestKeysEqual(
55 left: ThreadPullRequestKey,
56 right: ThreadPullRequestKey,
57): boolean {
58 return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right);
59}
60
61export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string {
62 const normalized = normalizeThreadPullRequestKey(key);
63 return `${normalized.host}/${normalized.repository}#${normalized.number}`;
64}
65
66/** Links a user should see. Tombstoned stack members stay in the array only so the
67 * sync reactor does not re-add them. */
68export function visibleThreadPullRequests(
69 links: ReadonlyArray<ThreadPullRequestLink>,
70): ReadonlyArray<ThreadPullRequestLink> {
71 return links.filter((link) => link.source !== "stack-dismissed");
72}
73
74function isOpen(link: ThreadPullRequestLink): boolean {
75 // Unsynced links are treated as open: they were just linked, and hiding them
76 // behind a terminal PR until the first sync would make the link look lost.
77 return link.snapshot === null || link.snapshot.state === "open";
78}
79
80function latestUpdatedAt(link: ThreadPullRequestLink): number {
81 const value = link.snapshot?.updatedAt ?? link.linkedAt;
82 const ms = Date.parse(value);
83 return Number.isNaN(ms) ? 0 : ms;
84}
85
86/** The single pull request a one-slot surface (sidebar badge, tab icon, copy link) shows. */
87export type ThreadCurrentPullRequest =
88 | { readonly kind: "single"; readonly link: ThreadPullRequestLink }
89 | {
90 readonly kind: "stack";
91 readonly open: ReadonlyArray<ThreadPullRequestLink>;
92 /** Highest layer of the open set; the one "View PR" and copy-link target. */
93 readonly top: ThreadPullRequestLink;
94 };
95
96/**
97 * Prefer open work and the highest open layer within a chain. A completed chain still
98 * points at its top; unrelated terminal links use the most recently updated request.
99 */
100export function resolveThreadCurrentPullRequest(
101 links: ReadonlyArray<ThreadPullRequestLink>,
102): ThreadCurrentPullRequest | null {
103 const visible = visibleThreadPullRequests(links);
104 if (visible.length === 0) return null;
105 const open = visible.filter(isOpen);
106 if (open.length === 1) return { kind: "single", link: open[0]! };
107 const chains = resolveThreadPullRequestChains(visible);
108 if (open.length > 1) {
109 const openChains = chains
110 .map((chain) => chain.layers.toReversed().filter(isOpen))
111 .filter((layers) => layers.length > 0)
112 .sort(
113 (left, right) =>
114 Math.max(...right.map((link) => Date.parse(link.linkedAt))) -
115 Math.max(...left.map((link) => Date.parse(link.linkedAt))),
116 );
117 const ordered = openChains.flat();
118 return { kind: "stack", open: ordered, top: ordered[0]! };
119 }
120 if (chains.length === 1) {
121 return { kind: "single", link: chains[0]!.layers.at(-1)! };
122 }
123 const terminal = [...visible].sort(
124 (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left),
125 );
126 return { kind: "single", link: terminal[0]! };
127}
128
129/** The one link a legacy `linkedPullRequest` consumer should see, or null. */
130export function resolveThreadCurrentPullRequestLink(
131 links: ReadonlyArray<ThreadPullRequestLink>,
132): ThreadPullRequestLink | null {
133 const current = resolveThreadCurrentPullRequest(links);
134 if (current === null) return null;
135 return current.kind === "single" ? current.link : current.top;
136}
137
138/** Legacy clients can only route links belonging to the thread's own repository. */
139export function legacyLinkedPullRequestOf(
140 links: ReadonlyArray<ThreadPullRequestLink>,
141 projectId: ThreadLinkedPullRequest["projectId"],
142 identity: RepositoryIdentity | null | undefined,
143): ThreadLinkedPullRequest | null {
144 if (!identity) return null;
145 const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind);
146 const repository = sourceControlRepositorySelector(identity);
147 if (repository === null) return null;
148 const azureKey =
149 identity.provider === "azure-devops"
150 ? canonicalRepositoryKey(identity.canonicalKey.toLowerCase())
151 : null;
152 const link = resolveThreadCurrentPullRequestLink(
153 links.filter((link) => {
154 if (azureKey !== null) {
155 const key = legacyThreadPullRequestKey(link, link.host);
156 return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey;
157 }
158 return (
159 link.host.toLowerCase() === host.toLowerCase() &&
160 link.repository.toLowerCase() === repository.toLowerCase()
161 );
162 }),
163 );
164 if (link === null) return null;
165 return {
166 projectId,
167 repository: azureKey === null ? link.repository : repository,
168 number: link.number,
169 url: link.url,
170 };
171}
172
173export interface ThreadPullRequestChain {
174 readonly kind: "native" | "derived";
175 /** Bottom to top. */
176 readonly layers: ReadonlyArray<ThreadPullRequestLink>;
177}
178
179/**
180 * Groups a thread's links into stacks. Native stacks come from the host and win; the rest
181 * are chained by matching one link's base branch to another's head branch within the same
182 * repository. A link that chains to nothing is a one-layer chain.
183 */
184export function resolveThreadPullRequestChains(
185 links: ReadonlyArray<ThreadPullRequestLink>,
186): ReadonlyArray<ThreadPullRequestChain> {
187 const visible = visibleThreadPullRequests(links);
188 const chains: Array<ThreadPullRequestChain> = [];
189 const placed = new Set<string>();
190
191 const nativeStacks = new Map<string, Array<ThreadPullRequestLink>>();
192 for (const link of visible) {
193 if (link.stack === null) continue;
194 const stackKey = `${link.host.toLowerCase()}/${link.repository.toLowerCase()}#stack:${link.stack.id}`;
195 const members = nativeStacks.get(stackKey) ?? [];
196 members.push(link);
197 nativeStacks.set(stackKey, members);
198 }
199 for (const members of nativeStacks.values()) {
200 const order = new Map(members[0]!.stack!.layers.map((layer, index) => [layer.number, index]));
201 members.sort((left, right) => (order.get(left.number) ?? 0) - (order.get(right.number) ?? 0));
202 for (const member of members) placed.add(threadPullRequestKeyOf(member));
203 chains.push({ kind: "native", layers: members });
204 }
205
206 const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link)));
207 const branchKey = (link: ThreadPullRequestLink, branch: string) =>
208 `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`;
209 // Reused head names cannot identify a parent unambiguously.
210 const byHead = new Map<string, ThreadPullRequestLink | null>();
211 for (const link of remaining) {
212 if (link.snapshot === null) continue;
213 const key = branchKey(link, link.snapshot.headBranch);
214 byHead.set(key, byHead.has(key) ? null : link);
215 }
216 const hasChild = new Set<string>();
217 for (const link of remaining) {
218 if (link.snapshot === null) continue;
219 const parent = byHead.get(branchKey(link, link.snapshot.baseBranch));
220 if (parent != null && parent !== link) hasChild.add(threadPullRequestKeyOf(parent));
221 }
222 // Walk from each top (a link nothing builds on) down its base chain.
223 for (const top of remaining) {
224 if (hasChild.has(threadPullRequestKeyOf(top))) continue;
225 const layers: Array<ThreadPullRequestLink> = [];
226 let cursor: ThreadPullRequestLink | undefined = top;
227 while (cursor !== undefined && !placed.has(threadPullRequestKeyOf(cursor))) {
228 placed.add(threadPullRequestKeyOf(cursor));
229 layers.unshift(cursor);
230 cursor =
231 cursor.snapshot === null
232 ? undefined
233 : (byHead.get(branchKey(cursor, cursor.snapshot.baseBranch)) ?? undefined);
234 }
235 if (layers.length > 0) chains.push({ kind: "derived", layers });
236 }
237 // Cycles have no top. Keep those links visible without inventing a stack order.
238 for (const link of remaining) {
239 if (!placed.has(threadPullRequestKeyOf(link))) {
240 chains.push({ kind: "derived", layers: [link] });
241 }
242 }
243 return chains;
244}
245
246export type ThreadPullRequestBadge =
247 | {
248 readonly kind: "stack";
249 readonly layers: number;
250 readonly state: "open" | "closed" | "merged";
251 }
252 | { readonly kind: "pull-request"; readonly others: number };
253
254/** Aggregate a single chain's state; unrelated links show a count beside the current PR. */
255export function resolveThreadPullRequestBadge(
256 pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,
257): ThreadPullRequestBadge | null {
258 const visible = visibleThreadPullRequests(pullRequests ?? []);
259 if (visible.length === 0) return null;
260 const chains = resolveThreadPullRequestChains(visible);
261 if (visible.length > 1 && chains.length === 1) {
262 const states = visible.map((link) => link.snapshot?.state ?? "open");
263 const state = states.includes("open")
264 ? "open"
265 : states.every((entry) => entry === "merged")
266 ? "merged"
267 : "closed";
268 return { kind: "stack", layers: visible.length, state };
269 }
270 return { kind: "pull-request", others: visible.length - 1 };
271}
packages/shared/src/threadReference.test.ts+66
...@@ -1,13 +1,26 @@...@@ -1,13 +1,26 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
23
3import { resolveThreadReferenceCopyTarget } from "./threadReference.ts";4import { resolveThreadReferenceCopyTarget } from "./threadReference.ts";
45
6const crossRepositoryPullRequest: ThreadPullRequestLink = {
7 host: "github.com",
8 repository: "other/repo",
9 number: 42,
10 url: "https://github.com/other/repo/pull/42",
11 source: "manual",
12 linkedAt: "2026-01-01T00:00:00.000Z",
13 snapshot: null,
14 stack: null,
15};
16
5describe("resolveThreadReferenceCopyTarget", () => {17describe("resolveThreadReferenceCopyTarget", () => {
6 it("does not copy another reference while the open panel URL is unavailable", () => {18 it("does not copy another reference while the open panel URL is unavailable", () => {
7 expect(19 expect(
8 resolveThreadReferenceCopyTarget({20 resolveThreadReferenceCopyTarget({
9 threadId: "thread-1",21 threadId: "thread-1",
10 openPanelPullRequestUrl: null,22 openPanelPullRequestUrl: null,
23 pullRequests: [crossRepositoryPullRequest],
11 linkedPullRequestUrl: "https://github.com/t3/pr/12",24 linkedPullRequestUrl: "https://github.com/t3/pr/12",
12 }),25 }),
13 ).toBeNull();26 ).toBeNull();
...@@ -18,6 +31,7 @@ describe("resolveThreadReferenceCopyTarget", () => {...@@ -18,6 +31,7 @@ describe("resolveThreadReferenceCopyTarget", () => {
18 resolveThreadReferenceCopyTarget({31 resolveThreadReferenceCopyTarget({
19 threadId: "thread-1",32 threadId: "thread-1",
20 openPanelPullRequestUrl: "https://github.com/t3/pr/14",33 openPanelPullRequestUrl: "https://github.com/t3/pr/14",
34 pullRequests: [crossRepositoryPullRequest],
21 linkedPullRequestUrl: "https://github.com/t3/pr/12",35 linkedPullRequestUrl: "https://github.com/t3/pr/12",
22 }),36 }),
23 ).toMatchObject({37 ).toMatchObject({
...@@ -27,6 +41,58 @@ describe("resolveThreadReferenceCopyTarget", () => {...@@ -27,6 +41,58 @@ describe("resolveThreadReferenceCopyTarget", () => {
27 });41 });
28 });42 });
2943
44 it.each([null, "https://github.com/t3/pr/12"])(
45 "copies a native cross-repository link before the fallback URL %s",
46 (linkedPullRequestUrl) => {
47 expect(
48 resolveThreadReferenceCopyTarget({
49 threadId: "thread-1",
50 pullRequests: [crossRepositoryPullRequest],
51 linkedPullRequestUrl,
52 }),
53 ).toMatchObject({
54 kind: "pull-request",
55 value: crossRepositoryPullRequest.url,
56 });
57 },
58 );
59
60 it("copies the highest open stack layer instead of the first link", () => {
61 const stack: NonNullable<ThreadPullRequestLink["stack"]> = {
62 kind: "native",
63 id: "stack-1",
64 number: 1,
65 url: "https://github.com/other/repo/stacks/1",
66 base: "main",
67 layers: [
68 { number: 42, headBranch: "first", state: "open" },
69 { number: 43, headBranch: "second", state: "open" },
70 ],
71 };
72 const top = {
73 ...crossRepositoryPullRequest,
74 number: 43,
75 url: "https://github.com/other/repo/pull/43",
76 stack,
77 };
78 expect(
79 resolveThreadReferenceCopyTarget({
80 threadId: "thread-1",
81 pullRequests: [{ ...crossRepositoryPullRequest, stack }, top],
82 }),
83 ).toMatchObject({ kind: "pull-request", value: top.url });
84 });
85
86 it("uses the fallback URL when native links are dismissed", () => {
87 expect(
88 resolveThreadReferenceCopyTarget({
89 threadId: "thread-1",
90 pullRequests: [{ ...crossRepositoryPullRequest, source: "stack-dismissed" }],
91 linkedPullRequestUrl: "https://github.com/t3/pr/12",
92 }),
93 ).toMatchObject({ kind: "pull-request", value: "https://github.com/t3/pr/12" });
94 });
95
30 it("uses the thread pull request when no panel is open", () => {96 it("uses the thread pull request when no panel is open", () => {
31 expect(97 expect(
32 resolveThreadReferenceCopyTarget({98 resolveThreadReferenceCopyTarget({
packages/shared/src/threadReference.ts+9-1
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
2
3import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts";
4
1export interface ThreadReferenceCopyTarget {5export interface ThreadReferenceCopyTarget {
2 readonly kind: "pull-request" | "thread";6 readonly kind: "pull-request" | "thread";
3 readonly value: string;7 readonly value: string;
...@@ -10,10 +14,14 @@ export function resolveThreadReferenceCopyTarget(input: {...@@ -10,10 +14,14 @@ export function resolveThreadReferenceCopyTarget(input: {
10 readonly threadId: string;14 readonly threadId: string;
11 /** Undefined means no PR panel; null means its URL is not available yet. */15 /** Undefined means no PR panel; null means its URL is not available yet. */
12 readonly openPanelPullRequestUrl?: string | null | undefined;16 readonly openPanelPullRequestUrl?: string | null | undefined;
17 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
13 readonly linkedPullRequestUrl?: string | null;18 readonly linkedPullRequestUrl?: string | null;
14}): ThreadReferenceCopyTarget | null {19}): ThreadReferenceCopyTarget | null {
15 if (input.openPanelPullRequestUrl === null) return null;20 if (input.openPanelPullRequestUrl === null) return null;
16 const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl;21 const pullRequestUrl =
22 input.openPanelPullRequestUrl ??
23 resolveThreadCurrentPullRequestLink(input.pullRequests ?? [])?.url ??
24 input.linkedPullRequestUrl;
17 return pullRequestUrl25 return pullRequestUrl
18 ? {26 ? {
19 kind: "pull-request",27 kind: "pull-request",