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";
7878import IconServer from "@tabler/icons-react-native/IconServer";
7979import IconSettings from "@tabler/icons-react-native/IconSettings";
8080import IconSparkles from "@tabler/icons-react-native/IconSparkles";
81import IconStack2 from "@tabler/icons-react-native/IconStack2";
8182import IconSun from "@tabler/icons-react-native/IconSun";
8283import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
8384import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
......@@ -100,6 +101,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
100101 "arrow.right.circle": IconArrowRightCircle,
101102 "arrow.triangle.branch": IconGitBranch,
102103 "arrow.triangle.pull": IconGitPullRequest,
104 "square.3.layers.3d": IconStack2,
103105 "arrow.turn.left.up": IconArrowBackUp,
104106 "arrow.up": IconArrowUp,
105107 "arrow.up.circle": IconArrowUpCircle,
apps/mobile/src/features/archive/archivedThreadList.test.ts+1
......@@ -31,6 +31,7 @@ function makeThread(
3131 interactionMode: "default",
3232 branch: null,
3333 worktreePath: null,
34 pullRequests: [],
3435 latestTurn: null,
3536 createdAt: "2026-06-01T00:00:00.000Z",
3637 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 {
4343 interactionMode: "default",
4444 branch: null,
4545 worktreePath: null,
46 pullRequests: [],
4647 latestTurn: null,
4748 createdAt: "2026-06-01T00:00:00.000Z",
4849 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/home/homeThreadList.test.ts+1
......@@ -36,6 +36,7 @@ function makeThread(
3636 interactionMode: "default",
3737 branch: null,
3838 worktreePath: null,
39 pullRequests: [],
3940 latestTurn: null,
4041 createdAt: "2026-06-01T00:00:00.000Z",
4142 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+1
......@@ -44,6 +44,7 @@ export function HardwareKeyboardCommandProvider({
4444 ? null
4545 : resolveThreadReferenceCopyTarget({
4646 threadId: activeThread?.id ?? activeThreadRef.threadId,
47 pullRequests: activeThread?.pullRequests,
4748 linkedPullRequestUrl:
4849 (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null,
4950 }),
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+58-1
......@@ -4,6 +4,10 @@ import {
44 getGitActionDisabledReason,
55 requiresDefaultBranchConfirmation,
66} from "@t3tools/client-runtime/state/vcs";
7import {
8 resolveThreadPullRequestChains,
9 threadPullRequestKeyOf,
10} from "@t3tools/shared/threadPullRequests";
711import { EnvironmentId, ThreadId } from "@t3tools/contracts";
812import {
913 CommonActions,
......@@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
4953 const isInspector = presentation === "inspector";
5054 const environmentId = EnvironmentId.make(props.route.params.environmentId);
5155 const threadId = ThreadId.make(props.route.params.threadId);
52 const { selectedThread } = useThreadSelection();
56 const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection();
5357 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 );
5467 const gitState = useSelectedThreadGitState();
5568 const gitActions = useSelectedThreadGitActions();
5669 const theme = useUniwindTheme();
......@@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
285298 />
286299 </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
288345 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
289346 </ScrollView>
290347 );
apps/mobile/src/features/threads/thread-list-items.tsx+25-9
......@@ -56,6 +56,7 @@ function pullRequestTintColor(
5656 return dark ? "#34d399" : "#059669";
5757 case "merged":
5858 return dark ? "#a78bfa" : "#7c3aed";
59 case null:
5960 case "closed":
6061 return dark ? "#a1a1aa" : "#71717a";
6162 }
......@@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
618619 </>
619620 ) : null}
620621 {pr !== null ? (
621 <View className="flex-row items-center gap-0.5">
622 <PullRequestIcon
623 size={compact ? 13 : 11}
624 color={
625 visuallySelected
626 ? String(effectiveSelectedForeground)
627 : pullRequestTintColor(pr, colorScheme)
628 }
629 />
622 <View
623 className="flex-row items-center gap-0.5"
624 accessibilityLabel={pr.accessibilityLabel}
625 >
626 {pr.kind === "stack" ? (
627 <SymbolView
628 name="square.3.layers.3d"
629 size={compact ? 13 : 11}
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 )}
630646 <Text
631647 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
632648 visuallySelected
apps/mobile/src/features/threads/thread-list-v2-items.tsx+29-14
......@@ -870,20 +870,35 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
870870 <View className="flex-1" />
871871 )}
872872 {pr ? (
873 <Text
874 accessibilityLabel={pr.accessibilityLabel}
875 className={cn(
876 "text-xs",
877 selected
878 ? materialYouStyleLayoutActive
879 ? "text-thread-selected-foreground"
880 : "text-user-bubble-foreground"
881 : pr.textClassName,
882 )}
883 style={{ fontFamily: MONO_FONT }}
884 >
885 #{pr.label}
886 </Text>
873 <View className="flex-row items-center gap-1" accessibilityLabel={pr.accessibilityLabel}>
874 {pr.kind === "stack" ? (
875 <SymbolView
876 name="square.3.layers.3d"
877 size={12}
878 tintColorClassName={
879 selected
880 ? materialYouStyleLayoutActive
881 ? "accent-thread-selected-foreground"
882 : "accent-user-bubble-foreground"
883 : "accent-foreground-muted"
884 }
885 />
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>
887902 ) : null}
888903 {props.providerInstance ? (
889904 <ProviderInstanceIcon
apps/mobile/src/features/threads/threadListV2.test.ts+1
......@@ -47,6 +47,7 @@ function makeThread(
4747 interactionMode: "default",
4848 branch: null,
4949 worktreePath: null,
50 pullRequests: [],
5051 latestTurn: null,
5152 createdAt: "2026-06-01T00:00:00.000Z",
5253 updatedAt: "2026-06-01T00:00:00.000Z",
apps/mobile/src/lib/threadActivity.test.ts+1
......@@ -258,6 +258,7 @@ function makeThread(
258258 interactionMode: "default",
259259 branch: null,
260260 worktreePath: null,
261 pullRequests: [],
261262 latestTurn: null,
262263 createdAt: "2026-04-01T00:00:00.000Z",
263264 updatedAt: "2026-04-01T00:00:00.000Z",
apps/mobile/src/state/pending-thread-creation.ts+1
......@@ -144,6 +144,7 @@ export function pendingThreadCreationShell(
144144 runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE,
145145 interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
146146 branch: creation.branch,
147 pullRequests: [],
147148 worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath,
148149 linkedPullRequest: null,
149150 latestTurn: null,
apps/mobile/src/state/thread-pr-presentation.ts+64-2
......@@ -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";
27import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";
38
9import {
10 resolveThreadCurrentPullRequestLink,
11 resolveThreadPullRequestBadge,
12} from "@t3tools/shared/threadPullRequests";
13
414export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
515
616export interface ThreadPrPresentation {
717 readonly number: number;
8 readonly state: ThreadPr["state"];
18 readonly state: ThreadPr["state"] | null;
19 readonly kind: "pull-request" | "stack";
920 readonly isDraft: boolean;
1021 /** Provider-side last activity, bounding when a terminal state landed. */
1122 readonly updatedAt: string | null;
......@@ -30,6 +41,7 @@ export function presentThreadPr(
3041 const presentation = resolveChangeRequestPresentation(provider);
3142 const isDraft = pr.state === "open" && pr.isDraft === true;
3243 return {
44 kind: "pull-request",
3345 number: pr.number,
3446 state: pr.state,
3547 isDraft,
......@@ -40,3 +52,53 @@ export function presentThreadPr(
4052 textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],
4153 };
4254}
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() {
330330 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
331331 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
332332 ...(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,
333335 });
334336 if (AsyncResult.isFailure(result)) {
335337 return result;
apps/mobile/src/state/use-thread-pr.test.ts+145-2
......@@ -1,7 +1,11 @@
1import type { VcsStatusResult } from "@t3tools/contracts";
1import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts";
22import { 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
610const pullRequest: NonNullable<VcsStatusResult["pr"]> = {
711 number: 3774,
......@@ -43,3 +47,142 @@ describe("presentThreadPr", () => {
4347 });
4448 });
4549});
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";
1010
1111import { connectionAtomRuntime } from "../connection/runtime";
1212import { appAtomRegistry } from "./atom-registry";
13import { serverEnvironment } from "./server";
1314import { 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
1621const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
1722const MAX_THREAD_PR_SNAPSHOTS = 500;
......@@ -35,11 +40,26 @@ export {
3540} from "./thread-pr-presentation";
3641
3742/**
38 * Live status for a thread's server-provided PR. Visible rows share a summary
39 * request for the same PR in the same environment.
43 * Linked PRs use server snapshots. Branch fallback and legacy references share
44 * a live summary request across visible rows in the same environment.
4045 */
4146export 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 );
4363 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
4464 const snapshotIdentity = JSON.stringify(pullRequestRef);
4565 // 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
101121 });
102122 }, [live, snapshotIdentity, threadKey]);
103123
104 return live === undefined ? snapshot : live;
124 return linkedPresentation ?? (live === undefined ? snapshot : live);
105125}
apps/mobile/src/state/use-thread-selection.ts+1
......@@ -64,6 +64,7 @@ function threadDetailToShell(
6464 branch: thread.branch,
6565 worktreePath: thread.worktreePath,
6666 linkedPullRequest: thread.linkedPullRequest ?? null,
67 pullRequests: thread.pullRequests,
6768 branchPullRequest: thread.branchPullRequest ?? null,
6869 latestTurn: thread.latestTurn,
6970 createdAt: thread.createdAt,
apps/server/integration/OrchestrationEngineHarness.integration.ts+8
......@@ -67,6 +67,7 @@ import {
6767} from "../src/orchestration/Services/OrchestrationEngine.ts";
6868import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts";
6969import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts";
70import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts";
7071import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts";
7172import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
7273import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
......@@ -404,6 +405,13 @@ export const makeOrchestrationIntegrationHarness = (
404405 drain: Effect.void,
405406 }),
406407 ),
408 Layer.provideMerge(
409 Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, {
410 start: () => Effect.void,
411 drain: Effect.void,
412 requestSync: () => Effect.void,
413 }),
414 ),
407415 Layer.provideMerge(
408416 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
409417 publishThread: () => Effect.void,
apps/server/src/auth/RpcAuthorization.ts+2
......@@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = {
6868 [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
6969 [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
7070 [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope,
71 [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
72 [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
7173 [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
7274 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
7375 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
169169 expect(second.capabilities.usagePriceOverrides).toBe(true);
170170 expect(second.capabilities.threadActiveReorder).toBe(true);
171171 expect(second.capabilities.threadTitleRegeneration).toBe(true);
172 expect(second.capabilities.threadPullRequests).toBe(true);
172173 expect(second.capabilities.threadPullRequestLinking).toBe(true);
173174 expect(second.capabilities.agentActivityPublishing).toBe(false);
174175 }),
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -230,6 +230,7 @@ export const make = Effect.gen(function* () {
230230 threadPinReorder: true,
231231 threadActiveReorder: true,
232232 threadTitleRegeneration: true,
233 threadPullRequests: true,
233234 threadPullRequestLinking: true,
234235 environmentIcon: true,
235236 ...(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";
66import * as Effect from "effect/Effect";
77import * as FileSystem from "effect/FileSystem";
88import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
910import * as Path from "effect/Path";
1011import * as Schema from "effect/Schema";
1112import * as Stream from "effect/Stream";
1213import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";
1314import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";
1415
16import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
17import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
1518import * as ServerConfig from "../config.ts";
1619import * as McpHttpServer from "./McpHttpServer.ts";
1720import * as McpInvocationContext from "./McpInvocationContext.ts";
......@@ -48,6 +51,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
4851 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })),
4952 Layer.provideMerge(NodeServices.layer),
5053);
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
5267const snapshotResult = {
5368 url: "http://example.test/",
......@@ -345,6 +360,38 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () =>
345360 ).pipe(Effect.provide(TestLayer)),
346361);
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
348395it.effect("keeps the snapshot text under the agent's output ceiling", () =>
349396 Effect.scoped(
350397 Effect.gen(function* () {
apps/server/src/mcp/McpHttpServer.ts+10-1
......@@ -28,6 +28,8 @@ import {
2828 PreviewSnapshotToolkit,
2929 PreviewStandardToolkit,
3030} from "./toolkits/preview/tools.ts";
31import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
32import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
3133
3234const unauthorized = HttpServerResponse.jsonUnsafe(
3335 {
......@@ -437,6 +439,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll(
437439 PreviewSnapshotRegistrationLive,
438440);
439441
442export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe(
443 Layer.provide(PullRequestsToolkitHandlersLive),
444);
445
440446const McpTransportLive = McpServer.layerHttp({
441447 name: "T3 Code",
442448 version: packageJson.version,
......@@ -444,4 +450,7 @@ const McpTransportLive = McpServer.layerHttp({
444450 protocols: [McpProtocol.v2025_06_18],
445451}).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 @@
11import { expect, it } from "@effect/vitest";
22import {
33 EnvironmentId,
4 McpCapabilityUnavailableError,
45 PreviewAutomationUnavailableError,
56 ProviderInstanceId,
67 ThreadId,
......@@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav
3637 expect(error.message).toBe("MCP credential does not grant the preview capability.");
3738 });
3839});
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 @@
11import {
22 type EnvironmentId,
3 McpCapabilityUnavailableError,
34 PreviewAutomationUnavailableError,
45 type ProviderInstanceId,
56 type ThreadId,
......@@ -7,7 +8,7 @@ import {
78import * as Context from "effect/Context";
89import * as Effect from "effect/Effect";
910
10export type McpCapability = "preview";
11export type McpCapability = "preview" | "pull-requests";
1112
1213export interface McpInvocationScope {
1314 readonly environmentId: EnvironmentId;
......@@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service<
2324 McpInvocationScope
2425>()("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,
2734 capability: McpCapability,
28) {
29 const invocation = yield* McpInvocationContext;
30 if (!invocation.capabilities.has(capability)) {
31 return yield* new PreviewAutomationUnavailableError({
32 capability,
33 environmentId: invocation.environmentId,
34 threadId: invocation.threadId,
35 providerSessionId: invocation.providerSessionId,
36 providerInstanceId: invocation.providerInstanceId,
37 });
38 }
39 return invocation;
40});
35): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {
36 const fields = {
37 environmentId: invocation.environmentId,
38 threadId: invocation.threadId,
39 providerSessionId: invocation.providerSessionId,
40 providerInstanceId: invocation.providerInstanceId,
41 };
42 return capability === "preview"
43 ? new PreviewAutomationUnavailableError({ capability, ...fields })
44 : new McpCapabilityUnavailableError({ capability, ...fields });
45};
46
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 {
77 readonly providerInstanceId: ProviderInstanceId;
88 readonly endpoint: string;
99 readonly authorizationHeader: string;
10 /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */
11 readonly preview: boolean;
1012}
1113
1214const 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
3939 const issued = yield* registry.issue({
4040 threadId,
4141 providerInstanceId: ProviderInstanceId.make("codex"),
42 preview: true,
4243 });
4344 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");
4445 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
5455 }),
5556);
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
5781it.effect("builds MCP endpoints from the bound server host", () =>
5882 Effect.gen(function* () {
5983 const cases = [
......@@ -68,6 +92,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>
6892 const issued = yield* registry.issue({
6993 threadId: ThreadId.make(`thread-${hostname}`),
7094 providerInstanceId: ProviderInstanceId.make("codex"),
95 preview: true,
7196 });
7297 expect(issued.config.endpoint).toBe(expectedEndpoint);
7398 }
......@@ -81,6 +106,7 @@ it.effect("expires credentials once their session stops showing signs of life",
81106 const issued = yield* registry.issue({
82107 threadId: ThreadId.make("thread-2"),
83108 providerInstanceId: ProviderInstanceId.make("claude"),
109 preview: true,
84110 });
85111 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
86112 timestamp += 101;
......@@ -96,6 +122,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",
96122 const issued = yield* registry.issue({
97123 threadId,
98124 providerInstanceId: ProviderInstanceId.make("claude"),
125 preview: true,
99126 });
100127 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
101128
......@@ -117,6 +144,7 @@ it.effect("does not keep credentials of other threads alive", () =>
117144 const issued = yield* registry.issue({
118145 threadId: ThreadId.make("thread-4"),
119146 providerInstanceId: ProviderInstanceId.make("codex"),
147 preview: true,
120148 });
121149 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";
1414export interface McpCredentialRequest {
1515 readonly threadId: ThreadId;
1616 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;
1722}
1823
1924export interface McpIssuedCredential {
......@@ -68,7 +73,7 @@ export interface McpSessionRegistryOptions {
6873 *
6974 * The bound matters because `/mcp` is mounted outside the environment auth
7075 * 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.
7277 */
7378const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000;
7479
......@@ -128,7 +133,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
128133 threadId: ThreadId.make(request.threadId),
129134 providerSessionId,
130135 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 ),
132139 issuedAt,
133140 };
134141 yield* SynchronizedRef.update(state, ({ records }) => {
......@@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
144151 providerInstanceId: scope.providerInstanceId,
145152 endpoint,
146153 authorizationHeader: `Bearer ${rawToken}`,
154 preview: request.preview,
147155 },
148156 };
149157 },
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);
6060const asTurnId = (value: string): TurnId => TurnId.make(value);
6161const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
6262
63function makeOrchestrationLayer(databasePath?: string) {
63function makeOrchestrationLayer(
64 databasePath?: string,
65 repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"],
66) {
6467 const persistence = databasePath
6568 ? makeSqlitePersistenceLive(databasePath)
6669 : SqlitePersistenceMemory;
......@@ -78,15 +81,27 @@ function makeOrchestrationLayer(databasePath?: string) {
7881 Layer.provide(ThreadPlanProgress.layer),
7982 Layer.provide(OrchestrationEventStoreLive),
8083 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 ),
8292 Layer.provide(persistence),
8393 Layer.provideMerge(ServerConfigLayer),
8494 Layer.provideMerge(NodeServices.layer),
8595 );
8696}
8797
88async function createOrchestrationSystem(databasePath?: string) {
89 const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath));
98async function createOrchestrationSystem(
99 databasePath?: string,
100 repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"],
101) {
102 const runtime = ManagedRuntime.make(
103 makeOrchestrationLayer(databasePath, repositoryIdentityResolver),
104 );
90105 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
91106 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
92107 return {
......@@ -373,6 +388,7 @@ describe("OrchestrationEngine", () => {
373388 runtimeMode: "full-access" as const,
374389 branch: null,
375390 worktreePath: null,
391 pullRequests: [],
376392 latestTurn: null,
377393 createdAt: "2026-03-03T00:00:02.000Z",
378394 updatedAt: "2026-03-03T00:00:03.000Z",
......@@ -1009,7 +1025,20 @@ describe("OrchestrationEngine", () => {
10091025 it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)(
10101026 "rejects PR discovery completed after a newer %s command",
10111027 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 });
10131042 try {
10141043 const projectId = ProjectId.make("pr-race-project");
10151044 const threadId = ThreadId.make("pr-race-thread");
......@@ -1058,6 +1087,7 @@ describe("OrchestrationEngine", () => {
10581087 linkedPullRequest: previous,
10591088 }),
10601089 );
1090 expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous);
10611091 const metadataChanges = {
10621092 unlink: { linkedPullRequest: null },
10631093 relink: {
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+12
......@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
1314import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1415import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1516import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
......@@ -84,6 +85,16 @@ describe("OrchestrationReactor", () => {
8485 drain: Effect.void,
8586 }),
8687 ),
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 ),
8798 Layer.provideMerge(
8899 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
89100 publishThread: () => Effect.void,
......@@ -107,6 +118,7 @@ describe("OrchestrationReactor", () => {
107118 "thread-deletion-reactor",
108119 "thread-pull-request-reactor",
109120 "thread-settlement-reactor",
121 "pull-request-sync-reactor",
110122 "agent-awareness-relay",
111123 ]);
112124
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
......@@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
1314import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1415import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1516
......@@ -19,6 +20,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
1920 const checkpointReactor = yield* CheckpointReactor;
2021 const threadDeletionReactor = yield* ThreadDeletionReactor;
2122 const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
23 const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
2224 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
2325 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
2426
......@@ -29,6 +31,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
2931 yield* threadDeletionReactor.start();
3032 yield* threadPullRequestReactor.start();
3133 yield* threadSettlementReactor.start();
34 yield* pullRequestSyncReactor.start();
3235 yield* agentAwarenessRelay.start();
3336 });
3437
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+237
......@@ -8,6 +8,7 @@ import {
88 MessageId,
99 ProjectId,
1010 ThreadId,
11 type ThreadPullRequestSnapshot,
1112 ThreadLinkedPullRequest,
1213 TurnId,
1314 ProviderInstanceId,
......@@ -686,6 +687,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))(
686687 },
687688);
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
689926it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))(
690927 "OrchestrationProjectionPipeline",
691928 (it) => {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+104
......@@ -16,6 +16,10 @@ import * as Path from "effect/Path";
1616import * as Schema from "effect/Schema";
1717import * as Stream from "effect/Stream";
1818import * as SqlClient from "effect/unstable/sql/SqlClient";
19import {
20 legacyThreadPullRequestKey,
21 threadPullRequestKeysEqual,
22} from "@t3tools/shared/threadPullRequests";
1923
2024import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
2125import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
......@@ -32,6 +36,7 @@ import {
3236 type ProjectionThreadProposedPlan,
3337 ProjectionThreadProposedPlanRepository,
3438} from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
39import * as ProjectionThreadPullRequests from "../../persistence/ProjectionThreadPullRequests.ts";
3540import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
3641import {
3742 type ProjectionTurn,
......@@ -480,6 +485,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
480485 const projectionThreadRepository = yield* ProjectionThreadRepository;
481486 const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository;
482487 const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository;
488 const projectionThreadPullRequestRepository =
489 yield* ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository;
483490 const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository;
484491 const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository;
485492 const projectionTurnRepository = yield* ProjectionTurnRepository;
......@@ -599,6 +606,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
599606 )(function* (event, attachmentSideEffects) {
600607 switch (event.type) {
601608 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 });
602613 yield* projectionThreadRepository.upsert({
603614 threadId: event.payload.threadId,
604615 projectId: event.payload.projectId,
......@@ -820,6 +831,94 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
820831 : {}),
821832 updatedAt: event.payload.updatedAt,
822833 });
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 });
823922 return;
824923 }
825924
......@@ -866,6 +965,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
866965 if (!recreatedLater) {
867966 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
868967 }
968 // A tombstoned thread must not show up as linked to a pull request.
969 yield* projectionThreadPullRequestRepository.deleteByThreadId({
970 threadId: event.payload.threadId,
971 });
869972 const existingRow = yield* projectionThreadRepository.getById({
870973 threadId: event.payload.threadId,
871974 });
......@@ -2068,6 +2171,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect(
20682171 Layer.provideMerge(ProjectionThreadRepositoryLive),
20692172 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
20702173 Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive),
2174 Layer.provideMerge(ProjectionThreadPullRequests.layer),
20712175 Layer.provideMerge(ProjectionThreadActivityRepositoryLive),
20722176 Layer.provideMerge(ProjectionThreadSessionRepositoryLive),
20732177 Layer.provideMerge(ProjectionTurnRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+146-17
......@@ -6,6 +6,7 @@ import {
66 MessageId,
77 ProjectId,
88 ThreadId,
9 type ThreadPullRequestLink,
910 ThreadLinkedPullRequest,
1011 TurnId,
1112 ProviderInstanceId,
......@@ -66,6 +67,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
6667 yield* sql`DELETE FROM projection_projects`;
6768 yield* sql`DELETE FROM projection_state`;
6869 yield* sql`DELETE FROM projection_thread_proposed_plans`;
70 yield* sql`DELETE FROM projection_thread_pull_requests`;
6971 yield* sql`DELETE FROM projection_turns`;
7072
7173 yield* sql`
......@@ -91,6 +93,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
9193 )
9294 `;
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
94135 yield* sql`
95136 INSERT INTO projection_threads (
96137 thread_id,
......@@ -124,7 +165,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
124165 'default',
125166 NULL,
126167 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"}',
128169 ${encodeThreadLinkedPullRequest(branchPullRequest)},
129170 'turn-1',
130171 '2026-02-24T00:00:04.000Z',
......@@ -286,6 +327,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
286327 sequence += 1;
287328 }
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
289361 const snapshot = yield* snapshotQuery.getSnapshot();
290362
291363 assert.equal(snapshot.snapshotSequence, 5);
......@@ -331,12 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
331403 runtimeMode: "full-access",
332404 branch: null,
333405 worktreePath: null,
334 linkedPullRequest: {
335 projectId: asProjectId("project-1"),
336 repository: "pingdotgg/t3code",
337 number: 42,
338 url: "https://github.com/pingdotgg/t3code/pull/42",
339 },
406 pullRequests: expectedPullRequests,
340407 branchPullRequest,
341408 latestTurn: {
342409 turnId: asTurnId("turn-1"),
......@@ -461,12 +528,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
461528 runtimeMode: "full-access",
462529 branch: null,
463530 worktreePath: null,
464 linkedPullRequest: {
465 projectId: asProjectId("project-1"),
466 repository: "pingdotgg/t3code",
467 number: 42,
468 url: "https://github.com/pingdotgg/t3code/pull/42",
469 },
531 pullRequests: expectedPullRequests,
470532 branchPullRequest,
471533 latestTurn: {
472534 turnId: asTurnId("turn-1"),
......@@ -516,13 +578,27 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
516578 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
517579 }
518580
519 const commandSnapshot = yield* snapshotQuery.getCommandReadModel();
520 assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq");
521 assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
522581 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
523582 assert.equal(threadShell._tag, "Some");
524583 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);
526602 }
527603
528604 yield* sql`
......@@ -3259,3 +3335,56 @@ projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => {
32593335 }),
32603336 );
32613337});
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 {
2929 ProjectId,
3030 ThreadLinkedPullRequest,
3131 ThreadId,
32 ThreadPullRequestSnapshot,
33 ThreadPullRequestStack,
34 type ThreadPullRequestLink,
3235} from "@t3tools/contracts";
36import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests";
3337import * as Arr from "effect/Array";
3438import * as Effect from "effect/Effect";
3539import * as Layer from "effect/Layer";
......@@ -54,6 +58,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
5458import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
5559import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts";
5660import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
61import { ProjectionThreadPullRequest } from "../../persistence/ProjectionThreadPullRequests.ts";
5762import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
5863import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
5964import {
......@@ -112,6 +117,12 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema
112117 Struct.assign({ hasOtherUserMessages: Schema.Number }),
113118);
114119const 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);
115126const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
116127 Struct.assign({
117128 modelSelection: Schema.fromJsonString(ModelSelection),
......@@ -403,6 +414,49 @@ function mapProposedPlanRow(
403414 };
404415}
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
406460function mapThreadActivityRow(
407461 row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,
408462): OrchestrationThreadActivity {
......@@ -649,6 +703,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
649703 `,
650704 });
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
652774 const listThreadActivityRows = SqlSchema.findAll({
653775 Request: Schema.Void,
654776 Result: ProjectionThreadActivityDbRowSchema,
......@@ -1208,6 +1330,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12081330 `,
12091331 });
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
12111354 const listThreadActivityRowsByThread = SqlSchema.findAll({
12121355 Request: ThreadIdLookupInput,
12131356 Result: ProjectionThreadActivityDbRowSchema,
......@@ -1852,6 +1995,14 @@ pending_approval_requests AS (
18521995 ),
18531996 ),
18541997 ),
1998 listThreadPullRequestRows(undefined).pipe(
1999 Effect.mapError(
2000 toPersistenceSqlOrDecodeError(
2001 "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:query",
2002 "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:decodeRows",
2003 ),
2004 ),
2005 ),
18552006 listThreadActivityRows(undefined).pipe(
18562007 Effect.mapError(
18572008 toPersistenceSqlOrDecodeError(
......@@ -1901,6 +2052,7 @@ pending_approval_requests AS (
19012052 threadRows,
19022053 messageRows,
19032054 proposedPlanRows,
2055 pullRequestRows,
19042056 activityRows,
19052057 sessionRows,
19062058 checkpointRows,
......@@ -1910,6 +2062,7 @@ pending_approval_requests AS (
19102062 Effect.gen(function* () {
19112063 const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
19122064 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
2065 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
19132066 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
19142067 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
19152068 const sessionsByThread = new Map<string, OrchestrationSession>();
......@@ -2071,10 +2224,12 @@ pending_approval_requests AS (
20712224 interactionMode: row.interactionMode,
20722225 branch: row.branch,
20732226 worktreePath: row.worktreePath,
2227 ...mapThreadPullRequests(
2228 pullRequestsByThread.get(row.threadId) ?? [],
2229 row.projectId,
2230 repositoryIdentities.get(row.projectId),
2231 ),
20742232 branchPullRequest: row.branchPullRequest,
2075 ...(row.linkedPullRequest === null
2076 ? {}
2077 : { linkedPullRequest: row.linkedPullRequest }),
20782233 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
20792234 createdAt: row.createdAt,
20802235 updatedAt: row.updatedAt,
......@@ -2146,6 +2301,14 @@ pending_approval_requests AS (
21462301 ),
21472302 ),
21482303 ),
2304 listThreadPullRequestRows(undefined).pipe(
2305 Effect.mapError(
2306 toPersistenceSqlOrDecodeError(
2307 "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:query",
2308 "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:decodeRows",
2309 ),
2310 ),
2311 ),
21492312 listThreadSessionRows(undefined).pipe(
21502313 Effect.mapError(
21512314 toPersistenceSqlOrDecodeError(
......@@ -2174,8 +2337,25 @@ pending_approval_requests AS (
21742337 )
21752338 .pipe(
21762339 Effect.flatMap(
2177 ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) =>
2178 Effect.sync(() => {
2340 ([
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 );
21792359 let updatedAt: string | null = null;
21802360 const projects: OrchestrationProject[] = [];
21812361 const threads: OrchestrationThread[] = [];
......@@ -2190,6 +2370,7 @@ pending_approval_requests AS (
21902370 id: row.projectId,
21912371 title: row.title,
21922372 workspaceRoot: row.workspaceRoot,
2373 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
21932374 defaultModelSelection: row.defaultModelSelection,
21942375 defaultThreadEnvMode: row.defaultThreadEnvMode,
21952376 autoPull: row.autoPull === 1,
......@@ -2252,6 +2433,7 @@ pending_approval_requests AS (
22522433 latestTurnByThread.set(row.threadId, mapLatestTurn(row));
22532434 }
22542435 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
2436 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
22552437 const sessionByThread = new Map<string, OrchestrationSession>();
22562438
22572439 for (let index = 0; index < sessionRows.length; index += 1) {
......@@ -2286,10 +2468,12 @@ pending_approval_requests AS (
22862468 interactionMode: row.interactionMode,
22872469 branch: row.branch,
22882470 worktreePath: row.worktreePath,
2471 ...mapThreadPullRequests(
2472 pullRequestsByThread.get(row.threadId) ?? [],
2473 row.projectId,
2474 repositoryIdentities.get(row.projectId),
2475 ),
22892476 branchPullRequest: row.branchPullRequest,
2290 ...(row.linkedPullRequest === null
2291 ? {}
2292 : { linkedPullRequest: row.linkedPullRequest }),
22932477 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
22942478 createdAt: row.createdAt,
22952479 updatedAt: row.updatedAt,
......@@ -2356,6 +2540,14 @@ pending_approval_requests AS (
23562540 ),
23572541 ),
23582542 ),
2543 listActiveThreadPullRequestRows(undefined).pipe(
2544 Effect.mapError(
2545 toPersistenceSqlOrDecodeError(
2546 "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query",
2547 "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:decodeRows",
2548 ),
2549 ),
2550 ),
23592551 listActiveLatestTurnRows(undefined).pipe(
23602552 Effect.mapError(
23612553 toPersistenceSqlOrDecodeError(
......@@ -2375,99 +2567,104 @@ pending_approval_requests AS (
23752567 ]),
23762568 )
23772569 .pipe(
2378 Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>
2379 Effect.gen(function* () {
2380 let updatedAt: string | null = null;
2381 for (const row of projectRows) {
2382 updatedAt = maxIso(updatedAt, row.updatedAt);
2383 }
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);
2570 Effect.flatMap(
2571 ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) =>
2572 Effect.gen(function* () {
2573 let updatedAt: string | null = null;
2574 for (const row of projectRows) {
2575 updatedAt = maxIso(updatedAt, row.updatedAt);
23942576 }
2395 if (row.completedAt !== null) {
2396 updatedAt = maxIso(updatedAt, row.completedAt);
2577 for (const row of threadRows) {
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);
23972594 }
2398 }
2399 for (const row of stateRows) {
2400 updatedAt = maxIso(updatedAt, row.updatedAt);
2401 }
24022595
2403 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows);
2404 const latestTurnByThread = new Map(
2405 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2406 );
2407 const sessionByThread = new Map(
2408 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2409 );
2596 const repositoryIdentities =
2597 yield* resolveRepositoryIdentitiesForProjects(projectRows);
2598 const latestTurnByThread = new Map(
2599 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2600 );
2601 const sessionByThread = new Map(
2602 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2603 );
2604 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
24102605
2411 const snapshot = {
2412 snapshotSequence: computeSnapshotSequence(stateRows),
2413 projects: Arr.filterMap(projectRows, (row) =>
2414 row.deletedAt === null
2415 ? Result.succeed(
2416 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2417 )
2418 : Result.failVoid,
2419 ),
2420 threads: Arr.filterMap(threadRows, (row) =>
2421 row.deletedAt === null
2422 ? Result.succeed({
2423 id: row.threadId,
2424 projectId: row.projectId,
2425 title: row.title,
2426 modelSelection: row.modelSelection,
2427 runtimeMode: row.runtimeMode,
2428 interactionMode: row.interactionMode,
2429 branch: row.branch,
2430 worktreePath: row.worktreePath,
2431 branchPullRequest: row.branchPullRequest,
2432 ...(row.linkedPullRequest === null
2433 ? {}
2434 : { linkedPullRequest: row.linkedPullRequest }),
2435 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2436 createdAt: row.createdAt,
2437 updatedAt: row.updatedAt,
2438 archivedAt: row.archivedAt,
2439 settledOverride: row.settledOverride,
2440 settledAt: row.settledAt,
2441 unsettledAt: row.unsettledAt,
2442 snoozedUntil: row.snoozedUntil,
2443 snoozedAt: row.snoozedAt,
2444 pinnedAt: row.pinnedAt,
2445 pinOrderKey: row.pinOrderKey ?? null,
2446 activeOrderKey: row.activeOrderKey ?? null,
2447 titleRegeneration: mapTitleRegeneration(row),
2448 session: sessionByThread.get(row.threadId) ?? null,
2449 latestUserMessageAt: row.latestUserMessageAt,
2450 hasPendingApprovals: row.pendingApprovalCount > 0,
2451 hasPendingUserInput: row.pendingUserInputCount > 0,
2452 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2453 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2454 row.threadId,
2455 ),
2456 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2457 } satisfies OrchestrationThreadShell)
2458 : Result.failVoid,
2459 ),
2460 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2461 };
2606 const snapshot = {
2607 snapshotSequence: computeSnapshotSequence(stateRows),
2608 projects: Arr.filterMap(projectRows, (row) =>
2609 row.deletedAt === null
2610 ? Result.succeed(
2611 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2612 )
2613 : Result.failVoid,
2614 ),
2615 threads: Arr.filterMap(threadRows, (row) =>
2616 row.deletedAt === null
2617 ? Result.succeed({
2618 id: row.threadId,
2619 projectId: row.projectId,
2620 title: row.title,
2621 modelSelection: row.modelSelection,
2622 runtimeMode: row.runtimeMode,
2623 interactionMode: row.interactionMode,
2624 branch: row.branch,
2625 worktreePath: row.worktreePath,
2626 branchPullRequest: row.branchPullRequest,
2627 ...mapThreadPullRequests(
2628 pullRequestsByThread.get(row.threadId) ?? [],
2629 row.projectId,
2630 repositoryIdentities.get(row.projectId),
2631 ),
2632 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
2633 createdAt: row.createdAt,
2634 updatedAt: row.updatedAt,
2635 archivedAt: row.archivedAt,
2636 settledOverride: row.settledOverride,
2637 settledAt: row.settledAt,
2638 unsettledAt: row.unsettledAt,
2639 snoozedUntil: row.snoozedUntil,
2640 snoozedAt: row.snoozedAt,
2641 pinnedAt: row.pinnedAt,
2642 pinOrderKey: row.pinOrderKey ?? null,
2643 activeOrderKey: row.activeOrderKey ?? null,
2644 titleRegeneration: mapTitleRegeneration(row),
2645 session: sessionByThread.get(row.threadId) ?? null,
2646 latestUserMessageAt: row.latestUserMessageAt,
2647 hasPendingApprovals: row.pendingApprovalCount > 0,
2648 hasPendingUserInput: row.pendingUserInputCount > 0,
2649 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
2650 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2651 row.threadId,
2652 ),
2653 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2654 } satisfies OrchestrationThreadShell)
2655 : Result.failVoid,
2656 ),
2657 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2658 };
24622659
2463 return yield* decodeShellSnapshot(snapshot).pipe(
2464 Effect.mapError(
2465 toPersistenceDecodeError(
2466 "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot",
2660 return yield* decodeShellSnapshot(snapshot).pipe(
2661 Effect.mapError(
2662 toPersistenceDecodeError(
2663 "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot",
2664 ),
24672665 ),
2468 ),
2469 );
2470 }),
2666 );
2667 }),
24712668 ),
24722669 Effect.mapError((error) => {
24732670 if (isPersistenceError(error)) {
......@@ -2505,6 +2702,14 @@ pending_approval_requests AS (
25052702 ),
25062703 ),
25072704 ),
2705 listArchivedThreadPullRequestRows(undefined).pipe(
2706 Effect.mapError(
2707 toPersistenceSqlOrDecodeError(
2708 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:query",
2709 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:decodeRows",
2710 ),
2711 ),
2712 ),
25082713 listArchivedLatestTurnRows(undefined).pipe(
25092714 Effect.mapError(
25102715 toPersistenceSqlOrDecodeError(
......@@ -2524,98 +2729,102 @@ pending_approval_requests AS (
25242729 ]),
25252730 )
25262731 .pipe(
2527 Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>
2528 Effect.gen(function* () {
2529 let updatedAt: string | null = null;
2530 for (const row of projectRows) {
2531 updatedAt = maxIso(updatedAt, row.updatedAt);
2532 }
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);
2732 Effect.flatMap(
2733 ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) =>
2734 Effect.gen(function* () {
2735 let updatedAt: string | null = null;
2736 for (const row of projectRows) {
2737 updatedAt = maxIso(updatedAt, row.updatedAt);
25432738 }
2544 if (row.completedAt !== null) {
2545 updatedAt = maxIso(updatedAt, row.completedAt);
2739 for (const row of threadRows) {
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);
25462756 }
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));
2553 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
2554 projectRows.filter((row) => activeProjectIds.has(row.projectId)),
2555 );
2556 const latestTurnByThread = new Map(
2557 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2558 );
2559 const sessionByThread = new Map(
2560 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2561 );
2758 const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows);
2759 const activeProjectIds = new Set(threadRows.map((row) => row.projectId));
2760 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
2761 projectRows.filter((row) => activeProjectIds.has(row.projectId)),
2762 );
2763 const latestTurnByThread = new Map(
2764 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
2765 );
2766 const sessionByThread = new Map(
2767 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
2768 );
25622769
2563 const snapshot = {
2564 snapshotSequence: computeSnapshotSequence(stateRows),
2565 projects: Arr.filterMap(projectRows, (row) =>
2566 row.deletedAt === null && activeProjectIds.has(row.projectId)
2567 ? Result.succeed(
2568 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2569 )
2570 : 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,
2770 const snapshot = {
2771 snapshotSequence: computeSnapshotSequence(stateRows),
2772 projects: Arr.filterMap(projectRows, (row) =>
2773 row.deletedAt === null && activeProjectIds.has(row.projectId)
2774 ? Result.succeed(
2775 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
2776 )
2777 : Result.failVoid,
26052778 ),
2606 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
2607 })),
2608 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
2609 };
2779 threads: threadRows.map((row): OrchestrationThreadShell => ({
2780 id: row.threadId,
2781 projectId: row.projectId,
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(
2612 Effect.mapError(
2613 toPersistenceDecodeError(
2614 "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot",
2820 return yield* decodeShellSnapshot(snapshot).pipe(
2821 Effect.mapError(
2822 toPersistenceDecodeError(
2823 "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot",
2824 ),
26152825 ),
2616 ),
2617 );
2618 }),
2826 );
2827 }),
26192828 ),
26202829 Effect.mapError((error) => {
26212830 if (isPersistenceError(error)) {
......@@ -2861,7 +3070,7 @@ pending_approval_requests AS (
28613070
28623071 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
28633072 Effect.gen(function* () {
2864 const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
3073 const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([
28653074 getActiveThreadRowById({ threadId }).pipe(
28663075 Effect.mapError(
28673076 toPersistenceSqlOrDecodeError(
......@@ -2886,6 +3095,14 @@ pending_approval_requests AS (
28863095 ),
28873096 ),
28883097 ),
3098 listThreadPullRequestRowsByThread({ threadId }).pipe(
3099 Effect.mapError(
3100 toPersistenceSqlOrDecodeError(
3101 "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query",
3102 "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows",
3103 ),
3104 ),
3105 ),
28893106 ]);
28903107
28913108 if (Option.isNone(threadRow)) {
......@@ -2901,10 +3118,15 @@ pending_approval_requests AS (
29013118 interactionMode: threadRow.value.interactionMode,
29023119 branch: threadRow.value.branch,
29033120 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 ),
29043129 branchPullRequest: threadRow.value.branchPullRequest,
2905 ...(threadRow.value.linkedPullRequest === null
2906 ? {}
2907 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
29083130 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
29093131 createdAt: threadRow.value.createdAt,
29103132 updatedAt: threadRow.value.updatedAt,
......@@ -3112,6 +3334,7 @@ pending_approval_requests AS (
31123334 threadRow,
31133335 messageRows,
31143336 proposedPlanRows,
3337 pullRequestRows,
31153338 activities,
31163339 checkpointRows,
31173340 latestTurnRow,
......@@ -3144,6 +3367,14 @@ pending_approval_requests AS (
31443367 ),
31453368 ),
31463369 ),
3370 listThreadPullRequestRowsByThread({ threadId }).pipe(
3371 Effect.mapError(
3372 toPersistenceSqlOrDecodeError(
3373 "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:query",
3374 "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:decodeRows",
3375 ),
3376 ),
3377 ),
31473378 activitiesEffect,
31483379 listCheckpointRowsByThread({ threadId }).pipe(
31493380 Effect.mapError(
......@@ -3184,10 +3415,15 @@ pending_approval_requests AS (
31843415 interactionMode: threadRow.value.interactionMode,
31853416 branch: threadRow.value.branch,
31863417 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 ),
31873426 branchPullRequest: threadRow.value.branchPullRequest,
3188 ...(threadRow.value.linkedPullRequest === null
3189 ? {}
3190 : { linkedPullRequest: threadRow.value.linkedPullRequest }),
31913427 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
31923428 createdAt: threadRow.value.createdAt,
31933429 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 {
1616 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
1717 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
1818 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
19 ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema,
20 ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema,
21 ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema,
1922 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
2023 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
2124 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
4851export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
4952export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
5053export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;
54export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema;
55export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema;
56export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema;
5157
5258export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
5359export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+1
......@@ -87,6 +87,7 @@ function thread(
8787 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
8888 runtimeMode: "full-access",
8989 interactionMode: "default",
90 pullRequests: [],
9091 branch: "feature",
9192 worktreePath: null,
9293 latestTurn: null,
apps/server/src/orchestration/ThreadPullRequestReactor.ts+6-13
......@@ -1,3 +1,7 @@
1import {
2 canonicalRepositoryKey,
3 sourceControlRepositorySelector,
4} from "@t3tools/shared/sourceControl";
15import {
26 CommandId,
37 type OrchestrationEvent,
......@@ -53,18 +57,6 @@ interface RefreshRequest {
5357 readonly backfill?: boolean;
5458}
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
6860export function pullRequestMatchesProject(
6961 pullRequest: GitManager.GitBranchPullRequest,
7062 project: OrchestrationProjectShell,
......@@ -138,7 +130,7 @@ export const make = Effect.gen(function* () {
138130 const first = group[0]!;
139131 const project = projects.get(first.projectId);
140132 if (project === undefined) return finishBackfill(group);
141 const repository = PullRequestService.repositoryIdentityOf(project);
133 const repository = sourceControlRepositorySelector(project.repositoryIdentity);
142134 if (first.branch !== null && repository === null) return finishBackfill(group);
143135 const worktreeExists =
144136 first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath));
......@@ -189,6 +181,7 @@ export const make = Effect.gen(function* () {
189181
190182 let replacement: ThreadLinkedPullRequest | undefined;
191183 if (
184 thread.pullRequests.length === 0 &&
192185 thread.linkedPullRequest != null &&
193186 detected?.state === "open" &&
194187 detectedReference !== null &&
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+74
......@@ -5,6 +5,7 @@ import {
55 ProjectId,
66 TurnId,
77 type OrchestrationThreadShell,
8 type ThreadPullRequestLink,
89} from "@t3tools/contracts";
910import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
1011
......@@ -18,6 +19,7 @@ const makeThread = (
1819 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
1920 runtimeMode: "full-access",
2021 interactionMode: "default",
22 pullRequests: [],
2123 branch: "feature",
2224 worktreePath: "/repo",
2325 latestTurn: null,
......@@ -213,3 +215,75 @@ describe("resolveAutoSettlementAt", () => {
213215 ).toBe(true);
214216 });
215217});
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 @@
11import type { OrchestrationThreadShell } from "@t3tools/contracts";
2import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
23
34export interface SettlementPullRequest {
45 readonly state: "open" | "closed" | "merged";
......@@ -72,7 +73,29 @@ export function resolveAutoSettlementAt(input: {
7273 readonly autoSettleAfterDays: number | null;
7374 readonly autoSettleOnMerge: boolean;
7475}): 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 }
7699 if (!isAutoSettlementCandidate(thread, input.now)) return null;
77100 const activityAt = latestTimestamp([
78101 thread.latestUserMessageAt,
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+52
......@@ -80,6 +80,7 @@ function makeThread(
8080 },
8181 runtimeMode: "full-access",
8282 interactionMode: "default",
83 pullRequests: [],
8384 branch: null,
8485 worktreePath: null,
8586 latestTurn: null,
......@@ -301,6 +302,57 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
301302});
302303
303304describe("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 );
304356 it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>
305357 Effect.scoped(
306358 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+3-1
......@@ -103,7 +103,9 @@ export const make = Effect.gen(function* () {
103103 {
104104 concurrency: 8,
105105 },
106 )).filter((thread) => thread !== null);
106 ))
107 .filter((thread) => thread !== null)
108 .filter((thread) => !thread.pullRequests.some((link) => link.source !== "stack-dismissed"));
107109
108110 // Use the same cwd as PR discovery so both paths share GitManager's cache.
109111 const lookupCwdByThreadId = new Map<string, string>();
apps/server/src/orchestration/commandInvariants.test.ts+2
......@@ -59,6 +59,7 @@ const readModel: OrchestrationReadModel = {
5959 runtimeMode: "full-access",
6060 branch: null,
6161 worktreePath: null,
62 pullRequests: [],
6263 createdAt: now,
6364 updatedAt: now,
6465 archivedAt: null,
......@@ -84,6 +85,7 @@ const readModel: OrchestrationReadModel = {
8485 runtimeMode: "full-access",
8586 branch: null,
8687 worktreePath: null,
88 pullRequests: [],
8789 createdAt: now,
8890 updatedAt: now,
8991 archivedAt: null,
apps/server/src/orchestration/decider.active-order.test.ts+1
......@@ -33,6 +33,7 @@ function makeReadModel(overrides: Partial<OrchestrationThread> = {}): Orchestrat
3333 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
3434 runtimeMode: "full-access",
3535 interactionMode: "default",
36 pullRequests: [],
3637 branch: null,
3738 worktreePath: null,
3839 latestTurn: null,
apps/server/src/orchestration/decider.pinned.test.ts+1
......@@ -36,6 +36,7 @@ function makeReadModel(input: {
3636 interactionMode: "default",
3737 branch: null,
3838 worktreePath: null,
39 pullRequests: [],
3940 latestTurn: null,
4041 createdAt: NOW,
4142 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 = {
2626 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
2727 runtimeMode: "full-access",
2828 interactionMode: "default",
29 pullRequests: [],
2930 branch: null,
3031 worktreePath: null,
3132 latestTurn: null,
apps/server/src/orchestration/decider.settled.test.ts+1
......@@ -47,6 +47,7 @@ function makeReadModel(
4747 interactionMode: "default",
4848 branch: null,
4949 worktreePath: null,
50 pullRequests: [],
5051 latestTurn: null,
5152 createdAt: NOW,
5253 updatedAt: NOW,
apps/server/src/orchestration/decider.snoozed.test.ts+1
......@@ -41,6 +41,7 @@ function makeReadModel(input: {
4141 interactionMode: "default",
4242 branch: null,
4343 worktreePath: null,
44 pullRequests: [],
4445 latestTurn: null,
4546 createdAt: NOW,
4647 updatedAt: NOW,
apps/server/src/orchestration/decider.titleRegeneration.test.ts+1
......@@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = {
2626 interactionMode: "default",
2727 branch: null,
2828 worktreePath: null,
29 pullRequests: [],
2930 latestTurn: null,
3031 createdAt: UPDATED_AT,
3132 updatedAt: UPDATED_AT,
apps/server/src/orchestration/decider.ts+221
......@@ -10,8 +10,16 @@ import {
1010 type OrchestrationEvent,
1111 type OrchestrationReadModel,
1212 type OrchestrationThread,
13 type ThreadPullRequestKey,
14 type ThreadPullRequestLink,
1315 type OrchestrationThreadActivity,
1416} from "@t3tools/contracts";
17import {
18 legacyLinkedPullRequestOf,
19 legacyThreadPullRequestKey,
20 normalizeThreadPullRequestKey,
21 threadPullRequestKeysEqual,
22} from "@t3tools/shared/threadPullRequests";
1523import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1624import * as DateTime from "effect/DateTime";
1725import * as Crypto from "effect/Crypto";
......@@ -121,6 +129,13 @@ function hasQueuedTurnStartForThread(
121129 );
122130}
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
124139function withEventBase(
125140 input: Pick<OrchestrationCommand, "commandId"> & {
126141 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
......@@ -876,6 +891,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
876891 command,
877892 threadId: command.threadId,
878893 });
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 }
879968 const branch =
880969 command.branch !== undefined &&
881970 command.expectedBranch !== undefined &&
......@@ -920,6 +1009,138 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
9201009 };
9211010 }
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
9231144 case "thread.pull-request.sync": {
9241145 const thread = yield* requireThreadNotArchived({
9251146 readModel,
apps/server/src/orchestration/decider.userInputDismiss.test.ts+1
......@@ -49,6 +49,7 @@ function makeReadModel(
4949 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
5050 runtimeMode: "full-access",
5151 interactionMode: "default",
52 pullRequests: [],
5253 branch: null,
5354 worktreePath: null,
5455 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", () => {
8686 interactionMode: "default",
8787 branch: null,
8888 worktreePath: null,
89 pullRequests: [],
8990 branchPullRequest: null,
9091 latestTurn: null,
9192 createdAt: now,
......@@ -117,7 +118,31 @@ describe("orchestration projector", () => {
117118 commandId: null,
118119 };
119120 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 },
121146 makeEvent({
122147 ...eventFields,
123148 sequence: 1,
apps/server/src/orchestration/projector.ts+212-25
......@@ -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";
210import {
311 isImportedAgentSessionMessageId,
412 OrchestrationCheckpointSummary,
......@@ -6,6 +14,11 @@ import {
614 OrchestrationSession,
715 OrchestrationThread,
816} from "@t3tools/contracts";
17import {
18 legacyLinkedPullRequestOf,
19 legacyThreadPullRequestKey,
20 threadPullRequestKeysEqual,
21} from "@t3tools/shared/threadPullRequests";
922import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1023import * as Effect from "effect/Effect";
1124import * as Schema from "effect/Schema";
......@@ -28,6 +41,9 @@ import {
2841 ThreadSettledPayload,
2942 ThreadPinnedPayload,
3043 ThreadPinReorderedPayload,
44 ThreadPullRequestLinkedPayload,
45 ThreadPullRequestSyncedPayload,
46 ThreadPullRequestUnlinkedPayload,
3147 ThreadSnoozedPayload,
3248 ThreadUnpinnedPayload,
3349 ThreadUnarchivedPayload,
......@@ -101,6 +117,77 @@ function updateThread(
101117 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
102118}
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
104191function decodeForEvent<A>(
105192 schema: Schema.Decoder<A, never>,
106193 value: unknown,
......@@ -336,6 +423,7 @@ export function projectEvent(
336423 interactionMode: payload.interactionMode,
337424 branch: payload.branch,
338425 worktreePath: payload.worktreePath,
426 pullRequests: [],
339427 branchPullRequest: null,
340428 latestTurn: null,
341429 createdAt: payload.createdAt,
......@@ -498,30 +586,129 @@ export function projectEvent(
498586
499587 case "thread.meta-updated":
500588 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
501 Effect.map((payload) => ({
502 ...nextBase,
503 threads: updateThread(nextBase.threads, payload.threadId, {
504 ...(payload.title !== undefined ? { title: payload.title } : {}),
505 ...(payload.activeOrderKey !== undefined
506 ? { activeOrderKey: payload.activeOrderKey }
507 : {}),
508 ...(payload.titleRegeneration !== undefined
509 ? { titleRegeneration: payload.titleRegeneration }
510 : {}),
511 ...(payload.modelSelection !== undefined
512 ? { modelSelection: payload.modelSelection }
513 : {}),
514 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
515 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
516 ...(payload.linkedPullRequest !== undefined
517 ? { linkedPullRequest: payload.linkedPullRequest }
518 : {}),
519 ...(payload.branchPullRequest !== undefined
520 ? { branchPullRequest: payload.branchPullRequest }
521 : {}),
522 updatedAt: payload.updatedAt,
523 }),
524 })),
589 Effect.map((payload) => {
590 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
591 // Legacy single-link events replay into the link array so the
592 // derived linkedPullRequest and pullRequests never disagree.
593 const legacyLinkPatch =
594 thread !== undefined && payload.linkedPullRequest !== undefined
595 ? pullRequestsPatch(
596 thread,
597 legacyLinkToPullRequests(
598 thread,
599 nextBase.projects.find((project) => project.id === thread.projectId),
600 payload.linkedPullRequest,
601 payload.updatedAt,
602 ),
603 nextBase.projects,
604 )
605 : {};
606 return {
607 ...nextBase,
608 threads: updateThread(nextBase.threads, payload.threadId, {
609 ...(payload.title !== undefined ? { title: payload.title } : {}),
610 ...(payload.titleRegeneration !== undefined
611 ? { titleRegeneration: payload.titleRegeneration }
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 }),
525712 );
526713
527714 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";
1515import { SqlitePersistenceMemory } from "./Sqlite.ts";
1616import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts";
1717import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts";
18import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts";
18import * as ProjectionThreadPullRequests from "../ProjectionThreadPullRequests.ts";
1919import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts";
2020import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts";
21import {
22 ProjectionThreadPullRequestRepository,
23 type ProjectionThreadPullRequest,
24} from "../ProjectionThreadPullRequests.ts";
25import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts";
2126import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts";
2227
2328const projectionRepositoriesLayer = it.layer(
2429 Layer.mergeAll(
2530 ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
2631 ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
32 ProjectionThreadPullRequests.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
2733 ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
2834 SqlitePersistenceMemory,
2935 ),
......@@ -529,4 +535,164 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
529535 assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest);
530536 }),
531537 );
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 );
532698});
apps/server/src/persistence/Migrations.ts+2
......@@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.
6161import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
6262import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
6363import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
64import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
6465
6566/**
6667 * Migration loader with all migrations defined inline.
......@@ -122,6 +123,7 @@ const migrationEntries = [
122123 [47, "ProjectionProjectIcon", Migration0047],
123124 [48, "ProjectionThreadBranchPullRequest", Migration0048],
124125 [49, "ProjectionThreadsActiveOrderKey", Migration0049],
126 [50, "ProjectionThreadPullRequests", Migration0050],
125127] as const;
126128
127129export 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: {
124124 modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" },
125125 runtimeMode: "full-access",
126126 interactionMode: "default",
127 pullRequests: [],
127128 branch: null,
128129 worktreePath: null,
129130 latestTurn: null,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+2-2
......@@ -1,3 +1,4 @@
1import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
12// @effect-diagnostics nodeBuiltinImport:off
23import * as NodeFS from "node:fs";
34import * as NodeOS from "node:os";
......@@ -392,8 +393,7 @@ describe("ClaudeAdapterLive", () => {
392393 assert.deepEqual(createInput?.options.systemPrompt, {
393394 type: "preset",
394395 preset: "claude_code",
395 append:
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>",
396 append: buildRuntimeInstructions({ harness: "Claude Code" }),
397397 });
398398 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
399399 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* (
22832283 "-c",
22842284 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
22852285 ],
2286 browserToolsAvailable: mcpSession.preview,
22862287 }
22872288 : {}),
22882289 };
apps/server/src/provider/Layers/CodexSessionRuntime.ts+11-2
......@@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions {
166166 readonly serviceTier?: CodexServiceTier | undefined;
167167 readonly resumeCursor?: CodexResumeCursor;
168168 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;
169176}
170177
171178export interface CodexSessionRuntimeSendTurnInput {
......@@ -2349,10 +2356,12 @@ export const makeCodexSessionRuntime = (
23492356 ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
23502357 ...(input.effort ? { effort: input.effort } : {}),
23512358 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
2352 // Derived from the session's own MCP configuration rather than the
2359 // Derived from the session's own credential rather than the
23532360 // setting, so the prompt describes the tools this turn actually
23542361 // 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),
23562365 });
23572366 const rawResponse = yield* client.raw.request("turn/start", params);
23582367 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) => {
47974797const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
47984798
47994799describe("agent browser access", () => {
4800 const revokedThreads: Array<ThreadId> = [];
48014800 const projectId = ProjectId.make("project-browser-access");
48024801
48034802 const startSessionWith = (
......@@ -4806,7 +4805,7 @@ describe("agent browser access", () => {
48064805 projectOverride?: boolean,
48074806 ) =>
48084807 Effect.gen(function* () {
4809 const issued: Array<ThreadId> = [];
4808 const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = [];
48104809 const codex = makeFakeCodexAdapter();
48114810 const providerAdapterLayer = Layer.succeed(
48124811 ProviderAdapterRegistry.ProviderAdapterRegistry,
......@@ -4865,10 +4864,9 @@ describe("agent browser access", () => {
48654864 const providerLayer = makeProviderServiceLive({
48664865 issueMcpCredential: (request) =>
48674866 Effect.sync(() => {
4868 issued.push(request.threadId);
4867 issued.push({ threadId: request.threadId, preview: request.preview });
48694868 return undefined;
48704869 }),
4871 revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)),
48724870 }).pipe(
48734871 Layer.provide(providerAdapterLayer),
48744872 Layer.provide(directoryLayer),
......@@ -4903,48 +4901,34 @@ describe("agent browser access", () => {
49034901 return issued;
49044902 });
49054903
4906 // Credential issuance is the observable that matters: it is the only place a
4907 // credential is minted, and `/mcp` accepts nothing else, so withholding it is
4908 // what actually denies every provider and external MCP client.
4909 it.effect("requests no MCP credential when agent browser access is off", () =>
4904 // The capability on the credential is the observable that matters: a session
4905 // always gets a credential (the pull request toolkit is never withheld), and
4906 // `preview` on it is what actually grants or denies the browser tools.
4907 it.effect("issues a credential without preview when agent browser access is off", () =>
49104908 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, []);
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);
4911 const issued = yield* startSessionWith(false, threadId);
49234912
4924 // Clearing the in-memory map is not enough: a token issued before the
4925 // toggle flipped stays valid against `/mcp` for its whole liveness
4926 // window, and later turns refresh it.
4927 assert.deepEqual(revokedThreads, [threadId]);
4913 assert.deepEqual(issued, [{ threadId, preview: false }]);
49284914 }).pipe(Effect.provide(NodeServices.layer)),
49294915 );
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", () =>
49324918 Effect.gen(function* () {
49334919 const threadId = asThreadId("thread-browser-on");
49344920
49354921 const issued = yield* startSessionWith(true, threadId);
49364922
4937 assert.deepEqual(issued, [threadId]);
4923 assert.deepEqual(issued, [{ threadId, preview: true }]);
49384924 }).pipe(Effect.provide(NodeServices.layer)),
49394925 );
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", () =>
49424928 Effect.gen(function* () {
49434929 const threadId = asThreadId("thread-project-browser-off");
4944 revokedThreads.length = 0;
49454930 const issued = yield* startSessionWith(true, threadId, false);
4946 assert.deepEqual(issued, []);
4947 assert.deepEqual(revokedThreads, [threadId]);
4931 assert.deepEqual(issued, [{ threadId, preview: false }]);
49484932 }).pipe(Effect.provide(NodeServices.layer)),
49494933 );
49504934
......@@ -4952,7 +4936,7 @@ describe("agent browser access", () => {
49524936 Effect.gen(function* () {
49534937 const threadId = asThreadId("thread-project-browser-on");
49544938 const issued = yield* startSessionWith(false, threadId, true);
4955 assert.deepEqual(issued, [threadId]);
4939 assert.deepEqual(issued, [{ threadId, preview: true }]);
49564940 }).pipe(Effect.provide(NodeServices.layer)),
49574941 );
49584942});
apps/server/src/provider/Layers/ProviderService.ts+13-23
......@@ -249,8 +249,6 @@ export interface ProviderServiceLiveOptions {
249249 * test see whether a credential was requested at all.
250250 */
251251 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
252 /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */
253 readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread;
254252}
255253
256254interface TurnAnalyticsMetadata {
......@@ -479,8 +477,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
479477 );
480478 const issueMcpCredential =
481479 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
482 const revokeMcpCredential =
483 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
484480 const fileSystem = yield* FileSystem.FileSystem;
485481 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
486482 const pendingCompactions = new Map<ThreadId, PendingCompaction>();
......@@ -853,14 +849,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
853849 yield* recordCompletedTurnProperties(properties);
854850 });
855851 /**
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.
857853 *
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 /**
864854 * Deny on an unreadable settings file rather than letting the read failure
865855 * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
866856 * a union every caller handles, for a branch that only decides whether one
......@@ -889,20 +879,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
889879 ),
890880 );
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 */
892892 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
893893 Effect.gen(function* () {
894 if (!(yield* agentBrowserAccessEnabled(threadId))) {
895 // Revoke as well as clear. Every other prepare path reaches
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 });
894 const preview = yield* agentBrowserAccessEnabled(threadId);
895 const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview });
906896 if (credential) {
907897 yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config));
908898 }
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -98,6 +98,7 @@ function makeReadModel(
9898 runtimeMode: "full-access" as const,
9999 branch: null,
100100 worktreePath: null,
101 pullRequests: [],
101102 createdAt: now,
102103 updatedAt: now,
103104 archivedAt: null,
apps/server/src/provider/RuntimeInstructions.test.ts+8
......@@ -2,6 +2,14 @@ import { describe, expect, it } from "vite-plus/test";
22import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
33
44describe("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
513 it("keeps known model and effort metadata on one line", () => {
614 expect(
715 buildRuntimeInstructions({
apps/server/src/provider/RuntimeInstructions.ts+5-1
......@@ -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
15/** Shared runtime context; omit model and effort when the harness manages them dynamically. */
26export function buildRuntimeInstructions(runtime: {
37 readonly harness: string;
......@@ -9,7 +13,7 @@ export function buildRuntimeInstructions(runtime: {
913 const effort = toSingleLine(runtime.reasoningEffort ?? "");
1014 const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : "";
1115 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}`;
1317}
1418
1519function toSingleLine(value: string): string {
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+227-26
......@@ -183,20 +183,37 @@ afterEach(() => {
183183});
184184
185185layer("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", () =>
187187 Effect.gen(function* () {
188 mockedGetPullRequest.mockReturnValueOnce(
189 Effect.succeed({
190 number: 7,
191 title: "Reuse the summary",
192 url: "https://github.com/acme/web/pull/7",
193 baseRefName: "main",
194 headRefName: "feat/summary",
195 state: "merged",
196 closedAt: "2026-08-23T10:00:00Z",
197 mergedAt: "2026-08-23T10:00:00Z",
198 updatedAt: "2026-08-24T12:34:56.000Z",
199 }),
188 mockedExecute.mockReturnValueOnce(
189 Effect.succeed(
190 output(
191 // @effect-diagnostics-next-line preferSchemaOverJson:off
192 JSON.stringify({
193 number: 7,
194 title: "Reuse the summary",
195 url: "https://github.com/acme/web/pull/7",
196 author: { login: "octocat", name: "Octo Cat" },
197 baseRefName: "main",
198 headRefName: "feat/summary",
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 ),
200217 );
201218 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
202219
......@@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => {
207224 number: 7,
208225 });
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",
211301 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",
220302 });
221 expect(mockedGetPullRequest).toHaveBeenCalledOnce();
222 expect(mockedGetPullRequest).toHaveBeenCalledWith({
303
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({
223329 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"),
225396 });
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");
227428 }),
228429 );
229430
apps/server/src/pullRequest/GitHubPullRequestCli.ts+94-42
......@@ -39,6 +39,7 @@ import {
3939 decodePullRequestListJson,
4040 decodePullRequestNodeIdJson,
4141 decodePullRequestSearchJson,
42 decodePullRequestStacksJson,
4243 decodePullRequestStatsJson,
4344 decodeReactionSubjectScopeJson,
4445 decodeRepositoryAccessJson,
......@@ -84,6 +85,7 @@ import {
8485 type GitHubPullRequestHead,
8586 type GitHubPullRequestListItem,
8687 type GitHubPullRequestSearchItem,
88 type GitHubPullRequestStack,
8789 type GitHubReviewThreadComments,
8890 type GitHubRepositoryAccess,
8991 type GitHubWorkflowRunApproval,
......@@ -91,7 +93,7 @@ import {
9193 type GitHubReviewThreadPage,
9294 type GitHubViewerAccess,
9395} from "./gitHubPullRequestJson.ts";
94import type { ProviderListCursor } from "./PullRequestProvider.ts";
96import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts";
9597
9698/**
9799 * 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<
460462 readonly repository: string;
461463 readonly host: string;
462464 readonly number: number;
463 }) => Effect.Effect<
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 >;
465 }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestCliError>;
478466
479467 readonly getPullRequestDetail: (input: {
480468 readonly cwd: string;
......@@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service<
494482 readonly isCrossRepository: true;
495483 }) => 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
497496 /**
498497 * How far the branch trails its base, and whether this viewer may update it. Its own read
499498 * because the comparison needs the head ref the detail answers with — a fork's branch is not
......@@ -807,8 +806,8 @@ function matchesFilters(
807806 viewer: string,
808807): boolean {
809808 if (filters === undefined) return true;
810 const labels = item.labels.map((label) => label.name.trim().toLowerCase());
811 const holds = (label: string) => labels.includes(label.trim().toLowerCase());
809 const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
810 const holds = (label: string) => labels.has(label.trim().toLowerCase());
812811 return (
813812 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
814813 (filters.review === undefined ||
......@@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () {
16301629 ).pipe(Effect.map((results) => results.flat()));
16311630 },
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.
16331634 getPullRequestSummary: (input) =>
16341635 github
1635 .getPullRequest({
1636 .execute({
16361637 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 ],
16381646 })
16391647 .pipe(
1640 Effect.flatMap((summary) =>
1641 summary.updatedAt === undefined
1642 ? Effect.fail(
1643 new GitHubPullRequestUpdatedAtUnavailableError({
1644 command: "gh",
1645 cwd: input.cwd,
1646 repository: input.repository,
1647 number: input.number,
1648 }),
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,
1648 Effect.flatMap((result) => {
1649 const decoded = decodePullRequestDetailJson(result.stdout.trim());
1650 if (!Result.isSuccess(decoded)) {
1651 return Effect.fail(
1652 new GitHubPullRequestReadError({
1653 command: "gh",
1654 cwd: input.cwd,
1655 operation: "getPullRequestSummary",
1656 cause: decoded.failure,
16611657 }),
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 }),
16631681 ),
16641682
16651683 getPullRequestDetail,
16661684 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
16681720 getPullRequestBaseComparison: (input) => {
16691721 const { owner, name } = parseRepositorySelector(input.repository);
16701722 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", () =>
2525 baseBranch: "main",
2626 state: "open" as const,
2727 updatedAt: "2026-08-24T12:34:56.000Z",
28 author: { login: "octocat", name: null, avatarUrl: null },
2829 };
2930 }),
3031 }),
......@@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () =>
4243
4344 expect(summary.state).toBe("open");
4445 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");
45106 }),
46107);
47108
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+15-1
......@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = {
4747 },
4848 reviewers: { request: true, listCandidates: true },
4949 edit: { changeRequest: true, comment: true },
50 stacks: true,
5051 labels: true,
5152};
5253
......@@ -292,7 +293,20 @@ export const make = Effect.gen(function* () {
292293 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
293294
294295 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
297311 getChangeRequest: (input) =>
298312 Effect.all(
apps/server/src/pullRequest/PullRequestProvider.ts+36
......@@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary {
103103 readonly closedAt?: string | null;
104104 readonly mergedAt?: string | null;
105105 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>;
106134}
107135
108136export interface ProviderChangeRequestPage {
......@@ -331,6 +359,14 @@ export interface PullRequestProviderApi {
331359 input: ProviderRepositoryRef & { readonly number: number },
332360 ) => 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
334370 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
335371 readonly getChangeRequestActivity: (
336372 input: ProviderRepositoryRef & { readonly number: number },
apps/server/src/pullRequest/PullRequestService.test.ts+323-39
......@@ -32,6 +32,7 @@ function project(input: {
3232 readonly repository?: string;
3333 readonly provider?: string;
3434 readonly host?: string;
35 readonly remoteUrl?: string;
3536}): OrchestrationProjectShell {
3637 // The host defaults from the provider, so a fixture only names one when the point of the
3738 // test is two hosts of the same kind.
......@@ -47,7 +48,7 @@ function project(input: {
4748 locator: {
4849 source: "git-remote" as const,
4950 remoteName: "origin",
50 remoteUrl: `https://${host}/${input.repository}.git`,
51 remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`,
5152 },
5253 provider: input.provider ?? "github",
5354 displayName: input.repository,
......@@ -470,7 +471,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows"
470471
471472 // Keyed by the selector Azure is actually asked with, which is the repository's own name.
472473 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",
474475 });
475476 }),
476477);
......@@ -1563,6 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project",
15631564 }),
15641565);
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
15661808it.effect("refuses a diff on a host that cannot produce one", () =>
15671809 Effect.gen(function* () {
15681810 const service = yield* makeService({
......@@ -3377,6 +3619,48 @@ it.effect("does not ask the host again for a linked summary it already holds", (
33773619 }),
33783620);
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
33803664it.effect("reuses an observed merged state for strict settlement reads", () =>
33813665 Effect.gen(function* () {
33823666 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
35873871 }),
35883872);
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
36273874it.effect("narrows the rows of a host that ignored the filters it was handed", () =>
36283875 Effect.gen(function* () {
36293876 const service = yield* makeService({
......@@ -4137,3 +4384,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the
41374384 assert.strictEqual(unnamed.viewer, undefined);
41384385 }),
41394386);
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 @@
1import {
2 canonicalRepositoryKey,
3 sourceControlRepositorySelector,
4} from "@t3tools/shared/sourceControl";
15import * as Cache from "effect/Cache";
26import * as Clock from "effect/Clock";
37import * as Context from "effect/Context";
......@@ -46,6 +50,7 @@ import {
4650 type PullRequestLabelCandidateList,
4751 type PullRequestLabelChangeInput,
4852 type PullRequestSubmitReviewInput,
53 type PullRequestStack,
4954 type PullRequestSummary,
5055 type PullRequestThreadReplyInput,
5156 type PullRequestThreadResolutionInput,
......@@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service<
144149 input: PullRequestRef,
145150 options?: { readonly recoverTransientFailure?: boolean },
146151 ) => 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>;
147159 readonly subscribeMerges: Effect.Effect<
148160 Stream.Stream<PullRequestMergeEvent>,
149161 never,
......@@ -241,6 +253,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang
241253
242254/** A project this page can read: its remote is on a host with an implementation. */
243255interface SupportedProject {
256 readonly cursorKey: string;
244257 readonly project: OrchestrationProjectShell;
245258 readonly api: PullRequestProviderApi;
246259 readonly repository: string;
......@@ -453,7 +466,7 @@ function withRateLimitBackoff(
453466 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,
454467 ) => wrap(operation, call, true);
455468
456 return {
469 const wrapped = {
457470 kind: api.kind,
458471 capabilities: api.capabilities,
459472 getViewer: wrap("getViewer", api.getViewer),
......@@ -474,6 +487,9 @@ function withRateLimitBackoff(
474487 : {
475488 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),
476489 }),
490 ...(api.getChangeRequestStack === undefined
491 ? {}
492 : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }),
477493 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
478494 ...(api.getReviewThreadComments === undefined
479495 ? {}
......@@ -506,30 +522,9 @@ function withRateLimitBackoff(
506522 setReaction: interactive("setReaction", api.setReaction),
507523 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
508524 };
509}
510
511/**
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;
525 // Optional provider methods must be forwarded too; returning the interface alone permits omissions.
526 return wrapped satisfies PullRequestProviderApi &
527 Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>;
533528}
534529
535530export const make = Effect.gen(function* () {
......@@ -554,7 +549,11 @@ export const make = Effect.gen(function* () {
554549 for (const project of projects) {
555550 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
556551 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;
558557 const host = pullRequestHostOf(identity, "unknown");
559558 // A legacy identity has no canonical host until its provider is refined, so it must reach
560559 // the refinement before a host filter can decide whether it belongs in the result.
......@@ -628,7 +627,7 @@ export const make = Effect.gen(function* () {
628627 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;
629628 const identity = project.repositoryIdentity;
630629 let kind = identity?.provider as SourceControlProviderKind | undefined;
631 const repository = repositoryIdentityOf(project);
630 const repository = sourceControlRepositorySelector(project.repositoryIdentity);
632631 if (!identity || kind === undefined || repository === null) continue;
633632 // Worktrees of one repository are separate projects; reading the remote once keeps
634633 // the page from repeating every change request per local checkout. The host is part
......@@ -647,7 +646,10 @@ export const make = Effect.gen(function* () {
647646 if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);
648647 else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot);
649648 }
650 const key = listCursorKey(host, repository);
649 const key = listCursorKey(
650 host,
651 kind === "azure-devops" ? identity.canonicalKey : repository,
652 );
651653 if (seen.has(key)) continue;
652654 seen.add(key);
653655 if (api === null) {
......@@ -657,6 +659,7 @@ export const make = Effect.gen(function* () {
657659 continue;
658660 }
659661 supported.push({
662 cursorKey: key,
660663 project,
661664 api: withRateLimitBackoff(api, host, rateLimits),
662665 repository,
......@@ -667,16 +670,30 @@ export const make = Effect.gen(function* () {
667670 }),
668671 );
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 */
670680 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>
671681 listWorkspaceProjects({ projectId: ref.projectId }).pipe(
672682 Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => {
673 const match = supported[0];
674 if (!match) {
675 return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));
683 const own = supported[0];
684 const repository = ref.repository.trim();
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);
676690 }
677 // The repository travels through the client, so it is checked against the project's
678 // own remote rather than being handed to a provider verbatim.
679 if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) {
691 if (host === undefined) {
692 if (own === undefined) {
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.
680697 return Effect.fail(
681698 new PullRequestOperationError({
682699 operation: "resolveRepository",
......@@ -684,7 +701,39 @@ export const make = Effect.gen(function* () {
684701 }),
685702 );
686703 }
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 );
688737 }),
689738 );
690739
......@@ -829,8 +878,8 @@ export const make = Effect.gen(function* () {
829878 viewer: string,
830879 ): boolean => {
831880 if (filters === undefined) return true;
832 const labels = item.labels.map((label) => label.name.trim().toLowerCase());
833 const holds = (label: string) => labels.includes(label.trim().toLowerCase());
881 const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
882 const holds = (label: string) => labels.has(label.trim().toLowerCase());
834883 return (
835884 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
836885 // Judged on the provider row rather than the entry, because the two absences mean
......@@ -949,9 +998,7 @@ export const make = Effect.gen(function* () {
949998 const selected =
950999 continuation === null
9511000 ? projects
952 : projects.filter(({ host, repository }) =>
953 continuation.has(listCursorKey(host, repository)),
954 );
1001 : projects.filter(({ cursorKey }) => continuation.has(cursorKey));
9551002 const readable = selected.filter(({ host }) => viewers[host] !== undefined);
9561003 // A host that could not be read still has projects, and they are absent from the list.
9571004 // Reporting them keeps "N repositories were unavailable" honest instead of dropping them.
......@@ -993,7 +1040,7 @@ export const make = Effect.gen(function* () {
9931040
9941041 const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT;
9951042 const cursorOf = (project: SupportedProject): ListCursor | undefined =>
996 continuation?.get(listCursorKey(project.host, project.repository));
1043 continuation?.get(project.cursorKey);
9971044
9981045 /**
9991046 * One repository asked on its own. What every host without a search across repositories
......@@ -1002,7 +1049,7 @@ export const make = Effect.gen(function* () {
10021049 const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => {
10031050 {
10041051 const viewer = viewers[project.host]!;
1005 const key = listCursorKey(project.host, project.repository);
1052 const key = project.cursorKey;
10061053 const cursor = cursorOf(project);
10071054 return project.api
10081055 .listChangeRequests({
......@@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () {
11611208 !cursorHere.seenAt.includes(item.number),
11621209 );
11631210 return Effect.succeed({
1164 key: listCursorKey(project.host, project.repository),
1211 key: project.cursorKey,
11651212 entries: items
11661213 .filter((item) => matchesRowFilters(item, input.filters, viewer))
11671214 .map((item) => toEntry({ project, item, viewer })),
......@@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () {
12541301 title: changeRequest.title,
12551302 url: changeRequest.url,
12561303 state: changeRequest.state,
1257 ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
12581304 headBranch: changeRequest.headBranch,
12591305 baseBranch: changeRequest.baseBranch,
12601306 closedAt: changeRequest.closedAt ?? null,
12611307 mergedAt: changeRequest.mergedAt ?? null,
12621308 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 }),
12631329 })),
12641330 );
12651331 }),
12661332 );
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
12681365 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
12691366 requireProject(input).pipe(
12701367 Effect.flatMap((project) =>
......@@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () {
15071604 })
15081605 .pipe(
15091606 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 ),
15111612 );
15121613 }),
15131614 );
......@@ -2133,11 +2234,35 @@ export const make = Effect.gen(function* () {
21332234 let turnRefreshEpoch = 0;
21342235 const refEpochs = new Map<string, number>();
21352236 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}`;
21372239 const refEpoch = (ref: PullRequestRef) =>
21382240 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.
21392243 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 };
21412266 // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse
21422267 // them; explicit refreshes, mutations, and turns strand old and in-flight results.
21432268 const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]);
......@@ -2181,8 +2306,7 @@ export const make = Effect.gen(function* () {
21812306
21822307 const summaryCache = yield* Cache.makeWith(
21832308 (key: string) => {
2184 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2185 return summaryUncached({ projectId, repository, number } as PullRequestRef);
2309 return summaryUncached(refOfCacheKey(key));
21862310 },
21872311 {
21882312 capacity: DETAIL_CACHE_CAPACITY,
......@@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () {
22032327 );
22042328 };
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
22062337 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
22072338 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
22082339 // 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* () {
22832414 const detailCache = yield* Cache.makeWith(
22842415 (key: string) => {
22852416 const statsKey = statsCacheKey(key);
2286 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2287 return detailUncached({ projectId, repository, number } as PullRequestRef).pipe(
2417 return detailUncached(refOfCacheKey(key)).pipe(
22882418 Effect.tap(
22892419 Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) {
22902420 recordStats(
......@@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () {
23072437 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
23082438 },
23092439 );
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,
23112446 provider: detail.provider,
23122447 projectId: detail.projectId,
23132448 repository: detail.repository,
......@@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () {
23152450 title: detail.title,
23162451 url: detail.url,
23172452 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,
23192459 headBranch: detail.headBranch,
23202460 baseBranch: detail.baseBranch,
23212461 closedAt: detail.closedAt,
......@@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () {
23382478 key,
23392479 Cache.get(detailCache, key).pipe(
23402480 Effect.tap((value) => {
2341 const summary = summaryFromDetail(value);
2481 const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
23422482 return shouldReplaceHeldSummary(key, summary)
23432483 ? lastGoodSummary.record(key, summary)
23442484 : Effect.void;
......@@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () {
23502490
23512491 const activityCache = yield* Cache.makeWith(
23522492 (key: string) => {
2353 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2354 return activityUncached({ projectId, repository, number } as PullRequestRef);
2493 return activityUncached(refOfCacheKey(key));
23552494 },
23562495 {
23572496 capacity: DETAIL_CACHE_CAPACITY,
......@@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () {
23652504
23662505 const diffCache = yield* Cache.makeWith(
23672506 (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 [
23692508 number,
23702509 string,
2510 string | null,
23712511 string,
23722512 number,
23732513 string | null,
......@@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () {
23752515 ];
23762516 return diffUncached({
23772517 projectId,
2518 ...(host === null ? {} : { host }),
23782519 repository,
23792520 number,
23802521 ...(cursor === null ? {} : { cursor }),
......@@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () {
23852526 capacity: DIFF_CACHE_CAPACITY,
23862527 timeToLive: (exit, key) => {
23872528 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];
23892530 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;
23902531 },
23912532 },
......@@ -2394,7 +2535,8 @@ export const make = Effect.gen(function* () {
23942535 const key = JSON.stringify([
23952536 refEpoch(input),
23962537 input.projectId,
2397 input.repository,
2538 input.host?.toLowerCase() ?? null,
2539 input.repository.toLowerCase(),
23982540 input.number,
23992541 input.cursor ?? null,
24002542 input.commit ?? null,
......@@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () {
25232665 list,
25242666 listStats,
25252667 summary,
2668 stack,
25262669 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
25272670 Effect.map((subscription) => Stream.fromSubscription(subscription)),
25282671 ),
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+79
......@@ -11,6 +11,7 @@ import {
1111 decodePullRequestListJson,
1212 decodePullRequestNodeIdJson,
1313 decodePullRequestSearchJson,
14 decodePullRequestStacksJson,
1415 decodeLabelCandidatesJson,
1516 decodeRepositoryAccessJson,
1617 decodeReviewerCandidatesJson,
......@@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => {
15001501 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);
15011502 });
15021503});
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(
24452445 omittedFileStats,
24462446 });
24472447}
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", () => {
334334 interactionMode: "default",
335335 branch: null,
336336 worktreePath: null,
337 pullRequests: [],
337338 latestTurn: null,
338339 createdAt: now,
339340 updatedAt: now,
......@@ -477,6 +478,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
477478 interactionMode: "default",
478479 branch: null,
479480 worktreePath: null,
481 pullRequests: [],
480482 latestTurn: {
481483 turnId: "turn-1" as TurnId,
482484 state: "running",
......@@ -668,6 +670,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
668670 interactionMode: "default",
669671 branch: null,
670672 worktreePath: null,
673 pullRequests: [],
671674 latestTurn: {
672675 turnId: "turn-1" as TurnId,
673676 state: "running",
apps/server/src/server.test.ts+10-1
......@@ -117,6 +117,7 @@ import {
117117} from "./orchestration/Errors.ts";
118118import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
119119import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
120import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
120121import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
121122import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts";
122123import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";
......@@ -331,6 +332,7 @@ const makeDefaultOrchestrationReadModel = () => {
331332 runtimeMode: "full-access" as const,
332333 branch: null,
333334 worktreePath: null,
335 pullRequests: [],
334336 createdAt: now,
335337 updatedAt: now,
336338 archivedAt: null,
......@@ -361,6 +363,7 @@ const makeDefaultOrchestrationThreadShell = (
361363 interactionMode: "default",
362364 branch: null,
363365 worktreePath: null,
366 pullRequests: [],
364367 latestTurn: null,
365368 createdAt: now,
366369 updatedAt: now,
......@@ -384,7 +387,7 @@ const browserOtlpTracingLayer = Layer.mergeAll(
384387
385388const makeAuthTestLayer = () =>
386389 EnvironmentAuth.layer.pipe(
387 Layer.provide(SqlitePersistenceMemory),
390 Layer.provideMerge(SqlitePersistenceMemory),
388391 Layer.provide(ServerSecretStore.layer),
389392 Layer.provide(
390393 Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({
......@@ -956,6 +959,11 @@ const buildAppUnderTest = (options?: {
956959 drainThrough: () => Effect.void,
957960 ...options?.layers?.threadDeletionReactor,
958961 }),
962 Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({
963 start: () => Effect.void,
964 drain: Effect.void,
965 requestSync: () => Effect.void,
966 }),
959967 ),
960968 ),
961969 Layer.provide(
......@@ -8104,6 +8112,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
81048112 runtimeMode: "full-access" as const,
81058113 branch: null,
81068114 worktreePath: null,
8115 pullRequests: [],
81078116 createdAt: now,
81088117 updatedAt: now,
81098118 archivedAt: null,
apps/server/src/server.ts+2
......@@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
7171import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
7272import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
7373import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
74import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
7475import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
7576import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
7677import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
......@@ -280,6 +281,7 @@ const ReactorLayerLive = Layer.empty.pipe(
280281 Layer.provideMerge(CheckpointReactorLive),
281282 Layer.provideMerge(ThreadDeletionReactorLive),
282283 Layer.provideMerge(ThreadSettlementReactor.layer),
284 Layer.provideMerge(PullRequestSyncReactor.layer),
283285 Layer.provideMerge(ThreadPullRequestReactor.layer),
284286 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
285287 Layer.provideMerge(RuntimeReceiptBusLive),
apps/server/src/ws.ts+89-8
......@@ -70,6 +70,7 @@ import {
7070 type TerminalError,
7171 type TerminalEvent,
7272 type TerminalMetadataStreamEvent,
73 type PullRequestRef,
7374 WS_METHODS,
7475 WsRpcGroup,
7576} from "@t3tools/contracts";
......@@ -124,6 +125,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
124125import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
125126import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
126127import * as GitWorkflowService from "./git/GitWorkflowService.ts";
128import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
127129import * as ReviewService from "./review/ReviewService.ts";
128130import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
129131import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
......@@ -142,6 +144,10 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
142144import * as UsageService from "./usage/UsageService.ts";
143145import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
144146import * 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";
145151import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
146152import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
147153import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
......@@ -476,7 +482,18 @@ const makeWsRpcLayer = (
476482 Effect.gen(function* () {
477483 const currentSessionId = currentSession.sessionId;
478484 const crypto = yield* Crypto.Crypto;
485 const sql = yield* SqlClient.SqlClient;
479486 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 );
480497 const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService;
481498 const threadDeletionReactor = yield* ThreadDeletionReactor;
482499 const analytics = yield* AnalyticsService.AnalyticsService;
......@@ -604,6 +621,7 @@ const makeWsRpcLayer = (
604621 const sourceControlRepositories =
605622 yield* SourceControlRepositoryService.SourceControlRepositoryService;
606623 const pullRequests = yield* PullRequestService.PullRequestService;
624 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
607625 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
608626 const sessions = yield* SessionStore.SessionStore;
609627 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
......@@ -2133,6 +2151,24 @@ const makeWsRpcLayer = (
21332151 observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), {
21342152 "rpc.aggregate": "pull-requests",
21352153 }),
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 ),
21362172 [WS_METHODS.pullRequestsDetail]: (input) =>
21372173 observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
21382174 "rpc.aggregate": "pull-requests",
......@@ -2156,9 +2192,21 @@ const makeWsRpcLayer = (
21562192 { "rpc.aggregate": "pull-requests" },
21572193 ),
21582194 [WS_METHODS.pullRequestsRunAction]: (input) =>
2159 observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), {
2160 "rpc.aggregate": "pull-requests",
2161 }),
2195 observeRpcEffect(
2196 WS_METHODS.pullRequestsRunAction,
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 ),
21622210 [WS_METHODS.pullRequestsUpdate]: (input) =>
21632211 observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), {
21642212 "rpc.aggregate": "pull-requests",
......@@ -2196,9 +2244,23 @@ const makeWsRpcLayer = (
21962244 "rpc.aggregate": "pull-requests",
21972245 }),
21982246 [WS_METHODS.pullRequestsInvalidate]: (input) =>
2199 observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {
2200 "rpc.aggregate": "pull-requests",
2201 }),
2247 observeRpcEffect(
2248 WS_METHODS.pullRequestsInvalidate,
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 ),
22022264 [WS_METHODS.pullRequestsSubscribeRefreshes]: () =>
22032265 observeRpcStream(
22042266 WS_METHODS.pullRequestsSubscribeRefreshes,
......@@ -2507,8 +2569,25 @@ const makeWsRpcLayer = (
25072569 .pipe(
25082570 Effect.matchCauseEffect({
25092571 onFailure: (cause) => Queue.failCause(queue, cause),
2510 onSuccess: () =>
2511 refreshGitStatus(input.cwd).pipe(
2572 onSuccess: (result) =>
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)),
25122591 Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),
25132592 ),
25142593 }),
......@@ -2932,6 +3011,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
29323011 ),
29333012 });
29343013 const pullRequests = yield* PullRequestService.PullRequestService;
3014 const sql = yield* SqlClient.SqlClient;
29353015 return HttpRouter.add(
29363016 "GET",
29373017 "/ws",
......@@ -2966,6 +3046,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
29663046 previewAutomationBroker,
29673047 ).pipe(
29683048 Layer.provideMerge(RpcSerialization.layerJson),
3049 Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),
29693050 Layer.provide(AgentSessionScanner.layer),
29703051 Layer.provide(ProviderMaintenanceRunner.layer),
29713052 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) => ({
4343vi.mock("../state/entities", () => ({
4444 readThreadShell: () => null,
4545 useProjects: () => [],
46 useServerConfigs: () => new Map(),
4647}));
4748vi.mock("../remoteOpen", () => ({
4849 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
......@@ -52,8 +53,7 @@ vi.mock("../editorPreferences", () => ({
5253 usePreferredEditor: () => [null, vi.fn()],
5354}));
5455vi.mock("~/lib/openPullRequestLink", () => ({
55 findProjectForChangeRequest: () => undefined,
56 matchesLinkedPullRequestUrl: () => false,
56 findProjectOnChangeRequestHost: () => undefined,
5757 parseChangeRequestUrl: () => null,
5858 useOpenChangeRequestLink: () => vi.fn(),
5959}));
apps/web/src/components/ChatMarkdown.tsx+25-50
......@@ -1,3 +1,4 @@
1import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
12import { useAtomValue } from "@effect/atom-react";
23import {
34 CheckIcon,
......@@ -26,7 +27,7 @@ import type {
2627 EnvironmentId,
2728 ScopedThreadRef,
2829 ServerProviderSkill,
29 ThreadLinkedPullRequest,
30 ThreadPullRequestKey,
3031} from "@t3tools/contracts";
3132import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
3233import {
......@@ -155,7 +156,6 @@ import { previewEnvironment } from "../state/preview";
155156import { useAtomCommand } from "../state/use-atom-command";
156157import { useAtomQueryRunner } from "../state/use-atom-query-runner";
157158import { projectEnvironment } from "../state/projects";
158import { threadEnvironment } from "../state/threads";
159159import {
160160 claimWorkspaceBasenameLookup,
161161 needsWorkspaceBasenameLookup,
......@@ -164,7 +164,6 @@ import {
164164} from "../workspaceBasenameLookup";
165165import {
166166 findProjectForChangeRequest,
167 matchesLinkedPullRequestUrl,
168167 parseChangeRequestUrl,
169168 pullRequestCandidateUrlFromReferenceAutolink,
170169 useOpenChangeRequestLink,
......@@ -2185,9 +2184,7 @@ function useChatMarkdownState({
21852184 const openPreview = useAtomCommand(previewEnvironment.open, {
21862185 reportFailure: false,
21872186 });
2188 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
2189 reportFailure: false,
2190 });
2187 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
21912188 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
21922189 const remoteOpen = useRemoteOpenResolution(environmentId);
21932190 const canUseShellActions = canUseMarkdownFileShellActions(
......@@ -2239,9 +2236,6 @@ function useChatMarkdownState({
22392236 [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef],
22402237 );
22412238 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
2242 const threadServerConfig = useAtomValue(
2243 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
2244 );
22452239 const projects = useProjects();
22462240 const availableEditors = serverConfig?.availableEditors ?? [];
22472241 const [preferredEditor] = usePreferredEditor(availableEditors);
......@@ -2331,52 +2325,33 @@ function useChatMarkdownState({
23312325 // makes a persisted "app" apply once settings hydrate after launch.
23322326 const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget);
23332327 const resolveThreadPullRequest = useCallback(
2334 (href: string): ThreadLinkedPullRequest | null => {
2328 (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => {
23352329 if (
23362330 threadRef === undefined ||
23372331 readThreadShell(threadRef) === null ||
2338 threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true
2339 ) {
2332 !pullRequestLinking.canLink(href)
2333 )
23402334 return null;
2341 }
23422335 const parsed = parseChangeRequestUrl(href);
2343 if (parsed === null) return null;
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 };
2336 return parsed === null ? null : { ...parsed, url: href };
23552337 },
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],
23572348 );
23582349 const updateThreadPullRequestLink = useCallback(
23592350 async (href: string, linked: boolean) => {
2360 if (threadRef === undefined) return;
2361 const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null;
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 }
2351 if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return;
2352 await pullRequestLinking.changeLink(threadRef, href, linked);
23782353 },
2379 [resolveThreadPullRequest, threadRef, updateThreadMetadata],
2354 [linkedThreadPullRequestFor, pullRequestLinking, threadRef],
23802355 );
23812356 const openExternalLinkInPreview = useCallback(
23822357 (url: string) => {
......@@ -2588,6 +2563,7 @@ function useChatMarkdownState({
25882563 openExternalLinkInPreview,
25892564 openMarkdownMedia,
25902565 projects,
2566 linkedThreadPullRequestFor,
25912567 resolveThreadPullRequest,
25922568 resolvedTheme,
25932569 serverConfig,
......@@ -2614,6 +2590,7 @@ function useChatMarkdownState({
26142590 openExternalLinkInPreview,
26152591 openMarkdownMedia,
26162592 projects,
2593 linkedThreadPullRequestFor,
26172594 resolveThreadPullRequest,
26182595 resolvedTheme,
26192596 serverConfig,
......@@ -2732,6 +2709,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
27322709 linkTargetPreference,
27332710 openExternalLinkInPreview,
27342711 projects,
2712 linkedThreadPullRequestFor,
27352713 resolveThreadPullRequest,
27362714 serverConfig,
27372715 updateThreadPullRequestLink,
......@@ -2866,13 +2844,10 @@ const CHAT_MARKDOWN_COMPONENTS = {
28662844 event.stopPropagation();
28672845 const api = readLocalApi();
28682846 if (!api) return;
2869 const pullRequest = resolveThreadPullRequest(href);
2870 const currentPullRequest =
2871 threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest;
28722847 const threadLinkAction =
2873 currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href)
2848 linkedThreadPullRequestFor(href) !== null
28742849 ? "unlink-from-thread"
2875 : pullRequest === null
2850 : resolveThreadPullRequest(href) === null
28762851 ? undefined
28772852 : "link-to-thread";
28782853 void showExternalLinkContextMenu({
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+2-2
......@@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({
3232vi.mock("../state/entities", () => ({
3333 readThreadShell: () => null,
3434 useProjects: () => [],
35 useServerConfigs: () => new Map(),
3536}));
3637vi.mock("../remoteOpen", () => ({
3738 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
......@@ -41,8 +42,7 @@ vi.mock("../editorPreferences", () => ({
4142 usePreferredEditor: () => [null, vi.fn()],
4243}));
4344vi.mock("~/lib/openPullRequestLink", () => ({
44 findProjectForChangeRequest: () => undefined,
45 matchesLinkedPullRequestUrl: () => false,
45 findProjectOnChangeRequestHost: () => undefined,
4646 parseChangeRequestUrl: () => null,
4747 useOpenChangeRequestLink: () => vi.fn(),
4848}));
apps/web/src/components/ChatView.logic.test.ts+2
......@@ -726,6 +726,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
726726 proposedPlans: [],
727727 activities: [],
728728 checkpoints: [],
729 pullRequests: [],
729730 createdAt: now,
730731 updatedAt: now,
731732 archivedAt: null,
......@@ -843,6 +844,7 @@ describe("buildLoadingThreadFromShell", () => {
843844 snoozedUntil: null,
844845 snoozedAt: null,
845846 session: null,
847 pullRequests: [],
846848 latestUserMessageAt: now,
847849 hasPendingApprovals: false,
848850 hasPendingUserInput: false,
apps/web/src/components/ChatView.logic.ts+1
......@@ -353,6 +353,7 @@ export function buildLocalDraftThread(
353353 branch: draftThread.branch,
354354 worktreePath: draftThread.worktreePath,
355355 checkpoints: [],
356 pullRequests: [],
356357 activities: [],
357358 proposedPlans: [],
358359 };
apps/web/src/components/ChatView.tsx+30-2
......@@ -203,6 +203,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
203203import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
204204import { RightPanelTabs } from "./RightPanelTabs";
205205import { AgentsPanel } from "./AgentsPanel";
206import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";
207import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel";
206208import {
207209 deriveAgentPanelModel,
208210 foldSubagentActivities,
......@@ -4127,6 +4129,12 @@ export default function ChatView(props: ChatViewProps) {
41274129 if (!activeThreadRef) return;
41284130 useRightPanelStore.getState().open(activeThreadRef, "agents");
41294131 }, [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]);
41304138 const openFileSurface = useCallback(
41314139 (relativePath: string) => {
41324140 if (!activeThreadRef || !activeProject) return;
......@@ -5375,9 +5383,16 @@ export default function ChatView(props: ChatViewProps) {
53755383 : resolveThreadReferenceCopyTarget({
53765384 threadId: activeThreadId,
53775385 openPanelPullRequestUrl,
5386 pullRequests: activeThreadMetadata?.pullRequests,
53785387 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
53795388 }),
5380 [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl],
5389 [
5390 activeThreadId,
5391 isServerThread,
5392 activeThreadMetadata?.pullRequests,
5393 linkedThreadPullRequest?.url,
5394 openPanelPullRequestUrl,
5395 ],
53815396 );
53825397 const copyActiveThreadReference = useCallback(() => {
53835398 const target = activeThreadReferenceCopyTarget;
......@@ -8037,11 +8052,12 @@ export default function ChatView(props: ChatViewProps) {
80378052 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
80388053 // checkable out like it is anywhere else.
80398054 <PullRequestDetailPanel
8040 key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
8055 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
80418056 environmentId={activeThread.environmentId}
80428057 threadRef={activeThreadRef}
80438058 reference={{
80448059 projectId: renderedRightPanelSurface.projectId as ProjectId,
8060 ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}),
80458061 repository: renderedRightPanelSurface.repository,
80468062 number: renderedRightPanelSurface.number,
80478063 }}
......@@ -8062,7 +8078,14 @@ export default function ChatView(props: ChatViewProps) {
80628078 : "page"
80638079 }
80648080 composerDraftTarget={composerDraftTarget}
8081 onBack={
8082 activeThreadRef !== null && supportsThreadPullRequests
8083 ? addPullRequestsSurface
8084 : undefined
8085 }
80658086 />
8087 ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? (
8088 <ThreadPullRequestsPanel threadRef={activeThreadRef} />
80668089 ) : renderedRightPanelSurface?.kind === "agents" ? (
80678090 <AgentsPanel
80688091 model={agentPanelModel}
......@@ -8620,12 +8643,14 @@ export default function ChatView(props: ChatViewProps) {
86208643 onAddDiff={addDiffSurface}
86218644 onAddFiles={addFilesSurface}
86228645 onAddPullRequest={addPullRequestSurface}
8646 onAddPullRequests={addPullRequestsSurface}
86238647 onAddAgents={addAgentsSurface}
86248648 browserAvailable={isPreviewSupportedInRuntime()}
86258649 terminalAvailable={activeProject !== null}
86268650 diffAvailable={isServerThread && isGitRepo}
86278651 filesAvailable={activeProject !== null}
86288652 pullRequestAvailable={pullRequestSurfaceAvailable}
8653 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
86298654 agentsAvailable
86308655 liveAgentCount={agentPanelModel.liveCount}
86318656 >
......@@ -8670,12 +8695,14 @@ export default function ChatView(props: ChatViewProps) {
86708695 onAddDiff={addDiffSurface}
86718696 onAddFiles={addFilesSurface}
86728697 onAddPullRequest={addPullRequestSurface}
8698 onAddPullRequests={addPullRequestsSurface}
86738699 onAddAgents={addAgentsSurface}
86748700 browserAvailable={isPreviewSupportedInRuntime()}
86758701 terminalAvailable={activeProject !== null}
86768702 diffAvailable={isServerThread && isGitRepo}
86778703 filesAvailable={activeProject !== null}
86788704 pullRequestAvailable={pullRequestSurfaceAvailable}
8705 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
86798706 agentsAvailable
86808707 liveAgentCount={agentPanelModel.liveCount}
86818708 >
......@@ -8684,6 +8711,7 @@ export default function ChatView(props: ChatViewProps) {
86848711 </RightPanelSheet>
86858712 ) : null}
86868713
8714 <LinkPullRequestDialogHost />
86878715 {expandedImage && (
86888716 <ExpandedImageDialog
86898717 key={expandedImageKey(expandedImage)}
apps/web/src/components/CommandPalette.logic.test.ts+1
......@@ -149,6 +149,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
149149 branch: null,
150150 worktreePath: null,
151151 checkpoints: [],
152 pullRequests: [],
152153 activities: [],
153154 ...overrides,
154155 };
apps/web/src/components/CommandPalette.tsx+38-1
......@@ -1,5 +1,7 @@
11"use client";
22
3import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
4
35import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
46import {
57 canCreateProjectInEnvironment,
......@@ -43,6 +45,7 @@ import {
4345 FileSearchIcon,
4446 FolderIcon,
4547 FolderPlusIcon,
48 GitPullRequestArrowIcon,
4649 LinkIcon,
4750 MessageSquareIcon,
4851 PaletteIcon,
......@@ -80,7 +83,7 @@ import { sourceControlEnvironment } from "../state/sourceControl";
8083import { useAtomCommand } from "../state/use-atom-command";
8184import { useAtomQueryRunner } from "../state/use-atom-query-runner";
8285import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
83import { useProjects, useThreadShells } from "../state/entities";
86import { useProjects, useServerConfigs, useThreadShells } from "../state/entities";
8487import { useThreadSearch } from "../state/queries";
8588import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
8689import {
......@@ -148,6 +151,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"
148151import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
149152import { ProjectFavicon } from "./ProjectFavicon";
150153import { ProjectFilePicker } from "./files/ProjectFilePicker";
154import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
151155import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
152156import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
153157import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
......@@ -605,12 +609,16 @@ function OpenCommandPaletteDialog(props: {
605609 ? scopeThreadRef(activeThread.environmentId, activeThread.id)
606610 : null;
607611 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef);
612 const activeThreadServerConfig = useServerConfigs().get(
613 activeThread?.environmentId ?? ("" as EnvironmentId),
614 );
608615 const activeThreadReferenceCopyTarget =
609616 referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)
610617 ? null
611618 : resolveThreadReferenceCopyTarget({
612619 threadId: referenceThreadRef.threadId,
613620 openPanelPullRequestUrl,
621 pullRequests: activeThread?.pullRequests,
614622 linkedPullRequestUrl:
615623 activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,
616624 });
......@@ -1591,6 +1599,35 @@ function OpenCommandPaletteDialog(props: {
15911599 });
15921600 }
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
15941631 actionItems.push({
15951632 kind: "action",
15961633 value: "action:open-file-picker",
apps/web/src/components/GitActionsControl.tsx+3
......@@ -1351,6 +1351,9 @@ export default function GitActionsControl({
13511351 ...(commitMessage ? { commitMessage } : {}),
13521352 ...(featureBranch ? { featureBranch } : {}),
13531353 ...(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 } : {}),
13541357 onProgress: applyProgressEvent,
13551358 });
13561359
apps/web/src/components/LegacySidebar.tsx+45-4
......@@ -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";
18import { Spinner } from "~/components/ui/spinner";
29import {
310 ArchiveIcon,
......@@ -465,10 +472,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
465472 });
466473 const linkedPullRequestStatus = useLinkedThreadPullRequest(
467474 thread.environmentId,
468 thread.linkedPullRequest ?? thread.branchPullRequest,
475 thread.linkedPullRequest,
469476 leaseLiveStatus,
477 thread.pullRequests,
478 thread.branchPullRequest,
470479 );
471480 const pr = linkedPullRequestStatus?.pr ?? null;
481 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
482 const currentLinkedPr = supportsMultiplePullRequests
483 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
484 : null;
472485 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
473486 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
474487 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
......@@ -579,17 +592,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
579592 );
580593 const handlePrClick = useCallback(
581594 (event: React.MouseEvent<HTMLAnchorElement>) => {
582 if (!prStatus) return;
595 const url = prStatus?.url ?? currentLinkedPr?.url;
596 if (!url) return;
583597 const openedInRightPanel = openPrLink(
584598 event,
585 prStatus.url,
599 url,
586600 openPullRequestsInRightPanel ? threadRef : undefined,
587601 );
588602 if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) {
589603 navigateToThread(threadRef);
590604 }
591605 },
592 [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef],
606 [
607 isActive,
608 navigateToThread,
609 openPrLink,
610 openPullRequestsInRightPanel,
611 prStatus,
612 currentLinkedPr,
613 threadRef,
614 ],
593615 );
594616 const handleRenameInputRef = useCallback(
595617 (element: HTMLInputElement | null) => {
......@@ -729,6 +751,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
729751 </TooltipPopup>
730752 </Tooltip>
731753 )}
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}
732773 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
733774 {renamingThreadKey === threadKey ? (
734775 <input
apps/web/src/components/RightPanelTabs.test.tsx+48
......@@ -1,9 +1,11 @@
1import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
12import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
23import { renderToStaticMarkup } from "react-dom/server";
34import { describe, expect, it } from "vite-plus/test";
45
56import {
67 RightPanelTabs,
8 resolvePullRequestTabLink,
79 shouldOpenDefaultBrowserProfileFromMenuClick,
810 surfaceShortcutActionForKey,
911 surfaceShortcutTargetsTypingContext,
......@@ -117,6 +119,7 @@ function renderTabs(
117119 onAddBrowserInProfile={() => undefined}
118120 onAddTerminal={() => undefined}
119121 onAddPullRequest={() => undefined}
122 onAddPullRequests={() => undefined}
120123 onAddDiff={() => undefined}
121124 onAddFiles={() => undefined}
122125 onAddAgents={() => undefined}
......@@ -126,6 +129,7 @@ function renderTabs(
126129 diffAvailable={false}
127130 filesAvailable={false}
128131 pullRequestAvailable={false}
132 pullRequestsAvailable={false}
129133 agentsAvailable={false}
130134 >
131135 <div>content</div>
......@@ -278,3 +282,47 @@ describe("tabMuteMenuItem", () => {
278282 });
279283 });
280284});
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 @@
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";
18import type {
29 ContextMenuItem,
310 EnvironmentId,
......@@ -14,6 +21,7 @@ import {
1421 FileDiff,
1522 Files,
1623 GitPullRequest,
24 GitPullRequestArrow,
1725 Globe2,
1826 Plus,
1927 TerminalSquare,
......@@ -104,12 +112,14 @@ interface RightPanelTabsProps {
104112 onAddDiff: () => void;
105113 onAddFiles: () => void;
106114 onAddPullRequest: () => void;
115 onAddPullRequests: () => void;
107116 onAddAgents: () => void;
108117 browserAvailable: boolean;
109118 terminalAvailable: boolean;
110119 diffAvailable: boolean;
111120 filesAvailable: boolean;
112121 pullRequestAvailable: boolean;
122 pullRequestsAvailable: boolean;
113123 agentsAvailable: boolean;
114124 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;
115125 /** Running + waiting subagents; badges the Agents card in the empty state. */
......@@ -139,6 +149,7 @@ const SURFACE_DISABLED_REASONS = {
139149 files: "Files are only available when a project is open.",
140150 diff: "Diff is only available for server threads in Git repositories.",
141151 pullRequest: "This thread's branch has no pull request yet.",
152 pullRequests: "Linked pull requests are only available for server threads.",
142153 agents: "Agents are only available from a thread.",
143154} as const;
144155
......@@ -161,6 +172,7 @@ const SURFACE_UNAVAILABLE_HINTS = {
161172 files: "Available when a project is open.",
162173 diff: "Available for Git repositories.",
163174 pullRequest: "No pull request on this branch yet.",
175 pullRequests: "Available for server threads.",
164176 agents: "Available from a thread.",
165177} as const;
166178
......@@ -298,12 +310,14 @@ function RightPanelEmptyState(props: {
298310 onAddDiff: () => void;
299311 onAddFiles: () => void;
300312 onAddPullRequest: () => void;
313 onAddPullRequests: () => void;
301314 onAddAgents: () => void;
302315 browserAvailable: boolean;
303316 terminalAvailable: boolean;
304317 diffAvailable: boolean;
305318 filesAvailable: boolean;
306319 pullRequestAvailable: boolean;
320 pullRequestsAvailable: boolean;
307321 agentsAvailable: boolean;
308322 liveAgentCount: number;
309323}) {
......@@ -361,6 +375,16 @@ function RightPanelEmptyState(props: {
361375 onClick: props.onAddPullRequest,
362376 badgeCount: 0,
363377 },
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 },
364388 {
365389 label: "Agents",
366390 description: "Follow subagents and workflows.",
......@@ -602,6 +626,8 @@ function surfaceTitle(
602626 );
603627 case "pull-request":
604628 return `#${surface.number}`;
629 case "pull-requests":
630 return "Pull requests";
605631 case "agents":
606632 return "Agents";
607633 case "preview": {
......@@ -683,11 +709,42 @@ function SurfaceIcon({
683709 seed={pullRequestStatusSeeds?.[surface.id]}
684710 />
685711 );
712 case "pull-requests":
713 return <GitPullRequestArrow className="size-3 shrink-0" />;
686714 case "agents":
687715 return <Bot className="size-3 shrink-0" />;
688716 }
689717}
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
691748function PullRequestSurfaceIcon({
692749 surface,
693750 environmentId,
......@@ -699,13 +756,36 @@ function PullRequestSurfaceIcon({
699756}) {
700757 const resolvedEnvironmentId =
701758 (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 =
703772 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
704781 ? null
705782 : pullRequestEnvironment.detail({
706783 environmentId: resolvedEnvironmentId,
707784 input: {
708785 projectId: surface.projectId as ProjectId,
786 ...(capabilities?.threadPullRequests === true && surface.host !== undefined
787 ? { host: surface.host }
788 : {}),
709789 repository: surface.repository,
710790 number: surface.number,
711791 },
......@@ -714,11 +794,15 @@ function PullRequestSurfaceIcon({
714794 // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the
715795 // full detail would flip an open tab to the conflict glyph the moment its read lands.
716796 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 };
718802 if (status === null) {
719803 return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />;
720804 }
721 const presentation = resolvePullRequestState(status);
805 const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft });
722806 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
723807}
724808
......@@ -806,6 +890,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
806890 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
807891 onClick: props.onAddPullRequest,
808892 },
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 },
809901 {
810902 label: "Agents",
811903 icon: Bot,
......@@ -1250,12 +1342,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
12501342 onAddDiff={props.onAddDiff}
12511343 onAddFiles={props.onAddFiles}
12521344 onAddPullRequest={props.onAddPullRequest}
1345 onAddPullRequests={props.onAddPullRequests}
12531346 onAddAgents={props.onAddAgents}
12541347 browserAvailable={props.browserAvailable}
12551348 terminalAvailable={props.terminalAvailable}
12561349 diffAvailable={props.diffAvailable}
12571350 filesAvailable={props.filesAvailable}
12581351 pullRequestAvailable={props.pullRequestAvailable}
1352 pullRequestsAvailable={props.pullRequestsAvailable}
12591353 agentsAvailable={props.agentsAvailable}
12601354 liveAgentCount={props.liveAgentCount}
12611355 />
apps/web/src/components/Sidebar.logic.test.ts+1
......@@ -2133,6 +2133,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
21332133 branch: null,
21342134 worktreePath: null,
21352135 checkpoints: [],
2136 pullRequests: [],
21362137 activities: [],
21372138 ...overrides,
21382139 };
apps/web/src/components/Sidebar.tsx+114-11
......@@ -1,3 +1,9 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton";
3import {
4 resolveThreadCurrentPullRequestLink,
5 visibleThreadPullRequests,
6} from "@t3tools/shared/threadPullRequests";
17import { useAtomValue } from "@effect/atom-react";
28import * as Schema from "effect/Schema";
39import {
......@@ -70,6 +76,7 @@ import {
7076} from "react";
7177import { useParams, useRouter } from "@tanstack/react-router";
7278
79import { useRightPanelStore } from "../rightPanelStore";
7380import {
7481 isAtomCommandInterrupted,
7582 settlePromise,
......@@ -185,8 +192,12 @@ import {
185192import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
186193import { createSidebarListMotion } from "./Sidebar.motion";
187194import {
195 PR_STATE_COLOR_CLASS,
196 ThreadPullRequestBadgeIcon,
197 ThreadPullRequestsMiniList,
188198 ThreadWorktreeIndicator,
189199 prStatusIndicator,
200 resolveThreadPullRequestBadge,
190201 settledPrHoverColorClass,
191202 terminalStatusFromRunningIds,
192203 type TerminalStatusIndicator,
......@@ -209,7 +220,7 @@ import {
209220} from "../providerInstances";
210221import { useThreadRunningTerminalIds } from "../state/terminalSessions";
211222import { stackedThreadToast, toastManager } from "./ui/toast";
212import { Button } from "./ui/button";
223import { Button, InlineButton } from "./ui/button";
213224import { Input } from "./ui/input";
214225import {
215226 Combobox,
......@@ -330,6 +341,7 @@ function SidebarThreadTooltip({
330341 terminalProcessCount: number;
331342}) {
332343 const driverKind = providerEntry?.driverKind ?? null;
344 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
333345 return (
334346 <TooltipPopup
335347 side="right"
......@@ -411,6 +423,11 @@ function SidebarThreadTooltip({
411423 </div>
412424 ) : null}
413425 </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}
414431 </div>
415432 </TooltipPopup>
416433 );
......@@ -1047,8 +1064,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10471064 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
10481065 const linkedPullRequestStatus = useLinkedThreadPullRequest(
10491066 thread.environmentId,
1050 thread.linkedPullRequest ?? thread.branchPullRequest,
1067 thread.linkedPullRequest,
10511068 leaseLiveStatus,
1069 thread.pullRequests,
1070 thread.branchPullRequest,
10521071 );
10531072 const gitStatus = useEnvironmentQuery(
10541073 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
......@@ -1063,6 +1082,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10631082 gitStatus.data,
10641083 );
10651084 const pr = linkedPullRequestStatus?.pr ?? null;
1085 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
1086 const currentLinkedPr = supportsMultiplePullRequests
1087 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
1088 : null;
10661089
10671090 // Same semantics as the legacy sidebar (never-visited counts as read):
10681091 // switching sidebars must not light up every historical thread as unread.
......@@ -1339,17 +1362,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13391362 }, [showSnoozeButton]);
13401363 const handlePrClick = useCallback(
13411364 (event: ReactMouseEvent<HTMLAnchorElement>) => {
1342 if (!pr?.url) return;
1365 const url = pr?.url ?? currentLinkedPr?.url;
1366 if (!url) return;
13431367 const openedInRightPanel = openPrLink(
13441368 event,
1345 pr.url,
1369 url,
13461370 openPullRequestsInRightPanel ? threadRef : undefined,
13471371 );
13481372 if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) {
13491373 onThreadActivate(threadRef);
13501374 }
13511375 },
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 ],
13531385 );
13541386
13551387 // All sidebar rows share one surface model. Live threads used to look
......@@ -1456,10 +1488,43 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14561488 </span>
14571489 );
14581490
1459 // A real link so cmd/ctrl+click and middle-click open the host in the
1460 // browser. A plain click still opens T3's pull request view.
1491 // One badge shape for every thread: the glyph says stack or not, the number is the current
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]);
14611513 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 ? (
14631528 <a
14641529 href={pr.url}
14651530 target="_blank"
......@@ -1469,14 +1534,38 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14691534 className={cn(
14701535 // Sidebar chrome follows the interface font; tabular digits keep the
14711536 // 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",
14731538 variant === "slim" && variantAction === "unsettle"
14741539 ? cn("text-secondary-label transition-colors", settledPrHoverClass)
14751540 : prStatus.colorClass,
14761541 )}
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 }
14781547 >
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}
14801569 </a>
14811570 ) : null;
14821571 const terminalStatusIcon = terminalStatus ? (
......@@ -1589,6 +1678,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15891678 remain visible AND clickable while the row is hovered. Only
15901679 the time/jump label yields to the settle affordance. */}
15911680 {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}
15921688 {sortable?.isDragging ? (
15931689 dragDestination
15941690 ) : (
......@@ -1889,6 +1985,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
18891985 )}
18901986 {terminalStatusIcon}
18911987 {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}
18921995 {diff ? (
18931996 <span className="shrink-0 font-mono">
18941997 <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 @@
1import { ThreadId } from "@t3tools/contracts";
1import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts";
22import { renderToStaticMarkup } from "react-dom/server";
33import { describe, expect, it } from "vite-plus/test";
44
5import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators";
5import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators";
66
77describe("ThreadWorktreeIndicator", () => {
88 it("renders the worktree folder and branch in an accessible label", () => {
......@@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => {
3737 expect(markup).toBe("");
3838 });
3939});
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 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
12import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
23import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
34import {
4 type EnvironmentId,
55 resolveEnvironmentMachineKind,
6 type EnvironmentId,
67 type ThreadLinkedPullRequest,
8 type ThreadPullRequestLink,
79 type VcsStatusResult,
810} 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";
1017import { useMemo } from "react";
18import { cn } from "../lib/utils";
1119import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1220import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
21import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
1322import { useEnvironmentQuery } from "../state/query";
1423import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
1524import { useThreadRunningTerminalIds } from "../state/terminalSessions";
1625import { useUiStateStore } from "../uiStateStore";
1726import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
1827import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
19import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
2028import type { SidebarThreadSummary } from "../types";
2129import { formatWorktreePathForDisplay } from "../worktreeCleanup";
2230import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
31import { pullRequestListLines } from "./pullRequest/pullRequestListLines";
32import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
2333
2434export interface PrStatusIndicator {
2535 label: string;
......@@ -43,42 +53,151 @@ export interface LinkedThreadPullRequestStatus {
4353 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
4454}
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. */
4757export function useLinkedThreadPullRequest(
4858 environmentId: EnvironmentId | null,
4959 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
5060 enabled = true,
61 pullRequests?: ReadonlyArray<ThreadPullRequestLink>,
62 branchPullRequest?: ThreadLinkedPullRequest | null,
5163): 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 }) };
5274 const queried = useEnvironmentQuery(
53 !enabled || environmentId === null || linkedPullRequest == null
75 !enabled || environmentId === null || reference === null
5476 ? null
55 : linkedPullRequestDetailAtom({
56 environmentId,
57 input: {
58 projectId: linkedPullRequest.projectId,
59 repository: linkedPullRequest.repository,
60 number: linkedPullRequest.number,
61 },
62 }),
77 : linkedPullRequestDetailAtom({ environmentId, input: reference }),
6378 ).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(
67146 () =>
68 detail === null
69 ? null
70 : {
71 pr: pullRequestDetailToVcsStatus(detail),
72 sourceControlProvider: {
73 kind: detail.provider,
74 name: detail.provider,
75 baseUrl: "",
76 },
77 },
78 [detail],
147 pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))),
148 [pullRequests],
149 );
150 if (lines.length === 0) return null;
151 return (
152 <ul className="flex flex-col gap-1">
153 {lines.map((line) => {
154 const snapshot = line.link.snapshot;
155 const presentation =
156 snapshot === null
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>
79191 );
80192}
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
82201export function settledPrHoverColorClass(
83202 state: NonNullable<ThreadPr>["state"],
84203 isDraft = false,
......@@ -282,7 +401,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
282401 );
283402 const pullRequest = useLinkedThreadPullRequest(
284403 thread.environmentId,
285 thread.linkedPullRequest ?? thread.branchPullRequest,
404 thread.linkedPullRequest,
405 true,
406 thread.pullRequests,
407 thread.branchPullRequest,
286408 );
287409 const pr = pullRequest?.pr ?? null;
288410 const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider);
......@@ -293,7 +415,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
293415 },
294416 });
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) {
297424 return null;
298425 }
299426
......@@ -316,6 +443,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
316443 </TooltipPopup>
317444 </Tooltip>
318445 ) : null}
446 {pendingLink ? (
447 <GitPullRequestArrowIcon
448 className="size-3 text-muted-foreground"
449 aria-label={`PR #${pendingLink.number}, status pending`}
450 />
451 ) : null}
319452 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
320453 </span>
321454 );
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+1
......@@ -390,6 +390,7 @@ describe("streaming row projection", () => {
390390 runtimeMode: "full-access",
391391 interactionMode: "default",
392392 branch: null,
393 pullRequests: [],
393394 worktreePath: null,
394395 latestTurn: {
395396 ...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";
6666import { readLocalApi } from "~/localApi";
6767import type { ReviewCommentContext } from "~/reviewCommentContext";
6868import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";
69import { useProjects } from "~/state/entities";
69import { useProjects, useServerConfigs } from "~/state/entities";
7070import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
7171import { useEnvironmentQuery } from "~/state/query";
7272import { useLiveRefresh } from "~/hooks/useLiveRefresh";
73import {
74 pullRequestEnvironment,
75 usePullRequestTurnRefresh,
76 useSharedPullRequestSummary,
77} from "~/state/pullRequests";
73import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests";
74import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests";
7875import { useAtomCommand } from "~/state/use-atom-command";
76import { PullRequestStackMap } from "./PullRequestStackMap";
77import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
7978import { vcsEnvironment } from "~/state/vcs";
8079import { formatRelativeTimeLabel } from "~/timestampFormat";
8180import { useUiStateStore } from "~/uiStateStore";
......@@ -452,13 +451,14 @@ function PullRequestBaseFreshnessWarning({
452451export function PullRequestDetailPanel({
453452 environmentId,
454453 threadRef = null,
455 reference,
454 reference: requestedReference,
456455 listEntry = null,
457456 refreshToken: forcedRefreshToken = 0,
458457 onActed,
459458 onClose,
460459 context = "page",
461460 composerDraftTarget,
461 onBack,
462462}: {
463463 environmentId: EnvironmentId;
464464 /**
......@@ -495,14 +495,35 @@ export function PullRequestDetailPanel({
495495 * land here instead of opening a new thread — the branch is already under the reader's feet.
496496 */
497497 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;
498504}) {
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}`;
500520 const matchingListEntry =
501521 listEntry?.projectId === reference.projectId &&
502522 listEntry.repository.toLowerCase() === reference.repository.toLowerCase() &&
503523 listEntry.number === reference.number
504524 ? listEntry
505525 : null;
526 const [threadPickerOpen, setThreadPickerOpen] = useState(false);
506527 const [tab, setTab] = useState<DetailTab>("summary");
507528 const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");
508529 const [codeCommitScope, setCodeCommitScope] = useState<{
......@@ -631,6 +652,11 @@ export function PullRequestDetailPanel({
631652 : {
632653 ...resolvedCoreDetail,
633654 ...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,
634660 closedAt:
635661 sharedSummary.closedAt === undefined
636662 ? resolvedCoreDetail.closedAt
......@@ -702,6 +728,13 @@ export function PullRequestDetailPanel({
702728 const isStackedPullRequest =
703729 detail !== null &&
704730 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;
705738 const activityPending = activityQuery.isPending && activity === null;
706739 const activityError = activity === null ? activityQuery.error : null;
707740 const refreshDetail = useCallback(() => {
......@@ -1379,6 +1412,17 @@ export function PullRequestDetailPanel({
13791412
13801413 return (
13811414 <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}
13821426 <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">
13831427 <div className="ml-4 grid h-7 min-w-0 items-center overflow-hidden">
13841428 <div
......@@ -1393,6 +1437,24 @@ export function PullRequestDetailPanel({
13931437 >
13941438 {detail && statePresentation ? (
13951439 <>
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}
13961458 <Tooltip>
13971459 <TooltipTrigger
13981460 render={
......@@ -1450,6 +1512,25 @@ export function PullRequestDetailPanel({
14501512 >
14511513 {detail && statePresentation ? (
14521514 <>
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}
14531534 <Tooltip>
14541535 <TooltipTrigger
14551536 render={
......@@ -1488,6 +1569,15 @@ export function PullRequestDetailPanel({
14881569 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
14891570 {detail ? (
14901571 <>
1572 {context === "page" ? (
1573 <PullRequestThreadLinks
1574 display="count"
1575 environmentId={environmentId}
1576 reference={reference}
1577 url={detail.url}
1578 threadRef={null}
1579 />
1580 ) : null}
14911581 {/* Checking a pull request out is the reason to open one here at all, so it is a
14921582 button of its own rather than a side effect of asking an agent for something.
14931583 It asks where, because the two answers are not interchangeable: one leaves your
......@@ -1714,6 +1804,17 @@ export function PullRequestDetailPanel({
17141804 <MoreHorizontalIcon className="size-4" />
17151805 </MenuTrigger>
17161806 <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 />
17171818 <MenuItem
17181819 disabled={isInvalidating || detailQuery.isPending}
17191820 onClick={() => void refreshFromHost()}
......@@ -2001,6 +2102,13 @@ export function PullRequestDetailPanel({
20012102 />
20022103 </span>
20032104 </div>
2105 {nativeStack ? (
2106 <PullRequestStackMap
2107 stack={nativeStack}
2108 currentNumber={detail.number}
2109 className="mt-1"
2110 />
2111 ) : null}
20042112 </div>
20052113 ) : null}
20062114 </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({
314314
315315function CommentComposer({
316316 environmentId,
317 reference,
317318 detail,
318319 actionPending,
319320 onCommentAction,
320321 onCommented,
321322}: {
322323 environmentId: EnvironmentId;
324 reference: PullRequestRef;
323325 detail: PullRequestDetailView;
324326 actionPending: boolean;
325327 onCommentAction: (
......@@ -355,9 +357,7 @@ function CommentComposer({
355357 const result = await postComment({
356358 environmentId,
357359 input: {
358 projectId: detail.projectId,
359 repository: detail.repository,
360 number: detail.number,
360 ...reference,
361361 body: trimmed,
362362 },
363363 });
......@@ -996,7 +996,14 @@ export function PullRequestSummaryTab({
996996 {/* Posting is a core capability and remains usable even if the activity read failed. */}
997997 {detail.capabilities.comment && detail.viewerPermissions.comment ? (
998998 <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 ])}
10001007 environmentId={environmentId}
10011008 detail={detail}
10021009 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", () => {
14201420 expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull();
14211421 });
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
14231459 it("shrugs off corrupt storage and no storage at all", () => {
14241460 const storage = makeStorage();
14251461 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 @@
11import * as Schema from "effect/Schema";
2import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
23
34import {
45 PullRequestDetail,
......@@ -1028,6 +1029,7 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo
10281029type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;
10291030
10301031export interface PullRequestDetailSnapshotRef {
1032 readonly host?: string | undefined;
10311033 readonly projectId: string;
10321034 readonly repository: string;
10331035 readonly number: number;
......@@ -1037,7 +1039,9 @@ const pullRequestDetailSnapshotKey = (
10371039 environmentId: string,
10381040 reference: PullRequestDetailSnapshotRef,
10391041) =>
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
10421046const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail);
10431047
......@@ -1056,7 +1060,9 @@ export function readPullRequestDetailSnapshot(
10561060 const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference));
10571061 if (!raw) return null;
10581062 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;
10601066 } catch {
10611067 return null;
10621068 }
......@@ -1090,7 +1096,9 @@ export function resolveDisplayedPullRequestDetail(input: {
10901096 input.cached !== null &&
10911097 input.cached.projectId === input.reference.projectId &&
10921098 input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() &&
1093 input.cached.number === input.reference.number
1099 input.cached.number === input.reference.number &&
1100 (input.reference.host === undefined ||
1101 parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase())
10941102 ) {
10951103 return input.cached;
10961104 }
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 @@
11import { 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
510function comment(id: string, body = id): PendingReviewComment {
611 return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } };
......@@ -36,6 +41,30 @@ describe("pull request review drafts", () => {
3641 });
3742 });
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
3968 it("does not clear a summary revised while submission is in flight", () => {
4069 const store = usePullRequestReviewStore.getState();
4170 store.setSummary("review-a", "Submitted body");
apps/web/src/components/pullRequest/pullRequestReviewStore.ts+11-8
......@@ -6,7 +6,7 @@
66 * hosts that have no pending review of their own. That also means a draft lives only as long
77 * as the tab does, which is why this is deliberately not persisted.
88 */
9import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";
9import type { PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts";
1010import { create } from "zustand";
1111
1212export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string };
......@@ -24,9 +24,14 @@ export function nextPendingReviewCommentId(): string {
2424 return `pending-review-comment-${pendingCommentSequence}`;
2525}
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. */
2828export 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 ]);
3035}
3136
3237interface PullRequestReviewStoreState {
......@@ -81,11 +86,9 @@ export const usePullRequestReviewStore = create<PullRequestReviewStoreState>()((
8186}));
8287
8388/** The comments a pull request's draft holds, stable across renders while it is empty. */
84export function usePendingReviewComments(reference: {
85 readonly projectId: ProjectId;
86 readonly repository: string;
87 readonly number: number;
88}): ReadonlyArray<PendingReviewComment> {
89export function usePendingReviewComments(
90 reference: PullRequestRef,
91): ReadonlyArray<PendingReviewComment> {
8992 return usePullRequestReviewStore(
9093 (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY,
9194 );
apps/web/src/components/ui/button.tsx+21
......@@ -23,6 +23,7 @@ const buttonVariants = cva(
2323 "icon-lg": "size-10 sm:size-9",
2424 "icon-micro":
2525 "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",
2627 "icon-sm": "size-8 sm:size-7",
2728 "icon-xl":
2829 "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) {
8586}
8687
8788export { 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
1111import { useProject } from "../state/entities";
1212import { pullRequestEnvironment } from "../state/pullRequests";
1313import { useEnvironmentQuery } from "../state/query";
14import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests";
1415
1516export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
1617 const surface = useRightPanelStore((state) =>
......@@ -20,6 +21,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
2021 const environmentId = reference?.environmentId
2122 ? EnvironmentId.make(reference.environmentId)
2223 : threadRef?.environmentId;
24 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null);
2325 const project = useProject(
2426 reference && environmentId
2527 ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId))
......@@ -31,6 +33,9 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
3133 environmentId,
3234 input: {
3335 projectId: ProjectId.make(reference.projectId),
36 ...(supportsMultiplePullRequests && reference.host !== undefined
37 ? { host: reference.host }
38 : {}),
3439 repository: reference.repository,
3540 number: reference.number,
3641 },
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";
33import {
44 changeRequestRepositoryUrl,
55 findProjectForChangeRequest,
6 findProjectOnChangeRequestHost,
67 gitHubPullRequestBrowserUrl,
78 matchesLinkedPullRequestUrl,
89 parseChangeRequestUrl,
......@@ -295,6 +296,81 @@ describe("parseChangeRequestUrl", () => {
295296 });
296297});
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
298374describe("findProjectForChangeRequest", () => {
299375 const project = (identity: Record<string, unknown>) =>
300376 ({ id: "p1", repositoryIdentity: identity }) as never;
apps/web/src/lib/openPullRequestLink.ts+76-182
......@@ -1,13 +1,13 @@
1import type {
2 EnvironmentId,
3 RepositoryIdentity,
4 ScopedThreadRef,
5 ThreadLinkedPullRequest,
6} from "@t3tools/contracts";
1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
72import { useNavigate } from "@tanstack/react-router";
83import { type MouseEvent, useCallback } from "react";
94
105import { 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
1212import { useOpenLink } from "../browser/useOpenLink";
1313import { stackedThreadToast, toastManager } from "../components/ui/toast";
......@@ -17,176 +17,14 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1717import { useProjects, useServerConfigs } from "../state/entities";
1818import { usePrimaryEnvironmentId } from "../state/environments";
1919
20/** Builds a GitHub URL that remains available when the pull request API cannot be read. */
21export function gitHubPullRequestBrowserUrl(
22 identity: RepositoryIdentity | null | undefined,
23 repository: string,
24 number: number,
25): string | null {
26 if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null;
27 const repositoryPath = repository.split("/");
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}
20export {
21 parseChangeRequestUrl,
22 type ChangeRequestLink,
23 gitHubPullRequestBrowserUrl,
24 pullRequestCandidateUrlFromReferenceAutolink,
25 matchesLinkedPullRequestUrl,
26 changeRequestRepositoryUrl,
27} from "@t3tools/shared/changeRequestUrl";
19028
19129/**
19230 * Returns a click handler that opens a pull request URL in the system browser.
......@@ -210,6 +48,12 @@ export function findProjectForChangeRequest(
21048 if (!identity) return false;
21149 const kind = identity.provider as SourceControlProviderKind | undefined;
21250 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 }
21357 const repository =
21458 identity.displayName ??
21559 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
......@@ -221,6 +65,36 @@ export function findProjectForChangeRequest(
22165 });
22266}
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
22498/**
22599 * Opens a change request link on the page, and says whether it did. Anything else — another
226100 * organisation's repository, a host nothing here is checked out from, a link that merely looks
......@@ -284,8 +158,25 @@ export function useOpenChangeRequestLink(
284158 Number(right.environmentId === primaryEnvironmentId) -
285159 Number(left.environmentId === primaryEnvironmentId),
286160 );
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);
288174 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);
289180 event.preventDefault();
290181 event.stopPropagation();
291182 if (resolvedPanelRef) {
......@@ -295,9 +186,12 @@ export function useOpenChangeRequestLink(
295186 ? {}
296187 : { environmentId: project.environmentId }),
297188 projectId: project.id,
298 // The identity's own spelling, not the one read out of the URL: the panel asks the
299 // provider for this repository, while matching a link only ever compares lower case.
300 repository: project.repositoryIdentity?.displayName ?? parsed.repository,
189 ...(serverConfigs.get(project.environmentId)?.environment.capabilities
190 .threadPullRequests === true
191 ? { host: parsed.host }
192 : {}),
193 repository,
194 url: targetUrl,
301195 number: parsed.number,
302196 });
303197 if (!resolvedThreadRef) {
......@@ -307,7 +201,7 @@ export function useOpenChangeRequestLink(
307201 ...previous,
308202 involvement: previous.involvement ?? "all",
309203 state: previous.state ?? "all",
310 repository: project.repositoryIdentity?.displayName ?? parsed.repository,
204 repository,
311205 number: parsed.number,
312206 selectedProjectId: project.id,
313207 selectedEnvironmentId: project.environmentId,
......@@ -324,7 +218,7 @@ export function useOpenChangeRequestLink(
324218 // Every state, so the pull request being opened is also in the list behind it whether
325219 // it is open, merged or closed.
326220 state: "all",
327 repository: parsed.repository,
221 repository,
328222 number: parsed.number,
329223 selectedProjectId: project.id,
330224 // 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 {
3434 branch: null,
3535 worktreePath: null,
3636 checkpoints: [],
37 pullRequests: [],
3738 activities: [],
3839 ...overrides,
3940 };
apps/web/src/rightPanelStore.test.ts+12
......@@ -605,6 +605,18 @@ describe("rightPanelStore", () => {
605605 expect(state.surfaces[1]).not.toHaveProperty("url");
606606 });
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
608620 it("keeps one pull request read from two servers as two tabs", () => {
609621 const local = {
610622 environmentId: "local",
apps/web/src/rightPanelStore.ts+12-1
......@@ -26,6 +26,7 @@ const RIGHT_PANEL_KINDS = [
2626 "preview",
2727 "terminal",
2828 "pull-request",
29 "pull-requests",
2930 "agents",
3031] as const;
3132export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
......@@ -68,10 +69,13 @@ export type RightPanelSurface =
6869 */
6970 environmentId?: string;
7071 projectId: string;
72 host?: string;
7173 repository: string;
7274 number: number;
7375 url?: string;
7476 }
77 /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */
78 | { id: "pull-requests"; kind: "pull-requests" }
7579 | { id: "agents"; kind: "agents" };
7680
7781const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
......@@ -124,6 +128,7 @@ interface RightPanelStoreState {
124128 target: {
125129 environmentId?: string;
126130 projectId: string;
131 host?: string;
127132 repository: string;
128133 number: number;
129134 url?: string;
......@@ -169,6 +174,8 @@ const singletonSurface = (
169174 return { id: "diff", kind };
170175 case "files":
171176 return { id: "files", kind };
177 case "pull-requests":
178 return { id: "pull-requests", kind };
172179 case "agents":
173180 return { id: "agents", kind };
174181 }
......@@ -213,6 +220,7 @@ export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-reques
213220export function pullRequestSurfaceId(target: {
214221 environmentId?: string;
215222 projectId: string;
223 host?: string;
216224 repository: string;
217225 number: number;
218226}): PullRequestSurface["id"] {
......@@ -220,12 +228,14 @@ export function pullRequestSurfaceId(target: {
220228 // servers is two tabs rather than one tab that changes its mind about which server it is on.
221229 const scope =
222230 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}`;
224233}
225234
226235export function pullRequestSurface(target: {
227236 environmentId?: string;
228237 projectId: string;
238 host?: string;
229239 repository: string;
230240 number: number;
231241 url?: string;
......@@ -235,6 +245,7 @@ export function pullRequestSurface(target: {
235245 kind: "pull-request",
236246 ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }),
237247 projectId: target.projectId,
248 ...(typeof target.host === "string" ? { host: target.host.toLowerCase() } : {}),
238249 repository: target.repository,
239250 number: target.number,
240251 ...(typeof target.url === "string" ? { url: target.url } : {}),
apps/web/src/routes/_chat.pull-requests.tsx+2
......@@ -1923,12 +1923,14 @@ function PullRequestsRouteView() {
19231923 onAddDiff={() => undefined}
19241924 onAddFiles={() => undefined}
19251925 onAddPullRequest={() => undefined}
1926 onAddPullRequests={() => undefined}
19261927 onAddAgents={() => undefined}
19271928 browserAvailable={false}
19281929 terminalAvailable={false}
19291930 diffAvailable={false}
19301931 filesAvailable={false}
19311932 pullRequestAvailable={false}
1933 pullRequestsAvailable={false}
19321934 agentsAvailable={false}
19331935 liveAgentCount={0}
19341936 pullRequestStatusSeeds={listedPullRequestTabStatuses}
apps/web/src/state/pullRequests.ts+3
......@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import {
33 createLinkedPullRequestSummaryAtomFamily,
44 createPullRequestEnvironmentAtoms,
5 createPullRequestStackAtomFamily,
56} from "@t3tools/client-runtime/state/pull-requests";
67import type {
78 EnvironmentId,
......@@ -58,6 +59,7 @@ export function useSharedPullRequestSummary(
5859 : JSON.stringify([
5960 environmentId,
6061 reference.projectId,
62 reference.host?.toLowerCase() ?? null,
6163 reference.repository.toLowerCase(),
6264 reference.number,
6365 ]);
......@@ -72,6 +74,7 @@ export function useSharedPullRequestSummary(
7274 }, [atom, current, environmentId]);
7375 return newestPullRequestSummary(current, observed);
7476}
77export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime);
7578
7679export interface EnvironmentQueryTarget<Input> {
7780 readonly environmentId: EnvironmentId;
apps/web/src/state/sourceControlActions.ts+2
......@@ -218,6 +218,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) {
218218 commitMessage?: string;
219219 featureBranch?: boolean;
220220 filePaths?: string[];
221 threadId?: ThreadId;
221222 onProgress?: (event: GitActionProgressEvent) => void;
222223 }) => {
223224 if (resolveScope(scope) === null) {
......@@ -237,6 +238,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) {
237238 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
238239 ...(input.featureBranch ? { featureBranch: true } : {}),
239240 ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}),
241 ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
240242 ...(input.onProgress ? { onProgress: input.onProgress } : {}),
241243 });
242244 },
apps/web/src/worktreeCleanup.test.ts+1
......@@ -21,6 +21,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2121 session: null,
2222 messages: [],
2323 checkpoints: [],
24 pullRequests: [],
2425 activities: [],
2526 proposedPlans: [],
2627 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
4545| Checkpoint | A saved workspace state used for diffs and restore, stored as a hidden Git ref. |
4646| Checkpoint baseline | The workspace state captured before the work being compared. |
4747| 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
1818thread does not pay for every thread's history. Authentication of a socket does not authorize every
1919method 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
2140Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands
2241and events, so adding a provider should not require branches throughout the domain or clients.
2342See [provider constraints](./providers.md).
docs/user/source-control.md+24
......@@ -91,3 +91,27 @@ reopening a declined pull request.
9191 remotes can require separate setup from the hosting provider's API access.
9292- **A review cannot load:** open it on the host website while resolving connectivity, permissions,
9393 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 @@
230230 "./work-log/scroll-anchor": {
231231 "types": "./src/work-log/scrollAnchor.ts",
232232 "default": "./src/work-log/scrollAnchor.ts"
233 },
234 "./thread-pull-request-compatibility": {
235 "types": "./src/threadPullRequestCompatibility.ts",
236 "default": "./src/threadPullRequestCompatibility.ts"
233237 }
234238 },
235239 "scripts": {
packages/client-runtime/src/operations/commands.ts+20
......@@ -44,6 +44,8 @@ export type UnpinThreadInput = CommandInput<"thread.unpin">;
4444export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
4545export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
4646export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
47export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">;
48export type UnlinkThreadPullRequestInput = CommandInput<"thread.pull-request.unlink">;
4749export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
4850export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
4951export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
......@@ -252,6 +254,24 @@ export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => Command
252254 });
253255});
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
255275export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn(
256276 "EnvironmentCommands.setThreadRuntimeMode",
257277)(function* (input) {
packages/client-runtime/src/state/entities.test.ts+1
......@@ -99,6 +99,7 @@ const THREAD_SHELL = {
9999 archivedAt: null,
100100 settledOverride: null,
101101 settledAt: null,
102 pullRequests: [],
102103 session: null,
103104 latestUserMessageAt: null,
104105 hasPendingApprovals: false,
packages/client-runtime/src/state/environmentHttpAuth.test.ts+1
......@@ -84,6 +84,7 @@ const THREAD = {
8484 runtimeMode: "full-access",
8585 interactionMode: "default",
8686 branch: null,
87 pullRequests: [],
8788 worktreePath: null,
8889 latestTurn: null,
8990 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 {
4141 };
4242}
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
44131it.effect("refreshes pull request activity after a comment is updated", () =>
45132 Effect.scoped(
46133 Effect.gen(function* () {
......@@ -76,46 +163,10 @@ it.effect("refreshes pull request activity after a comment is updated", () =>
76163 commentBody = input.body;
77164 }),
78165 } as unknown as WsRpcProtocolClient;
79 const connectionState: SupervisorConnectionState = {
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 );
166 const { atoms, registry } = yield* makeTestRuntime(client);
117167 const reference = {
118168 projectId: ProjectId.make("project-1"),
169 host: "github.example.com",
119170 repository: "acme/web",
120171 number: 1,
121172 } as const;
packages/client-runtime/src/state/pullRequests.ts+27-2
......@@ -60,6 +60,18 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(
6060 });
6161}
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
6375export function pullRequestDetailToVcsStatus(
6476 detail: PullRequestDetail | PullRequestSummary,
6577): NonNullable<VcsStatusResult["pr"]> {
......@@ -97,6 +109,13 @@ export function createPullRequestEnvironmentAtoms<R, E>(
97109 });
98110 return {
99111 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 }),
100119 list: createEnvironmentRpcQueryAtomFamily(runtime, {
101120 label: "environment-data:pull-requests:list",
102121 tag: WS_METHODS.pullRequestsList,
......@@ -159,6 +178,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
159178 JSON.stringify([
160179 environmentId,
161180 input.projectId,
181 input.host?.toLowerCase() ?? null,
162182 input.repository,
163183 input.number,
164184 input.commit ?? null,
......@@ -191,9 +211,14 @@ export function createPullRequestEnvironmentAtoms<R, E>(
191211 tag: WS_METHODS.pullRequestsUpdateComment,
192212 scheduler: commandScheduler,
193213 concurrency: serialPerEnvironment,
194 onSuccess: ({ environmentId, input: { projectId, repository, number } }, registry) =>
214 onSuccess: ({ environmentId, input: { projectId, host, repository, number } }, registry) =>
195215 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 ),
197222 ),
198223 }),
199224 submitReview: createEnvironmentRpcCommand(runtime, {
packages/client-runtime/src/state/shellReducer.test.ts+1
......@@ -38,6 +38,7 @@ const stubThread = {
3838 archivedAt: null,
3939 settledOverride: null,
4040 settledAt: null,
41 pullRequests: [],
4142 latestUserMessageAt: null,
4243 hasPendingApprovals: false,
4344 hasPendingUserInput: false,
packages/client-runtime/src/state/threadCommands.ts+18
......@@ -12,6 +12,7 @@ import {
1212 type CreateThreadInput,
1313 type DeleteThreadInput,
1414 type InterruptThreadTurnInput,
15 type LinkThreadPullRequestInput,
1516 type RespondToThreadApprovalInput,
1617 type RespondToThreadUserInputInput,
1718 type DismissThreadUserInputInput,
......@@ -26,6 +27,7 @@ import {
2627 type StartThreadTurnInput,
2728 type StopThreadSessionInput,
2829 type UnarchiveThreadInput,
30 type UnlinkThreadPullRequestInput,
2931 type UnpinThreadInput,
3032 type UnsettleThreadInput,
3133 type UnsnoozeThreadInput,
......@@ -34,6 +36,7 @@ import {
3436 createThread,
3537 deleteThread,
3638 interruptThreadTurn,
39 linkThreadPullRequest,
3740 respondToThreadApproval,
3841 respondToThreadUserInput,
3942 dismissThreadUserInput,
......@@ -48,6 +51,7 @@ import {
4851 startThreadTurn,
4952 stopThreadSession,
5053 unarchiveThread,
54 unlinkThreadPullRequest,
5155 unpinThread,
5256 unsettleThread,
5357 unsnoozeThread,
......@@ -60,6 +64,7 @@ export type {
6064 CreateThreadInput,
6165 DeleteThreadInput,
6266 InterruptThreadTurnInput,
67 LinkThreadPullRequestInput,
6368 RespondToThreadApprovalInput,
6469 RespondToThreadUserInputInput,
6570 DismissThreadUserInputInput,
......@@ -74,6 +79,7 @@ export type {
7479 StartThreadTurnInput,
7580 StopThreadSessionInput,
7681 UnarchiveThreadInput,
82 UnlinkThreadPullRequestInput,
7783 UnpinThreadInput,
7884 UnsettleThreadInput,
7985 UnsnoozeThreadInput,
......@@ -168,6 +174,18 @@ export function createThreadEnvironmentAtoms<R, E>(
168174 scheduler,
169175 concurrency,
170176 }),
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 }),
171189 setRuntimeMode: createEnvironmentCommand(runtime, {
172190 label: "environment-data:commands:thread:set-runtime-mode",
173191 execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input),
packages/client-runtime/src/state/threadReducer.test.ts+119
......@@ -37,6 +37,7 @@ const baseThread: OrchestrationThread = {
3737 archivedAt: null,
3838 settledOverride: null,
3939 settledAt: null,
40 pullRequests: [],
4041 deletedAt: null,
4142 messages: [],
4243 proposedPlans: [],
......@@ -406,6 +407,124 @@ describe("applyThreadDetailEvent", () => {
406407 );
407408 });
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
409528 describe("thread.message-sent", () => {
410529 it("appends a new message", () => {
411530 const result = applyThreadDetailEvent(baseThread, {
packages/client-runtime/src/state/threadReducer.ts+60
......@@ -10,8 +10,10 @@ import type {
1010 OrchestrationSession,
1111 OrchestrationThread,
1212 OrchestrationThreadActivity,
13 ThreadPullRequestLink,
1314 TurnId,
1415} from "@t3tools/contracts";
16import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequests";
1517import { isImportedAgentSessionMessageId } from "@t3tools/contracts";
1618import { compareDateTimeStrings } from "@t3tools/shared/dateTime";
1719
......@@ -20,6 +22,29 @@ export type ThreadDetailReducerResult =
2022 | { readonly kind: "deleted" }
2123 | { 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
2348const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>(
2449 O.mapInput(O.String, (p) => p.createdAt),
2550 O.mapInput(O.String, (p) => p.id),
......@@ -109,6 +134,7 @@ export function applyThreadDetailEvent(
109134 snoozedUntil: null,
110135 snoozedAt: null,
111136 deletedAt: null,
137 pullRequests: [],
112138 messages: [],
113139 proposedPlans: [],
114140 activities: [],
......@@ -253,6 +279,40 @@ export function applyThreadDetailEvent(
253279 },
254280 };
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
256316 case "thread.runtime-mode-set":
257317 return {
258318 kind: "updated",
packages/client-runtime/src/state/threads-atoms.test.ts+1
......@@ -65,6 +65,7 @@ const THREAD: OrchestrationThread = {
6565 runtimeMode: "full-access",
6666 interactionMode: "default",
6767 branch: "main",
68 pullRequests: [],
6869 worktreePath: null,
6970 latestTurn: null,
7071 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 = {
104104 archivedAt: null,
105105 settledOverride: null,
106106 settledAt: null,
107 pullRequests: [],
107108 deletedAt: null,
108109 messages: [RECENT_MESSAGE],
109110 proposedPlans: [],
packages/client-runtime/src/state/threads-sync.test.ts+1
......@@ -75,6 +75,7 @@ const BASE_THREAD: OrchestrationThread = {
7575 archivedAt: null,
7676 settledOverride: null,
7777 settledAt: null,
78 pullRequests: [],
7879 deletedAt: null,
7980 messages: [],
8081 proposedPlans: [],
packages/client-runtime/src/state/vcsAction.test.ts+11-2
......@@ -1,7 +1,9 @@
11import {
22 EnvironmentId,
3 ThreadId,
34 WS_METHODS,
45 type GitActionProgressEvent,
6 type GitRunStackedActionInput,
57 type GitRunStackedActionResult,
68} from "@t3tools/contracts";
79import { describe, expect, it } from "@effect/vitest";
......@@ -589,9 +591,10 @@ describe("vcsActionState", () => {
589591 successfulActionId,
590592 );
591593 const failedTransportActionId = createVcsActionTransportId(targetKey, failedActionId);
594 const rpcInputs = new Array<GitRunStackedActionInput>();
592595 const client = {
593 [WS_METHODS.gitRunStackedAction]: (input: { readonly actionId: string }) =>
594 input.actionId === successfulTransportActionId
596 [WS_METHODS.gitRunStackedAction]: (input: GitRunStackedActionInput) =>
597 (rpcInputs.push(input), input.actionId === successfulTransportActionId)
595598 ? Stream.make(
596599 progress({
597600 kind: "action_finished",
......@@ -652,16 +655,22 @@ describe("vcsActionState", () => {
652655 const state = vcsRefsCacheStateAtom({ environmentId });
653656
654657 expect(registry.get(state).revision).toBe(0);
658 const threadId = ThreadId.make("thread-stacked-action");
655659 const successfulResult = yield* Effect.promise(() =>
656660 manager.runStackedAction(targetKey).run(registry, {
657661 actionId: successfulActionId,
658662 action,
663 threadId,
659664 }),
660665 );
661666
662667 expect(AsyncResult.isSuccess(successfulResult)).toBe(true);
663668 expect(registry.get(state).revision).toBe(1);
664669 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
666675 const failedResult = yield* Effect.promise(() =>
667676 manager.runStackedAction(targetKey).run(registry, {
packages/client-runtime/src/state/vcsAction.ts+4
......@@ -6,6 +6,7 @@ import {
66 type GitRunStackedActionInput,
77 type GitRunStackedActionResult,
88 GitStackedAction,
9 type ThreadId,
910 WS_METHODS,
1011} from "@t3tools/contracts";
1112import * as Cause from "effect/Cause";
......@@ -76,6 +77,8 @@ export interface RunVcsStackedActionInput {
7677 readonly commitMessage?: string;
7778 readonly featureBranch?: boolean;
7879 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;
7982 readonly onProgress?: (event: GitActionProgressEvent) => void;
8083}
8184
......@@ -463,6 +466,7 @@ export function createVcsActionManager<R, E>(
463466 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
464467 ...(input.featureBranch ? { featureBranch: true } : {}),
465468 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
469 ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
466470 };
467471 return consumeVcsActionProgress(
468472 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({
123123 /** Server understands regenerateTitle on thread.meta.update. Absent on
124124 older servers, so clients hide the action instead of sending it. */
125125 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. */
127128 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),
128133 /** The update path clients should offer for this server. Absent on
129134 servers that must be relaunched manually (dev checkouts, Windows
130135 foreground runs, pre-update servers). */
packages/contracts/src/git.ts+2
......@@ -119,6 +119,8 @@ export const GitRunStackedActionInput = Schema.Struct({
119119 filePaths: Schema.optional(
120120 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
121121 ),
122 /** The thread the action runs beside; a pull request it creates is linked to it. */
123 threadId: Schema.optional(ThreadId),
122124});
123125export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
124126
packages/contracts/src/orchestration.test.ts+161-11
......@@ -26,6 +26,7 @@ import {
2626 OrchestrationMessage,
2727 ThreadMessageSentPayload,
2828 ThreadMetaUpdatedPayload,
29 ThreadLinkedPullRequest,
2930 ThreadTurnStartCommand,
3031 ThreadCreatedPayload,
3132 ThreadTurnDiff,
......@@ -682,6 +683,116 @@ it.effect("defaults settled fields when decoding historical thread data", () =>
682683 assert.strictEqual(thread.settledAt, null);
683684 assert.strictEqual(shell.settledOverride, null);
684685 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");
685796 }),
686797);
687798
......@@ -966,25 +1077,64 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () =>
9661077 }),
9671078);
9681079
969it.effect("accepts a linked pull request in thread.meta.update", () =>
1080it.effect("accepts thread.pull-request.link and .unlink commands", () =>
9701081 Effect.gen(function* () {
971 const linkedPullRequest = {
972 projectId: "project-1",
1082 const link = yield* decodeOrchestrationCommand({
1083 type: "thread.pull-request.link",
1084 commandId: "cmd-link-pull-request",
1085 threadId: "thread-1",
1086 host: "github.com",
9731087 repository: "pingdotgg/t3code",
9741088 number: 42,
9751089 url: "https://github.com/pingdotgg/t3code/pull/42",
976 };
977 const parsed = yield* decodeOrchestrationCommand({
978 type: "thread.meta.update",
979 commandId: "cmd-link-pull-request",
1090 source: "manual",
1091 });
1092 assert.strictEqual(link.type, "thread.pull-request.link");
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",
9801101 threadId: "thread-1",
981 linkedPullRequest,
1102 host: "github.com",
1103 repository: "pingdotgg/t3code",
1104 number: 42,
9821105 });
1106 assert.strictEqual(unlink.type, "thread.pull-request.unlink");
1107 }),
1108);
9831109
984 assert.strictEqual(parsed.type, "thread.meta.update");
985 if (parsed.type === "thread.meta.update") {
986 assert.deepStrictEqual(parsed.linkedPullRequest, linkedPullRequest);
1110it.effect("still decodes a persisted thread.meta-updated event carrying linkedPullRequest", () =>
1111 Effect.gen(function* () {
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}.`);
9871136 }
1137 assert.strictEqual(event.payload.linkedPullRequest?.number, 42);
9881138 }),
9891139);
9901140
packages/contracts/src/orchestration.ts+168
......@@ -23,6 +23,13 @@ import {
2323 TurnId,
2424} from "./baseSchemas.ts";
2525import { ProviderInstanceId } from "./providerInstance.ts";
26import {
27 PullRequestActor,
28 PullRequestChecksState,
29 PullRequestMergeability,
30 PullRequestReviewDecision,
31 PullRequestState,
32} from "./pullRequest.ts";
2633
2734export const ORCHESTRATION_WS_METHODS = {
2835 dispatchCommand: "orchestration.dispatchCommand",
......@@ -599,6 +606,11 @@ export const ThreadTitleRegeneration = Schema.Struct({
599606});
600607export 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 */
602614export const ThreadLinkedPullRequest = Schema.Struct({
603615 projectId: ProjectId,
604616 repository: TrimmedNonEmptyString,
......@@ -607,6 +619,80 @@ export const ThreadLinkedPullRequest = Schema.Struct({
607619});
608620export 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
610696export const OrchestrationThread = Schema.Struct({
611697 id: ThreadId,
612698 projectId: ProjectId,
......@@ -619,6 +705,10 @@ export const OrchestrationThread = Schema.Struct({
619705 branch: Schema.NullOr(TrimmedNonEmptyString),
620706 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
621707 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 ),
622712 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
623713 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
624714 createdAt: IsoDateTime,
......@@ -701,6 +791,9 @@ export const OrchestrationThreadShell = Schema.Struct({
701791 branch: Schema.NullOr(TrimmedNonEmptyString),
702792 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
703793 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
794 pullRequests: Schema.Array(ThreadPullRequestLink).pipe(
795 Schema.withDecodingDefault(Effect.succeed([])),
796 ),
704797 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
705798 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
706799 createdAt: IsoDateTime,
......@@ -1050,6 +1143,22 @@ const ThreadMetaUpdateCommand = Schema.Struct({
10501143 ),
10511144);
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
10531162const ThreadRuntimeModeSetCommand = Schema.Struct({
10541163 type: Schema.Literal("thread.runtime-mode.set"),
10551164 commandId: CommandId,
......@@ -1208,6 +1317,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
12081317 ThreadPinReorderCommand,
12091318 ThreadActiveReorderCommand,
12101319 ThreadMetaUpdateCommand,
1320 ThreadPullRequestLinkCommand,
1321 ThreadPullRequestUnlinkCommand,
12111322 ThreadRuntimeModeSetCommand,
12121323 ThreadInteractionModeSetCommand,
12131324 ThreadTurnStartCommand,
......@@ -1238,6 +1349,8 @@ export const ClientOrchestrationCommand = Schema.Union([
12381349 ThreadPinReorderCommand,
12391350 ThreadActiveReorderCommand,
12401351 ThreadMetaUpdateCommand,
1352 ThreadPullRequestLinkCommand,
1353 ThreadPullRequestUnlinkCommand,
12411354 ThreadRuntimeModeSetCommand,
12421355 ThreadInteractionModeSetCommand,
12431356 ClientThreadTurnStartCommand,
......@@ -1354,8 +1467,19 @@ const ThreadPullRequestSyncCommand = Schema.Struct({
13541467 linkedPullRequest: Schema.optional(ThreadLinkedPullRequest),
13551468});
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
13571479const InternalOrchestrationCommand = Schema.Union([
13581480 ThreadAutoSettleCommand,
1481 ThreadPullRequestSyncCommand,
1482 ThreadPullRequestLinkSyncCommand,
13591483 ThreadSessionSetCommand,
13601484 ThreadMessageAssistantDeltaCommand,
13611485 ThreadMessageAssistantCompleteCommand,
......@@ -1366,6 +1490,7 @@ const InternalOrchestrationCommand = Schema.Union([
13661490 ThreadRevertCompleteCommand,
13671491 ThreadTitleRegenerationCompleteCommand,
13681492 ThreadPullRequestSyncCommand,
1493 ThreadPullRequestLinkSyncCommand,
13691494]);
13701495export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
13711496
......@@ -1391,6 +1516,9 @@ export const OrchestrationEventType = Schema.Literals([
13911516 "thread.unpinned",
13921517 "thread.pin-reordered",
13931518 "thread.meta-updated",
1519 "thread.pull-request-linked",
1520 "thread.pull-request-unlinked",
1521 "thread.pull-request-synced",
13941522 "thread.runtime-mode-set",
13951523 "thread.interaction-mode-set",
13961524 "thread.message-sent",
......@@ -1541,11 +1669,36 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
15411669 modelSelection: Schema.optional(ModelSelection),
15421670 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
15431671 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.
15441674 linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
15451675 branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
15461676 updatedAt: IsoDateTime,
15471677});
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
15491702export const ThreadRuntimeModeSetPayload = Schema.Struct({
15501703 threadId: ThreadId,
15511704 runtimeMode: RuntimeMode,
......@@ -1759,6 +1912,21 @@ export const OrchestrationEvent = Schema.Union([
17591912 type: Schema.Literal("thread.meta-updated"),
17601913 payload: ThreadMetaUpdatedPayload,
17611914 }),
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 }),
17621930 Schema.Struct({
17631931 ...EventBaseFields,
17641932 type: Schema.Literal("thread.runtime-mode-set"),
packages/contracts/src/previewAutomation.ts+21-4
......@@ -633,14 +633,31 @@ export const PreviewAutomationResponse = Schema.Struct({
633633});
634634export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;
635635
636const McpCapabilityErrorFields = {
637 environmentId: EnvironmentId,
638 threadId: ThreadId,
639 providerSessionId: TrimmedNonEmptyString,
640 providerInstanceId: ProviderInstanceId,
641};
642
636643export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()(
637644 "PreviewAutomationUnavailableError",
638645 {
639646 capability: Schema.Literal("preview"),
640 environmentId: EnvironmentId,
641 threadId: ThreadId,
642 providerSessionId: TrimmedNonEmptyString,
643 providerInstanceId: ProviderInstanceId,
647 ...McpCapabilityErrorFields,
648 },
649) {
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,
644661 },
645662) {
646663 override get message(): string {
packages/contracts/src/pullRequest.ts+49
......@@ -7,6 +7,7 @@ import {
77 NonNegativeInt,
88 PositiveInt,
99 ProjectId,
10 ThreadId,
1011 TrimmedNonEmptyString,
1112} from "./baseSchemas.ts";
1213import { SourceControlProviderKind } from "./sourceControl.ts";
......@@ -412,6 +413,11 @@ export const PullRequestCapabilities = Schema.Struct({
412413 * what every server before this one was.
413414 */
414415 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),
415421 /**
416422 * The repository's labels can be listed, and one put on a change request or taken off it.
417423 * 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({
615621});
616622export 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 */
618631export const PullRequestRef = Schema.Struct({
619632 projectId: ProjectId,
633 host: Schema.optional(TrimmedNonEmptyString),
620634 repository: TrimmedNonEmptyString,
621635 number: PositiveInt,
622636});
623637export 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
625651/**
626652 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
627653 * status check never loads permissions, repository settings, checks, or base comparison data.
......@@ -641,9 +667,32 @@ export const PullRequestSummary = Schema.Struct({
641667 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
642668 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
643669 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),
644677});
645678export 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
647696/**
648697 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is
649698 * 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 {
115115 PullRequestOperationError,
116116 PullRequestReactionInput,
117117 PullRequestRef,
118 PullRequestStack,
119 PullRequestLinkedThreadsResult,
118120 PullRequestSummary,
119121 PullRequestReviewerCandidateList,
120122 PullRequestReviewerRequestInput,
......@@ -344,6 +346,8 @@ export const WS_METHODS = {
344346 pullRequestsList: "pullRequests.list",
345347 pullRequestsListStats: "pullRequests.listStats",
346348 pullRequestsSummary: "pullRequests.summary",
349 pullRequestsStack: "pullRequests.stack",
350 pullRequestsLinkedThreads: "pullRequests.linkedThreads",
347351 pullRequestsDetail: "pullRequests.detail",
348352 pullRequestsActivity: "pullRequests.activity",
349353 pullRequestsThreadComments: "pullRequests.threadComments",
......@@ -648,6 +652,18 @@ const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, {
648652 error: PullRequestRpcError,
649653});
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
651667const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
652668 payload: PullRequestRef,
653669 success: PullRequestDetail,
......@@ -1221,6 +1237,8 @@ export const WsRpcGroup = RpcGroup.make(
12211237 WsPullRequestsListRpc,
12221238 WsPullRequestsListStatsRpc,
12231239 WsPullRequestsSummaryRpc,
1240 WsPullRequestsStackRpc,
1241 WsPullRequestsLinkedThreadsRpc,
12241242 WsPullRequestsDetailRpc,
12251243 WsPullRequestsActivityRpc,
12261244 WsPullRequestsThreadCommentsRpc,
packages/shared/package.json+8
......@@ -175,6 +175,14 @@
175175 "types": "./src/threadReference.ts",
176176 "import": "./src/threadReference.ts"
177177 },
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 },
178186 "./composerTrigger": {
179187 "types": "./src/composerTrigger.ts",
180188 "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 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 sourceControlRepositorySelector,
45 detectSourceControlProviderFromRemoteUrl,
56 getChangeRequestTerminologyForKind,
67 isSshRemoteUrl,
......@@ -160,3 +161,34 @@ describe("isSshRemoteUrl", () => {
160161 expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false);
161162 });
162163});
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 @@
1import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
1import type {
2 RepositoryIdentity,
3 SourceControlProviderInfo,
4 SourceControlProviderKind,
5} from "@t3tools/contracts";
26
37export interface ChangeRequestPresentation {
48 readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
......@@ -234,3 +238,43 @@ export function detectSourceControlProviderFromRemoteUrl(
234238 baseUrl: toBaseUrl(host),
235239 };
236240}
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 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
12import { describe, expect, it } from "vite-plus/test";
23
34import { 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
517describe("resolveThreadReferenceCopyTarget", () => {
618 it("does not copy another reference while the open panel URL is unavailable", () => {
719 expect(
820 resolveThreadReferenceCopyTarget({
921 threadId: "thread-1",
1022 openPanelPullRequestUrl: null,
23 pullRequests: [crossRepositoryPullRequest],
1124 linkedPullRequestUrl: "https://github.com/t3/pr/12",
1225 }),
1326 ).toBeNull();
......@@ -18,6 +31,7 @@ describe("resolveThreadReferenceCopyTarget", () => {
1831 resolveThreadReferenceCopyTarget({
1932 threadId: "thread-1",
2033 openPanelPullRequestUrl: "https://github.com/t3/pr/14",
34 pullRequests: [crossRepositoryPullRequest],
2135 linkedPullRequestUrl: "https://github.com/t3/pr/12",
2236 }),
2337 ).toMatchObject({
......@@ -27,6 +41,58 @@ describe("resolveThreadReferenceCopyTarget", () => {
2741 });
2842 });
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
3096 it("uses the thread pull request when no panel is open", () => {
3197 expect(
3298 resolveThreadReferenceCopyTarget({
packages/shared/src/threadReference.ts+9-1
......@@ -1,3 +1,7 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
2
3import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts";
4
15export interface ThreadReferenceCopyTarget {
26 readonly kind: "pull-request" | "thread";
37 readonly value: string;
......@@ -10,10 +14,14 @@ export function resolveThreadReferenceCopyTarget(input: {
1014 readonly threadId: string;
1115 /** Undefined means no PR panel; null means its URL is not available yet. */
1216 readonly openPanelPullRequestUrl?: string | null | undefined;
17 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
1318 readonly linkedPullRequestUrl?: string | null;
1419}): ThreadReferenceCopyTarget | null {
1520 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;
1725 return pullRequestUrl
1826 ? {
1927 kind: "pull-request",