| author | |
| committer | |
| log | afb84898be3bf9b83bd4cb98c608b79c433579cc |
| tree | d57590e4e2bbbbef6477cb1f1f1abb2bd412e212 |
| parent | 383cc40f4d5d9f61d47b3caa28fac839a8b9c433 |
| signature | Signed by PGP key B5690EEEBB952194 |
172 files changed, 12057 insertions(+), 908 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+2| ... | ... | @@ -78,6 +78,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch"; |
| 78 | 78 | import IconServer from "@tabler/icons-react-native/IconServer"; |
| 79 | 79 | import IconSettings from "@tabler/icons-react-native/IconSettings"; |
| 80 | 80 | import IconSparkles from "@tabler/icons-react-native/IconSparkles"; |
| 81 | import IconStack2 from "@tabler/icons-react-native/IconStack2"; | |
| 81 | 82 | import IconSun from "@tabler/icons-react-native/IconSun"; |
| 82 | 83 | import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; |
| 83 | 84 | import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; |
| ... | ... | @@ -100,6 +101,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 100 | 101 | "arrow.right.circle": IconArrowRightCircle, |
| 101 | 102 | "arrow.triangle.branch": IconGitBranch, |
| 102 | 103 | "arrow.triangle.pull": IconGitPullRequest, |
| 104 | "square.3.layers.3d": IconStack2, | |
| 103 | 105 | "arrow.turn.left.up": IconArrowBackUp, |
| 104 | 106 | "arrow.up": IconArrowUp, |
| 105 | 107 | "arrow.up.circle": IconArrowUpCircle, |
apps/mobile/src/features/archive/archivedThreadList.test.ts+1| ... | ... | @@ -31,6 +31,7 @@ function makeThread( |
| 31 | 31 | interactionMode: "default", |
| 32 | 32 | branch: null, |
| 33 | 33 | worktreePath: null, |
| 34 | pullRequests: [], | |
| 34 | 35 | latestTurn: null, |
| 35 | 36 | createdAt: "2026-06-01T00:00:00.000Z", |
| 36 | 37 | updatedAt: "2026-06-01T00:00:00.000Z", |
apps/mobile/src/features/home/homeListItems.test.ts+1| ... | ... | @@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { |
| 43 | 43 | interactionMode: "default", |
| 44 | 44 | branch: null, |
| 45 | 45 | worktreePath: null, |
| 46 | pullRequests: [], | |
| 46 | 47 | latestTurn: null, |
| 47 | 48 | createdAt: "2026-06-01T00:00:00.000Z", |
| 48 | 49 | updatedAt: "2026-06-01T00:00:00.000Z", |
apps/mobile/src/features/home/homeThreadList.test.ts+1| ... | ... | @@ -36,6 +36,7 @@ function makeThread( |
| 36 | 36 | interactionMode: "default", |
| 37 | 37 | branch: null, |
| 38 | 38 | worktreePath: null, |
| 39 | pullRequests: [], | |
| 39 | 40 | latestTurn: null, |
| 40 | 41 | createdAt: "2026-06-01T00:00:00.000Z", |
| 41 | 42 | updatedAt: "2026-06-01T00:00:00.000Z", |
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+1| ... | ... | @@ -44,6 +44,7 @@ export function HardwareKeyboardCommandProvider({ |
| 44 | 44 | ? null |
| 45 | 45 | : resolveThreadReferenceCopyTarget({ |
| 46 | 46 | threadId: activeThread?.id ?? activeThreadRef.threadId, |
| 47 | pullRequests: activeThread?.pullRequests, | |
| 47 | 48 | linkedPullRequestUrl: |
| 48 | 49 | (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, |
| 49 | 50 | }), |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+58-1| ... | ... | @@ -4,6 +4,10 @@ import { |
| 4 | 4 | getGitActionDisabledReason, |
| 5 | 5 | requiresDefaultBranchConfirmation, |
| 6 | 6 | } from "@t3tools/client-runtime/state/vcs"; |
| 7 | import { | |
| 8 | resolveThreadPullRequestChains, | |
| 9 | threadPullRequestKeyOf, | |
| 10 | } from "@t3tools/shared/threadPullRequests"; | |
| 7 | 11 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 8 | 12 | import { |
| 9 | 13 | CommonActions, |
| ... | ... | @@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 49 | 53 | const isInspector = presentation === "inspector"; |
| 50 | 54 | const environmentId = EnvironmentId.make(props.route.params.environmentId); |
| 51 | 55 | const threadId = ThreadId.make(props.route.params.threadId); |
| 52 | const { selectedThread } = useThreadSelection(); | |
| 56 | const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection(); | |
| 53 | 57 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 58 | const supportsLinkedPrSnapshots = | |
| 59 | selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true; | |
| 60 | const linkedPrChains = useMemo( | |
| 61 | () => | |
| 62 | resolveThreadPullRequestChains( | |
| 63 | supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [], | |
| 64 | ), | |
| 65 | [selectedThread?.pullRequests, supportsLinkedPrSnapshots], | |
| 66 | ); | |
| 54 | 67 | const gitState = useSelectedThreadGitState(); |
| 55 | 68 | const gitActions = useSelectedThreadGitActions(); |
| 56 | 69 | const theme = useUniwindTheme(); |
| ... | ... | @@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 285 | 298 | /> |
| 286 | 299 | </View> |
| 287 | 300 | |
| 301 | {linkedPrChains.length > 0 ? ( | |
| 302 | <View className="gap-2"> | |
| 303 | <Text className="px-1 text-xs font-t3-bold text-foreground-muted"> | |
| 304 | Linked pull requests | |
| 305 | </Text> | |
| 306 | {linkedPrChains.map((chain) => ( | |
| 307 | <View | |
| 308 | key={threadPullRequestKeyOf(chain.layers[0]!)} | |
| 309 | className="overflow-hidden rounded-2xl border border-border bg-card px-3 py-1" | |
| 310 | > | |
| 311 | {chain.layers.length > 1 ? ( | |
| 312 | <View className="flex-row items-center gap-2 px-1 pt-2 pb-1"> | |
| 313 | <SymbolView | |
| 314 | name="square.3.layers.3d" | |
| 315 | size={14} | |
| 316 | tintColorClassName="accent-foreground-muted" | |
| 317 | /> | |
| 318 | <Text className="text-xs text-foreground-muted"> | |
| 319 | {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs | |
| 320 | · bottom to top | |
| 321 | </Text> | |
| 322 | </View> | |
| 323 | ) : null} | |
| 324 | {chain.layers.map((link, index) => ( | |
| 325 | <View key={threadPullRequestKeyOf(link)}> | |
| 326 | {index > 0 ? <View className="ml-12 h-px bg-border" /> : null} | |
| 327 | <SheetListRow | |
| 328 | icon="arrow.triangle.pull" | |
| 329 | title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`} | |
| 330 | subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}`} | |
| 331 | onPress={() => { | |
| 332 | void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { | |
| 333 | if (!opened) | |
| 334 | Alert.alert("Unable to open PR", "The pull request could not be opened."); | |
| 335 | }); | |
| 336 | }} | |
| 337 | /> | |
| 338 | </View> | |
| 339 | ))} | |
| 340 | </View> | |
| 341 | ))} | |
| 342 | </View> | |
| 343 | ) : null} | |
| 344 | ||
| 288 | 345 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} |
| 289 | 346 | </ScrollView> |
| 290 | 347 | ); |
apps/mobile/src/features/threads/thread-list-items.tsx+25-9| ... | ... | @@ -56,6 +56,7 @@ function pullRequestTintColor( |
| 56 | 56 | return dark ? "#34d399" : "#059669"; |
| 57 | 57 | case "merged": |
| 58 | 58 | return dark ? "#a78bfa" : "#7c3aed"; |
| 59 | case null: | |
| 59 | 60 | case "closed": |
| 60 | 61 | return dark ? "#a1a1aa" : "#71717a"; |
| 61 | 62 | } |
| ... | ... | @@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 618 | 619 | </> |
| 619 | 620 | ) : null} |
| 620 | 621 | {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 | )} | |
| 630 | 646 | <Text |
| 631 | 647 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ |
| 632 | 648 | visuallySelected |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+29-14| ... | ... | @@ -870,20 +870,35 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 870 | 870 | <View className="flex-1" /> |
| 871 | 871 | )} |
| 872 | 872 | {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> | |
| 887 | 902 | ) : null} |
| 888 | 903 | {props.providerInstance ? ( |
| 889 | 904 | <ProviderInstanceIcon |
apps/mobile/src/features/threads/threadListV2.test.ts+1| ... | ... | @@ -47,6 +47,7 @@ function makeThread( |
| 47 | 47 | interactionMode: "default", |
| 48 | 48 | branch: null, |
| 49 | 49 | worktreePath: null, |
| 50 | pullRequests: [], | |
| 50 | 51 | latestTurn: null, |
| 51 | 52 | createdAt: "2026-06-01T00:00:00.000Z", |
| 52 | 53 | updatedAt: "2026-06-01T00:00:00.000Z", |
apps/mobile/src/lib/threadActivity.test.ts+1| ... | ... | @@ -258,6 +258,7 @@ function makeThread( |
| 258 | 258 | interactionMode: "default", |
| 259 | 259 | branch: null, |
| 260 | 260 | worktreePath: null, |
| 261 | pullRequests: [], | |
| 261 | 262 | latestTurn: null, |
| 262 | 263 | createdAt: "2026-04-01T00:00:00.000Z", |
| 263 | 264 | updatedAt: "2026-04-01T00:00:00.000Z", |
apps/mobile/src/state/pending-thread-creation.ts+1| ... | ... | @@ -144,6 +144,7 @@ export function pendingThreadCreationShell( |
| 144 | 144 | runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 145 | 145 | interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, |
| 146 | 146 | branch: creation.branch, |
| 147 | pullRequests: [], | |
| 147 | 148 | worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath, |
| 148 | 149 | linkedPullRequest: null, |
| 149 | 150 | latestTurn: null, |
apps/mobile/src/state/thread-pr-presentation.ts+64-2| ... | ... | @@ -1,11 +1,22 @@ |
| 1 | import type { VcsStatusResult } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 2 | import type { | |
| 3 | ExecutionEnvironmentCapabilities, | |
| 4 | ThreadPullRequestLink, | |
| 5 | VcsStatusResult, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; |
| 3 | 8 | |
| 9 | import { | |
| 10 | resolveThreadCurrentPullRequestLink, | |
| 11 | resolveThreadPullRequestBadge, | |
| 12 | } from "@t3tools/shared/threadPullRequests"; | |
| 13 | ||
| 4 | 14 | export type ThreadPr = NonNullable<VcsStatusResult["pr"]>; |
| 5 | 15 | |
| 6 | 16 | export interface ThreadPrPresentation { |
| 7 | 17 | readonly number: number; |
| 8 | readonly state: ThreadPr["state"]; | |
| 18 | readonly state: ThreadPr["state"] | null; | |
| 19 | readonly kind: "pull-request" | "stack"; | |
| 9 | 20 | readonly isDraft: boolean; |
| 10 | 21 | /** Provider-side last activity, bounding when a terminal state landed. */ |
| 11 | 22 | readonly updatedAt: string | null; |
| ... | ... | @@ -30,6 +41,7 @@ export function presentThreadPr( |
| 30 | 41 | const presentation = resolveChangeRequestPresentation(provider); |
| 31 | 42 | const isDraft = pr.state === "open" && pr.isDraft === true; |
| 32 | 43 | return { |
| 44 | kind: "pull-request", | |
| 33 | 45 | number: pr.number, |
| 34 | 46 | state: pr.state, |
| 35 | 47 | isDraft, |
| ... | ... | @@ -40,3 +52,53 @@ export function presentThreadPr( |
| 40 | 52 | textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], |
| 41 | 53 | }; |
| 42 | 54 | } |
| 55 | ||
| 56 | /** Persisted links render immediately, including links awaiting their first host sync. */ | |
| 57 | export 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. */ | |
| 87 | export function resolveThreadPrSource( | |
| 88 | thread: Pick<EnvironmentThreadShell, "pullRequests" | "linkedPullRequest" | "branchPullRequest">, | |
| 89 | capabilities: | |
| 90 | | Pick<ExecutionEnvironmentCapabilities, "threadPullRequests" | "threadPullRequestLinking"> | |
| 91 | | undefined, | |
| 92 | ) { | |
| 93 | const supportsSnapshots = capabilities?.threadPullRequests === true; | |
| 94 | const linkedPresentation = supportsSnapshots | |
| 95 | ? presentThreadLinkedPullRequests(thread.pullRequests) | |
| 96 | : null; | |
| 97 | const pullRequestRef = | |
| 98 | linkedPresentation !== null | |
| 99 | ? null | |
| 100 | : ((supportsSnapshots | |
| 101 | ? thread.branchPullRequest | |
| 102 | : (thread.linkedPullRequest ?? thread.branchPullRequest)) ?? null); | |
| 103 | return { linkedPresentation, pullRequestRef }; | |
| 104 | } |
apps/mobile/src/state/use-selected-thread-git-actions.ts+2| ... | ... | @@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() { |
| 330 | 330 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), |
| 331 | 331 | ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), |
| 332 | 332 | ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), |
| 333 | // A pull request the action opens is linked to the thread it ran beside. | |
| 334 | threadId: thread.id, | |
| 333 | 335 | }); |
| 334 | 336 | if (AsyncResult.isFailure(result)) { |
| 335 | 337 | return result; |
apps/mobile/src/state/use-thread-pr.test.ts+145-2| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | import type { VcsStatusResult } from "@t3tools/contracts"; | |
| 1 | import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { presentThreadPr } from "./thread-pr-presentation"; | |
| 4 | import { | |
| 5 | presentThreadLinkedPullRequests, | |
| 6 | presentThreadPr, | |
| 7 | resolveThreadPrSource, | |
| 8 | } from "./thread-pr-presentation"; | |
| 5 | 9 | |
| 6 | 10 | const pullRequest: NonNullable<VcsStatusResult["pr"]> = { |
| 7 | 11 | number: 3774, |
| ... | ... | @@ -43,3 +47,142 @@ describe("presentThreadPr", () => { |
| 43 | 47 | }); |
| 44 | 48 | }); |
| 45 | 49 | }); |
| 50 | ||
| 51 | function 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 | ||
| 76 | describe("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 | ||
| 124 | describe("resolveThreadPrSource compatibility", () => { | |
| 125 | const legacyRef = { | |
| 126 | projectId: ProjectId.make("project"), | |
| 127 | repository: "t3tools/t3code", | |
| 128 | number: 1, | |
| 129 | url: "https://github.com/t3tools/t3code/pull/1", | |
| 130 | }; | |
| 131 | const branchRef = { ...legacyRef, number: 2, url: "https://github.com/t3tools/t3code/pull/2" }; | |
| 132 | ||
| 133 | it("polls the legacy reference when only the older linking capability exists", () => { | |
| 134 | expect( | |
| 135 | resolveThreadPrSource( | |
| 136 | { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, | |
| 137 | { threadPullRequestLinking: true }, | |
| 138 | ), | |
| 139 | ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef }); | |
| 140 | }); | |
| 141 | ||
| 142 | it("keeps the legacy reference for a server without either capability", () => { | |
| 143 | expect(resolveThreadPrSource({ pullRequests: [], linkedPullRequest: legacyRef }, {})).toEqual({ | |
| 144 | linkedPresentation: null, | |
| 145 | pullRequestRef: legacyRef, | |
| 146 | }); | |
| 147 | }); | |
| 148 | ||
| 149 | it("ignores stale snapshots after reconnecting to a server without array support", () => { | |
| 150 | expect( | |
| 151 | resolveThreadPrSource( | |
| 152 | { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef }, | |
| 153 | { threadPullRequestLinking: true, threadPullRequests: false }, | |
| 154 | ), | |
| 155 | ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef }); | |
| 156 | }); | |
| 157 | ||
| 158 | it("uses snapshots without polling when both capabilities are advertised", () => { | |
| 159 | expect( | |
| 160 | resolveThreadPrSource( | |
| 161 | { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, | |
| 162 | { threadPullRequestLinking: true, threadPullRequests: true }, | |
| 163 | ), | |
| 164 | ).toMatchObject({ linkedPresentation: { number: 3 }, pullRequestRef: null }); | |
| 165 | }); | |
| 166 | ||
| 167 | it("does not revive a removed modern link from the compatibility reference", () => { | |
| 168 | expect( | |
| 169 | resolveThreadPrSource( | |
| 170 | { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, | |
| 171 | { threadPullRequests: true }, | |
| 172 | ), | |
| 173 | ).toEqual({ linkedPresentation: null, pullRequestRef: branchRef }); | |
| 174 | }); | |
| 175 | ||
| 176 | it("does not poll when a modern link is waiting for its first snapshot", () => { | |
| 177 | expect( | |
| 178 | resolveThreadPrSource( | |
| 179 | { | |
| 180 | pullRequests: [linkedPr(3, { snapshot: null })], | |
| 181 | linkedPullRequest: legacyRef, | |
| 182 | branchPullRequest: branchRef, | |
| 183 | }, | |
| 184 | { threadPullRequests: true }, | |
| 185 | ), | |
| 186 | ).toMatchObject({ linkedPresentation: { number: 3, state: null }, pullRequestRef: null }); | |
| 187 | }); | |
| 188 | }); |
apps/mobile/src/state/use-thread-pr.ts+25-5| ... | ... | @@ -10,8 +10,13 @@ import { useCallback, useEffect, useMemo } from "react"; |
| 10 | 10 | |
| 11 | 11 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 12 | 12 | import { appAtomRegistry } from "./atom-registry"; |
| 13 | import { serverEnvironment } from "./server"; | |
| 13 | 14 | import { useEnvironmentQuery } from "./query"; |
| 14 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; | |
| 15 | import { | |
| 16 | resolveThreadPrSource, | |
| 17 | presentThreadPr, | |
| 18 | type ThreadPrPresentation, | |
| 19 | } from "./thread-pr-presentation"; | |
| 15 | 20 | |
| 16 | 21 | const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); |
| 17 | 22 | const MAX_THREAD_PR_SNAPSHOTS = 500; |
| ... | ... | @@ -35,11 +40,26 @@ export { |
| 35 | 40 | } from "./thread-pr-presentation"; |
| 36 | 41 | |
| 37 | 42 | /** |
| 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. | |
| 40 | 45 | */ |
| 41 | 46 | export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { |
| 42 | const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; | |
| 47 | const supportsLinks = useAtomValue( | |
| 48 | serverEnvironment.configValueAtom(thread.environmentId), | |
| 49 | (config) => config?.environment.capabilities.threadPullRequests === true, | |
| 50 | ); | |
| 51 | const { linkedPresentation, pullRequestRef } = useMemo( | |
| 52 | () => | |
| 53 | resolveThreadPrSource( | |
| 54 | { | |
| 55 | pullRequests: thread.pullRequests, | |
| 56 | linkedPullRequest: thread.linkedPullRequest, | |
| 57 | branchPullRequest: thread.branchPullRequest, | |
| 58 | }, | |
| 59 | { threadPullRequests: supportsLinks }, | |
| 60 | ), | |
| 61 | [thread.pullRequests, thread.linkedPullRequest, thread.branchPullRequest, supportsLinks], | |
| 62 | ); | |
| 43 | 63 | const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); |
| 44 | 64 | const snapshotIdentity = JSON.stringify(pullRequestRef); |
| 45 | 65 | // Select this row's entry so writes for other rows do not re-render it. |
| ... | ... | @@ -101,5 +121,5 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio |
| 101 | 121 | }); |
| 102 | 122 | }, [live, snapshotIdentity, threadKey]); |
| 103 | 123 | |
| 104 | return live === undefined ? snapshot : live; | |
| 124 | return linkedPresentation ?? (live === undefined ? snapshot : live); | |
| 105 | 125 | } |
apps/mobile/src/state/use-thread-selection.ts+1| ... | ... | @@ -64,6 +64,7 @@ function threadDetailToShell( |
| 64 | 64 | branch: thread.branch, |
| 65 | 65 | worktreePath: thread.worktreePath, |
| 66 | 66 | linkedPullRequest: thread.linkedPullRequest ?? null, |
| 67 | pullRequests: thread.pullRequests, | |
| 67 | 68 | branchPullRequest: thread.branchPullRequest ?? null, |
| 68 | 69 | latestTurn: thread.latestTurn, |
| 69 | 70 | createdAt: thread.createdAt, |
apps/server/integration/OrchestrationEngineHarness.integration.ts+8| ... | ... | @@ -67,6 +67,7 @@ import { |
| 67 | 67 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; |
| 68 | 68 | import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; |
| 69 | 69 | import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; |
| 70 | import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts"; | |
| 70 | 71 | import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; |
| 71 | 72 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; |
| 72 | 73 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; |
| ... | ... | @@ -404,6 +405,13 @@ export const makeOrchestrationIntegrationHarness = ( |
| 404 | 405 | drain: Effect.void, |
| 405 | 406 | }), |
| 406 | 407 | ), |
| 408 | Layer.provideMerge( | |
| 409 | Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { | |
| 410 | start: () => Effect.void, | |
| 411 | drain: Effect.void, | |
| 412 | requestSync: () => Effect.void, | |
| 413 | }), | |
| 414 | ), | |
| 407 | 415 | Layer.provideMerge( |
| 408 | 416 | Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { |
| 409 | 417 | publishThread: () => Effect.void, |
apps/server/src/auth/RpcAuthorization.ts+2| ... | ... | @@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 68 | 68 | [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, |
| 69 | 69 | [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, |
| 70 | 70 | [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, |
| 71 | [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, | |
| 72 | [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, | |
| 71 | 73 | [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, |
| 72 | 74 | [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, |
| 73 | 75 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, |
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | ... | @@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 169 | 169 | expect(second.capabilities.usagePriceOverrides).toBe(true); |
| 170 | 170 | expect(second.capabilities.threadActiveReorder).toBe(true); |
| 171 | 171 | expect(second.capabilities.threadTitleRegeneration).toBe(true); |
| 172 | expect(second.capabilities.threadPullRequests).toBe(true); | |
| 172 | 173 | expect(second.capabilities.threadPullRequestLinking).toBe(true); |
| 173 | 174 | expect(second.capabilities.agentActivityPublishing).toBe(false); |
| 174 | 175 | }), |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -230,6 +230,7 @@ export const make = Effect.gen(function* () { |
| 230 | 230 | threadPinReorder: true, |
| 231 | 231 | threadActiveReorder: true, |
| 232 | 232 | threadTitleRegeneration: true, |
| 233 | threadPullRequests: true, | |
| 233 | 234 | threadPullRequestLinking: true, |
| 234 | 235 | environmentIcon: true, |
| 235 | 236 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
apps/server/src/git/linkCreatedPullRequest.test.ts created+230| ... | ... | @@ -0,0 +1,230 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | ProjectId, | |
| 4 | ProviderInstanceId, | |
| 5 | ThreadId, | |
| 6 | type GitRunStackedActionResult, | |
| 7 | type OrchestrationCommand, | |
| 8 | type OrchestrationProjectShell, | |
| 9 | type OrchestrationThreadShell, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { describe, expect, it } from "@effect/vitest"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import * as Ref from "effect/Ref"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | ||
| 18 | import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; | |
| 19 | import { | |
| 20 | OrchestrationEngineService, | |
| 21 | type OrchestrationEngineShape, | |
| 22 | } from "../orchestration/Services/OrchestrationEngine.ts"; | |
| 23 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 24 | import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts"; | |
| 25 | ||
| 26 | const PROJECT_ID = ProjectId.make("project-1"); | |
| 27 | const THREAD_ID = ThreadId.make("thread-1"); | |
| 28 | const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test")); | |
| 29 | ||
| 30 | const 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 | ||
| 52 | const 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 | ||
| 75 | function prResult(pr: GitRunStackedActionResult["pr"]): Pick<GitRunStackedActionResult, "pr"> { | |
| 76 | return { pr }; | |
| 77 | } | |
| 78 | ||
| 79 | const 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 | ||
| 96 | const 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 | ||
| 103 | describe("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 | ||
| 156 | describe("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 @@ |
| 1 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | |
| 2 | import { | |
| 3 | type CommandId, | |
| 4 | pullRequestHostOf, | |
| 5 | type GitRunStackedActionResult, | |
| 6 | type OrchestrationProjectShell, | |
| 7 | type SourceControlProviderKind, | |
| 8 | type ThreadId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 11 | import * as Cause from "effect/Cause"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | ||
| 15 | import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; | |
| 16 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 17 | ||
| 18 | export 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 | */ | |
| 32 | export 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 | */ | |
| 60 | export const linkCreatedPullRequest = <E>(input: { | |
| 61 | readonly threadId: ThreadId; | |
| 62 | readonly result: Pick<GitRunStackedActionResult, "pr">; | |
| 63 | readonly commandId: Effect.Effect<CommandId, E>; | |
| 64 | }): Effect.Effect< | |
| 65 | void, | |
| 66 | never, | |
| 67 | OrchestrationEngine.OrchestrationEngineService | ProjectionSnapshotQuery.ProjectionSnapshotQuery | |
| 68 | > => | |
| 69 | Effect.gen(function* () { | |
| 70 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | |
| 71 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | |
| 72 | const thread = yield* snapshots.getThreadShellById(input.threadId); | |
| 73 | if (Option.isNone(thread)) return; | |
| 74 | const project = Option.getOrUndefined( | |
| 75 | yield* snapshots.getProjectShellById(thread.value.projectId), | |
| 76 | ); | |
| 77 | const key = createdPullRequestKey(input.result, project); | |
| 78 | if (key === null) return; | |
| 79 | const commandId = yield* input.commandId; | |
| 80 | yield* engine | |
| 81 | .dispatch({ | |
| 82 | type: "thread.pull-request.link", | |
| 83 | commandId, | |
| 84 | threadId: input.threadId, | |
| 85 | ...key, | |
| 86 | source: "created", | |
| 87 | }) | |
| 88 | .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void })); | |
| 89 | }).pipe( | |
| 90 | Effect.withSpan("linkCreatedPullRequest"), | |
| 91 | Effect.catchCause((cause) => | |
| 92 | Cause.hasInterruptsOnly(cause) | |
| 93 | ? Effect.failCause(cause as Cause.Cause<never>) | |
| 94 | : Effect.logWarning("failed to link created pull request to thread", { | |
| 95 | threadId: input.threadId, | |
| 96 | }), | |
| 97 | ), | |
| 98 | ); |
apps/server/src/mcp/McpHttpServer.test.ts+47| ... | ... | @@ -6,12 +6,15 @@ import * as Deferred from "effect/Deferred"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | |
| 9 | 10 | import * as Path from "effect/Path"; |
| 10 | 11 | import * as Schema from "effect/Schema"; |
| 11 | 12 | import * as Stream from "effect/Stream"; |
| 12 | 13 | import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; |
| 13 | 14 | import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; |
| 14 | 15 | |
| 16 | import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; | |
| 17 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 15 | 18 | import * as ServerConfig from "../config.ts"; |
| 16 | 19 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 17 | 20 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| ... | ... | @@ -48,6 +51,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( |
| 48 | 51 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), |
| 49 | 52 | Layer.provideMerge(NodeServices.layer), |
| 50 | 53 | ); |
| 54 | const 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 | ); | |
| 51 | 66 | |
| 52 | 67 | const snapshotResult = { |
| 53 | 68 | url: "http://example.test/", |
| ... | ... | @@ -345,6 +360,38 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () => |
| 345 | 360 | ).pipe(Effect.provide(TestLayer)), |
| 346 | 361 | ); |
| 347 | 362 | |
| 363 | it.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 | ||
| 348 | 395 | it.effect("keeps the snapshot text under the agent's output ceiling", () => |
| 349 | 396 | Effect.scoped( |
| 350 | 397 | Effect.gen(function* () { |
apps/server/src/mcp/McpHttpServer.ts+10-1| ... | ... | @@ -28,6 +28,8 @@ import { |
| 28 | 28 | PreviewSnapshotToolkit, |
| 29 | 29 | PreviewStandardToolkit, |
| 30 | 30 | } from "./toolkits/preview/tools.ts"; |
| 31 | import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; | |
| 32 | import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; | |
| 31 | 33 | |
| 32 | 34 | const unauthorized = HttpServerResponse.jsonUnsafe( |
| 33 | 35 | { |
| ... | ... | @@ -437,6 +439,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( |
| 437 | 439 | PreviewSnapshotRegistrationLive, |
| 438 | 440 | ); |
| 439 | 441 | |
| 442 | export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe( | |
| 443 | Layer.provide(PullRequestsToolkitHandlersLive), | |
| 444 | ); | |
| 445 | ||
| 440 | 446 | const McpTransportLive = McpServer.layerHttp({ |
| 441 | 447 | name: "T3 Code", |
| 442 | 448 | version: packageJson.version, |
| ... | ... | @@ -444,4 +450,7 @@ const McpTransportLive = McpServer.layerHttp({ |
| 444 | 450 | protocols: [McpProtocol.v2025_06_18], |
| 445 | 451 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); |
| 446 | 452 | |
| 447 | export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); | |
| 453 | export const layer = Layer.mergeAll( | |
| 454 | PreviewToolkitRegistrationLive, | |
| 455 | PullRequestsToolkitRegistrationLive, | |
| 456 | ).pipe(Layer.provideMerge(McpTransportLive)); |
apps/server/src/mcp/McpInvocationContext.test.ts+27| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | 2 | import { |
| 3 | 3 | EnvironmentId, |
| 4 | McpCapabilityUnavailableError, | |
| 4 | 5 | PreviewAutomationUnavailableError, |
| 5 | 6 | ProviderInstanceId, |
| 6 | 7 | ThreadId, |
| ... | ... | @@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav |
| 36 | 37 | expect(error.message).toBe("MCP credential does not grant the preview capability."); |
| 37 | 38 | }); |
| 38 | 39 | }); |
| 40 | ||
| 41 | it.effect("reports other missing capabilities with the neutral error", () => { | |
| 42 | const invocation: McpInvocationContext.McpInvocationScope = { | |
| 43 | environmentId: EnvironmentId.make("environment-1"), | |
| 44 | threadId: ThreadId.make("thread-1"), | |
| 45 | providerSessionId: "provider-session-1", | |
| 46 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 47 | capabilities: new Set(["preview"]), | |
| 48 | issuedAt: 1, | |
| 49 | }; | |
| 50 | ||
| 51 | return Effect.gen(function* () { | |
| 52 | const error = yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe( | |
| 53 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 54 | Effect.flip, | |
| 55 | ); | |
| 56 | ||
| 57 | expect(error).toBeInstanceOf(McpCapabilityUnavailableError); | |
| 58 | expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId }); | |
| 59 | ||
| 60 | const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe( | |
| 61 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 62 | ); | |
| 63 | expect(scope).toBe(invocation); | |
| 64 | }); | |
| 65 | }); |
apps/server/src/mcp/McpInvocationContext.ts+30-15| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | type EnvironmentId, |
| 3 | McpCapabilityUnavailableError, | |
| 3 | 4 | PreviewAutomationUnavailableError, |
| 4 | 5 | type ProviderInstanceId, |
| 5 | 6 | type ThreadId, |
| ... | ... | @@ -7,7 +8,7 @@ import { |
| 7 | 8 | import * as Context from "effect/Context"; |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 9 | 10 | |
| 10 | export type McpCapability = "preview"; | |
| 11 | export type McpCapability = "preview" | "pull-requests"; | |
| 11 | 12 | |
| 12 | 13 | export interface McpInvocationScope { |
| 13 | 14 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service< |
| 23 | 24 | McpInvocationScope |
| 24 | 25 | >()("t3/mcp/McpInvocationContext") {} |
| 25 | 26 | |
| 26 | export 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. */ | |
| 28 | export type McpCapabilityError<C extends McpCapability> = C extends "preview" | |
| 29 | ? PreviewAutomationUnavailableError | |
| 30 | : McpCapabilityUnavailableError; | |
| 31 | ||
| 32 | const missingCapability = ( | |
| 33 | invocation: McpInvocationScope, | |
| 27 | 34 | 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 | ||
| 47 | export const requireMcpCapability = <const C extends McpCapability>( | |
| 48 | capability: C, | |
| 49 | ): Effect.Effect<McpInvocationScope, McpCapabilityError<C>, McpInvocationContext> => | |
| 50 | Effect.flatMap(McpInvocationContext, (invocation) => | |
| 51 | invocation.capabilities.has(capability) | |
| 52 | ? Effect.succeed(invocation) | |
| 53 | : // The conditional type narrows what the literal argument decided at runtime. | |
| 54 | Effect.fail(missingCapability(invocation, capability) as McpCapabilityError<C>), | |
| 55 | ).pipe(Effect.withSpan("mcp.requireCapability")); |
apps/server/src/mcp/McpProviderSession.ts+2| ... | ... | @@ -7,6 +7,8 @@ export interface McpProviderSessionConfig { |
| 7 | 7 | readonly providerInstanceId: ProviderInstanceId; |
| 8 | 8 | readonly endpoint: string; |
| 9 | 9 | readonly authorizationHeader: string; |
| 10 | /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */ | |
| 11 | readonly preview: boolean; | |
| 10 | 12 | } |
| 11 | 13 | |
| 12 | 14 | const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>(); |
apps/server/src/mcp/McpSessionRegistry.test.ts+28| ... | ... | @@ -39,6 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t |
| 39 | 39 | const issued = yield* registry.issue({ |
| 40 | 40 | threadId, |
| 41 | 41 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 42 | preview: true, | |
| 42 | 43 | }); |
| 43 | 44 | expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); |
| 44 | 45 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); |
| ... | ... | @@ -54,6 +55,29 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t |
| 54 | 55 | }), |
| 55 | 56 | ); |
| 56 | 57 | |
| 58 | it.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 | ||
| 57 | 81 | it.effect("builds MCP endpoints from the bound server host", () => |
| 58 | 82 | Effect.gen(function* () { |
| 59 | 83 | const cases = [ |
| ... | ... | @@ -68,6 +92,7 @@ it.effect("builds MCP endpoints from the bound server host", () => |
| 68 | 92 | const issued = yield* registry.issue({ |
| 69 | 93 | threadId: ThreadId.make(`thread-${hostname}`), |
| 70 | 94 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 95 | preview: true, | |
| 71 | 96 | }); |
| 72 | 97 | expect(issued.config.endpoint).toBe(expectedEndpoint); |
| 73 | 98 | } |
| ... | ... | @@ -81,6 +106,7 @@ it.effect("expires credentials once their session stops showing signs of life", |
| 81 | 106 | const issued = yield* registry.issue({ |
| 82 | 107 | threadId: ThreadId.make("thread-2"), |
| 83 | 108 | providerInstanceId: ProviderInstanceId.make("claude"), |
| 109 | preview: true, | |
| 84 | 110 | }); |
| 85 | 111 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); |
| 86 | 112 | timestamp += 101; |
| ... | ... | @@ -96,6 +122,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", |
| 96 | 122 | const issued = yield* registry.issue({ |
| 97 | 123 | threadId, |
| 98 | 124 | providerInstanceId: ProviderInstanceId.make("claude"), |
| 125 | preview: true, | |
| 99 | 126 | }); |
| 100 | 127 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); |
| 101 | 128 | |
| ... | ... | @@ -117,6 +144,7 @@ it.effect("does not keep credentials of other threads alive", () => |
| 117 | 144 | const issued = yield* registry.issue({ |
| 118 | 145 | threadId: ThreadId.make("thread-4"), |
| 119 | 146 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 147 | preview: true, | |
| 120 | 148 | }); |
| 121 | 149 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); |
| 122 | 150 |
apps/server/src/mcp/McpSessionRegistry.ts+10-2| ... | ... | @@ -14,6 +14,11 @@ import * as McpProviderSession from "./McpProviderSession.ts"; |
| 14 | 14 | export interface McpCredentialRequest { |
| 15 | 15 | readonly threadId: ThreadId; |
| 16 | 16 | readonly providerInstanceId: ProviderInstanceId; |
| 17 | /** | |
| 18 | * Whether the credential may drive the user's browser. The pull request | |
| 19 | * toolkit is always granted: it only touches the thread's own links. | |
| 20 | */ | |
| 21 | readonly preview: boolean; | |
| 17 | 22 | } |
| 18 | 23 | |
| 19 | 24 | export interface McpIssuedCredential { |
| ... | ... | @@ -68,7 +73,7 @@ export interface McpSessionRegistryOptions { |
| 68 | 73 | * |
| 69 | 74 | * The bound matters because `/mcp` is mounted outside the environment auth |
| 70 | 75 | * stack and is reachable on whatever host the server binds to, so this token is |
| 71 | * the only thing guarding the preview toolkit on a remote-reachable server. | |
| 76 | * the only thing guarding the `t3-code` toolkits on a remote-reachable server. | |
| 72 | 77 | */ |
| 73 | 78 | const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000; |
| 74 | 79 | |
| ... | ... | @@ -128,7 +133,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 128 | 133 | threadId: ThreadId.make(request.threadId), |
| 129 | 134 | providerSessionId, |
| 130 | 135 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), |
| 131 | capabilities: new Set(["preview"]), | |
| 136 | capabilities: new Set<McpInvocationContext.McpCapability>( | |
| 137 | request.preview ? ["pull-requests", "preview"] : ["pull-requests"], | |
| 138 | ), | |
| 132 | 139 | issuedAt, |
| 133 | 140 | }; |
| 134 | 141 | yield* SynchronizedRef.update(state, ({ records }) => { |
| ... | ... | @@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 144 | 151 | providerInstanceId: scope.providerInstanceId, |
| 145 | 152 | endpoint, |
| 146 | 153 | authorizationHeader: `Bearer ${rawToken}`, |
| 154 | preview: request.preview, | |
| 147 | 155 | }, |
| 148 | 156 | }; |
| 149 | 157 | }, |
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts created+418| ... | ... | @@ -0,0 +1,418 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | ProjectId, | |
| 4 | ProviderInstanceId, | |
| 5 | ThreadId, | |
| 6 | type OrchestrationCommand, | |
| 7 | type OrchestrationProjectShell, | |
| 8 | type OrchestrationThreadShell, | |
| 9 | type ThreadPullRequestLink, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { describe, expect, it } from "@effect/vitest"; | |
| 12 | import * as Crypto from "effect/Crypto"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import * as Ref from "effect/Ref"; | |
| 17 | import * as Stream from "effect/Stream"; | |
| 18 | import type { Tool } from "effect/unstable/ai"; | |
| 19 | ||
| 20 | import { OrchestrationCommandInvariantError } from "../../../orchestration/Errors.ts"; | |
| 21 | import { | |
| 22 | OrchestrationEngineService, | |
| 23 | type OrchestrationEngineShape, | |
| 24 | } from "../../../orchestration/Services/OrchestrationEngine.ts"; | |
| 25 | import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 26 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 27 | import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts"; | |
| 28 | import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; | |
| 29 | ||
| 30 | const PROJECT_ID = ProjectId.make("project-1"); | |
| 31 | const THREAD_ID = ThreadId.make("thread-1"); | |
| 32 | ||
| 33 | const testCrypto = Crypto.make({ | |
| 34 | randomBytes: (size) => new Uint8Array(size).fill(7), | |
| 35 | digest: (_algorithm, data) => Effect.succeed(data), | |
| 36 | }); | |
| 37 | ||
| 38 | const 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 | ||
| 49 | function 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 | ||
| 75 | function 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 | ||
| 100 | function 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 | ||
| 132 | interface HarnessOptions { | |
| 133 | readonly thread?: OrchestrationThreadShell | null; | |
| 134 | readonly project?: OrchestrationProjectShell | null; | |
| 135 | readonly reject?: (command: OrchestrationCommand) => OrchestrationCommandInvariantError | null; | |
| 136 | } | |
| 137 | ||
| 138 | const 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 | ||
| 186 | describe("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 | ||
| 386 | describe("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 | ||
| 413 | it("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 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | pullRequestHostOf, | |
| 4 | type OrchestrationProjectShell, | |
| 5 | type OrchestrationThreadShell, | |
| 6 | type SourceControlProviderKind, | |
| 7 | type ThreadId, | |
| 8 | type ThreadPullRequestLink, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 11 | import { | |
| 12 | resolveThreadPullRequestChains, | |
| 13 | threadPullRequestKeyOf, | |
| 14 | visibleThreadPullRequests, | |
| 15 | } from "@t3tools/shared/threadPullRequests"; | |
| 16 | import * as Cause from "effect/Cause"; | |
| 17 | import * as Crypto from "effect/Crypto"; | |
| 18 | import * as Effect from "effect/Effect"; | |
| 19 | import * as Option from "effect/Option"; | |
| 20 | ||
| 21 | import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; | |
| 22 | import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 23 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 24 | import { | |
| 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 | ||
| 38 | interface 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. */ | |
| 46 | function 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 | */ | |
| 65 | const 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 | ||
| 96 | function 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. */ | |
| 126 | export 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 | ||
| 141 | const 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 | ||
| 243 | export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer(make); |
apps/server/src/mcp/toolkits/pullRequests/tools.ts created+231| ... | ... | @@ -0,0 +1,231 @@ |
| 1 | import { | |
| 2 | McpCapabilityUnavailableError, | |
| 3 | PositiveInt, | |
| 4 | PullRequestState, | |
| 5 | ThreadPullRequestLinkSource, | |
| 6 | TrimmedNonEmptyString, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as Tool from "effect/unstable/ai/Tool"; | |
| 10 | import * as Toolkit from "effect/unstable/ai/Toolkit"; | |
| 11 | ||
| 12 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 13 | import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; | |
| 14 | import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 15 | ||
| 16 | const dependencies = [ | |
| 17 | McpInvocationContext.McpInvocationContext, | |
| 18 | OrchestrationEngine.OrchestrationEngineService, | |
| 19 | ProjectionSnapshotQuery.ProjectionSnapshotQuery, | |
| 20 | ]; | |
| 21 | ||
| 22 | const 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 | */ | |
| 30 | export 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 | }); | |
| 55 | export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; | |
| 56 | ||
| 57 | export 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 | ||
| 66 | export 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 | ||
| 75 | export 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 | ||
| 84 | export 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 | ||
| 93 | export 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 | ||
| 102 | export 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 | ||
| 111 | export 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 | ||
| 120 | export const PullRequestToolError = Schema.Union([ | |
| 121 | McpCapabilityUnavailableError, | |
| 122 | PullRequestUrlInvalidError, | |
| 123 | PullRequestTargetIncompleteError, | |
| 124 | PullRequestHostRequiredError, | |
| 125 | PullRequestThreadNotFoundError, | |
| 126 | PullRequestLinkFailedError, | |
| 127 | PullRequestUnlinkFailedError, | |
| 128 | PullRequestListFailedError, | |
| 129 | ]); | |
| 130 | export type PullRequestToolError = typeof PullRequestToolError.Type; | |
| 131 | ||
| 132 | const PullRequestIdentity = { | |
| 133 | host: Schema.String, | |
| 134 | repository: Schema.String, | |
| 135 | number: Schema.Int, | |
| 136 | url: Schema.String, | |
| 137 | }; | |
| 138 | ||
| 139 | export 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 | }); | |
| 145 | export type LinkPullRequestResult = typeof LinkPullRequestResult.Type; | |
| 146 | ||
| 147 | export 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 | }); | |
| 155 | export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; | |
| 156 | ||
| 157 | export 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 | }); | |
| 174 | export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type; | |
| 175 | ||
| 176 | export 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 | }); | |
| 186 | export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; | |
| 187 | ||
| 188 | const 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 | ||
| 201 | const 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 | ||
| 215 | const 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 | ||
| 227 | export const PullRequestsToolkit = Toolkit.make( | |
| 228 | LinkPullRequestTool, | |
| 229 | UnlinkPullRequestTool, | |
| 230 | ListThreadPullRequestsTool, | |
| 231 | ); |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+35-5| ... | ... | @@ -60,7 +60,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); |
| 60 | 60 | const asTurnId = (value: string): TurnId => TurnId.make(value); |
| 61 | 61 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); |
| 62 | 62 | |
| 63 | function makeOrchestrationLayer(databasePath?: string) { | |
| 63 | function makeOrchestrationLayer( | |
| 64 | databasePath?: string, | |
| 65 | repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], | |
| 66 | ) { | |
| 64 | 67 | const persistence = databasePath |
| 65 | 68 | ? makeSqlitePersistenceLive(databasePath) |
| 66 | 69 | : SqlitePersistenceMemory; |
| ... | ... | @@ -78,15 +81,27 @@ function makeOrchestrationLayer(databasePath?: string) { |
| 78 | 81 | Layer.provide(ThreadPlanProgress.layer), |
| 79 | 82 | Layer.provide(OrchestrationEventStoreLive), |
| 80 | 83 | Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), |
| 81 | Layer.provide(RepositoryIdentityResolver.layer), | |
| 84 | Layer.provide( | |
| 85 | repositoryIdentityResolver | |
| 86 | ? Layer.succeed( | |
| 87 | RepositoryIdentityResolver.RepositoryIdentityResolver, | |
| 88 | repositoryIdentityResolver, | |
| 89 | ) | |
| 90 | : RepositoryIdentityResolver.layer, | |
| 91 | ), | |
| 82 | 92 | Layer.provide(persistence), |
| 83 | 93 | Layer.provideMerge(ServerConfigLayer), |
| 84 | 94 | Layer.provideMerge(NodeServices.layer), |
| 85 | 95 | ); |
| 86 | 96 | } |
| 87 | 97 | |
| 88 | async function createOrchestrationSystem(databasePath?: string) { | |
| 89 | const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath)); | |
| 98 | async function createOrchestrationSystem( | |
| 99 | databasePath?: string, | |
| 100 | repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], | |
| 101 | ) { | |
| 102 | const runtime = ManagedRuntime.make( | |
| 103 | makeOrchestrationLayer(databasePath, repositoryIdentityResolver), | |
| 104 | ); | |
| 90 | 105 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| 91 | 106 | const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); |
| 92 | 107 | return { |
| ... | ... | @@ -373,6 +388,7 @@ describe("OrchestrationEngine", () => { |
| 373 | 388 | runtimeMode: "full-access" as const, |
| 374 | 389 | branch: null, |
| 375 | 390 | worktreePath: null, |
| 391 | pullRequests: [], | |
| 376 | 392 | latestTurn: null, |
| 377 | 393 | createdAt: "2026-03-03T00:00:02.000Z", |
| 378 | 394 | updatedAt: "2026-03-03T00:00:03.000Z", |
| ... | ... | @@ -1009,7 +1025,20 @@ describe("OrchestrationEngine", () => { |
| 1009 | 1025 | it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)( |
| 1010 | 1026 | "rejects PR discovery completed after a newer %s command", |
| 1011 | 1027 | async (change) => { |
| 1012 | const system = await createOrchestrationSystem(); | |
| 1028 | const system = await createOrchestrationSystem(undefined, { | |
| 1029 | resolve: (workspaceRoot) => | |
| 1030 | Effect.succeed({ | |
| 1031 | canonicalKey: "example.test/owner/repository", | |
| 1032 | provider: "github", | |
| 1033 | displayName: "owner/repository", | |
| 1034 | rootPath: workspaceRoot, | |
| 1035 | locator: { | |
| 1036 | source: "git-remote", | |
| 1037 | remoteName: "origin", | |
| 1038 | remoteUrl: "https://example.test/owner/repository.git", | |
| 1039 | }, | |
| 1040 | }), | |
| 1041 | }); | |
| 1013 | 1042 | try { |
| 1014 | 1043 | const projectId = ProjectId.make("pr-race-project"); |
| 1015 | 1044 | const threadId = ThreadId.make("pr-race-thread"); |
| ... | ... | @@ -1058,6 +1087,7 @@ describe("OrchestrationEngine", () => { |
| 1058 | 1087 | linkedPullRequest: previous, |
| 1059 | 1088 | }), |
| 1060 | 1089 | ); |
| 1090 | expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous); | |
| 1061 | 1091 | const metadataChanges = { |
| 1062 | 1092 | unlink: { linkedPullRequest: null }, |
| 1063 | 1093 | relink: { |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+12| ... | ... | @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; |
| 13 | import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; | |
| 13 | 14 | import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; |
| 14 | 15 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 15 | 16 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| ... | ... | @@ -84,6 +85,16 @@ describe("OrchestrationReactor", () => { |
| 84 | 85 | drain: Effect.void, |
| 85 | 86 | }), |
| 86 | 87 | ), |
| 88 | Layer.provideMerge( | |
| 89 | Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { | |
| 90 | start: () => { | |
| 91 | started.push("pull-request-sync-reactor"); | |
| 92 | return Effect.void; | |
| 93 | }, | |
| 94 | drain: Effect.void, | |
| 95 | requestSync: () => Effect.void, | |
| 96 | }), | |
| 97 | ), | |
| 87 | 98 | Layer.provideMerge( |
| 88 | 99 | Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { |
| 89 | 100 | publishThread: () => Effect.void, |
| ... | ... | @@ -107,6 +118,7 @@ describe("OrchestrationReactor", () => { |
| 107 | 118 | "thread-deletion-reactor", |
| 108 | 119 | "thread-pull-request-reactor", |
| 109 | 120 | "thread-settlement-reactor", |
| 121 | "pull-request-sync-reactor", | |
| 110 | 122 | "agent-awareness-relay", |
| 111 | 123 | ]); |
| 112 | 124 |
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3| ... | ... | @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | 12 | import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; |
| 13 | import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; | |
| 13 | 14 | import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; |
| 14 | 15 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| 15 | 16 | |
| ... | ... | @@ -19,6 +20,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { |
| 19 | 20 | const checkpointReactor = yield* CheckpointReactor; |
| 20 | 21 | const threadDeletionReactor = yield* ThreadDeletionReactor; |
| 21 | 22 | const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; |
| 23 | const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; | |
| 22 | 24 | const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; |
| 23 | 25 | const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; |
| 24 | 26 | |
| ... | ... | @@ -29,6 +31,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { |
| 29 | 31 | yield* threadDeletionReactor.start(); |
| 30 | 32 | yield* threadPullRequestReactor.start(); |
| 31 | 33 | yield* threadSettlementReactor.start(); |
| 34 | yield* pullRequestSyncReactor.start(); | |
| 32 | 35 | yield* agentAwarenessRelay.start(); |
| 33 | 36 | }); |
| 34 | 37 |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+237| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | MessageId, |
| 9 | 9 | ProjectId, |
| 10 | 10 | ThreadId, |
| 11 | type ThreadPullRequestSnapshot, | |
| 11 | 12 | ThreadLinkedPullRequest, |
| 12 | 13 | TurnId, |
| 13 | 14 | ProviderInstanceId, |
| ... | ... | @@ -686,6 +687,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( |
| 686 | 687 | }, |
| 687 | 688 | ); |
| 688 | 689 | |
| 690 | it.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 | ||
| 689 | 926 | it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))( |
| 690 | 927 | "OrchestrationProjectionPipeline", |
| 691 | 928 | (it) => { |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+104| ... | ... | @@ -16,6 +16,10 @@ import * as Path from "effect/Path"; |
| 16 | 16 | import * as Schema from "effect/Schema"; |
| 17 | 17 | import * as Stream from "effect/Stream"; |
| 18 | 18 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 19 | import { | |
| 20 | legacyThreadPullRequestKey, | |
| 21 | threadPullRequestKeysEqual, | |
| 22 | } from "@t3tools/shared/threadPullRequests"; | |
| 19 | 23 | |
| 20 | 24 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; |
| 21 | 25 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| ... | ... | @@ -32,6 +36,7 @@ import { |
| 32 | 36 | type ProjectionThreadProposedPlan, |
| 33 | 37 | ProjectionThreadProposedPlanRepository, |
| 34 | 38 | } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; |
| 39 | import * as ProjectionThreadPullRequests from "../../persistence/ProjectionThreadPullRequests.ts"; | |
| 35 | 40 | import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 36 | 41 | import { |
| 37 | 42 | type ProjectionTurn, |
| ... | ... | @@ -480,6 +485,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 480 | 485 | const projectionThreadRepository = yield* ProjectionThreadRepository; |
| 481 | 486 | const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; |
| 482 | 487 | const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; |
| 488 | const projectionThreadPullRequestRepository = | |
| 489 | yield* ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository; | |
| 483 | 490 | const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; |
| 484 | 491 | const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; |
| 485 | 492 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| ... | ... | @@ -599,6 +606,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 599 | 606 | )(function* (event, attachmentSideEffects) { |
| 600 | 607 | switch (event.type) { |
| 601 | 608 | case "thread.created": |
| 609 | // A draft retry can re-create this id; links belong to the old incarnation. | |
| 610 | yield* projectionThreadPullRequestRepository.deleteByThreadId({ | |
| 611 | threadId: event.payload.threadId, | |
| 612 | }); | |
| 602 | 613 | yield* projectionThreadRepository.upsert({ |
| 603 | 614 | threadId: event.payload.threadId, |
| 604 | 615 | projectId: event.payload.projectId, |
| ... | ... | @@ -820,6 +831,94 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 820 | 831 | : {}), |
| 821 | 832 | updatedAt: event.payload.updatedAt, |
| 822 | 833 | }); |
| 834 | // Legacy single-link events replay into the link table. The old | |
| 835 | // field held one user-chosen link, so it only ever owns the manual | |
| 836 | // rows; created/agent/stack links are left alone. | |
| 837 | if (event.payload.linkedPullRequest !== undefined) { | |
| 838 | yield* projectionThreadPullRequestRepository.deleteByThreadIdAndSource({ | |
| 839 | threadId: event.payload.threadId, | |
| 840 | source: "manual", | |
| 841 | }); | |
| 842 | if (event.payload.linkedPullRequest !== null) { | |
| 843 | const linked = event.payload.linkedPullRequest; | |
| 844 | yield* projectionThreadPullRequestRepository.upsert({ | |
| 845 | threadId: event.payload.threadId, | |
| 846 | ...legacyThreadPullRequestKey(linked), | |
| 847 | url: linked.url, | |
| 848 | source: "manual", | |
| 849 | linkedAt: event.payload.updatedAt, | |
| 850 | snapshot: null, | |
| 851 | stack: null, | |
| 852 | }); | |
| 853 | } | |
| 854 | } | |
| 855 | return; | |
| 856 | } | |
| 857 | ||
| 858 | case "thread.pull-request-linked": { | |
| 859 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 860 | threadId: event.payload.threadId, | |
| 861 | }); | |
| 862 | if (Option.isNone(existingRow)) { | |
| 863 | return; | |
| 864 | } | |
| 865 | yield* projectionThreadPullRequestRepository.upsert({ | |
| 866 | threadId: event.payload.threadId, | |
| 867 | ...event.payload.link, | |
| 868 | }); | |
| 869 | yield* projectionThreadRepository.upsert({ | |
| 870 | ...existingRow.value, | |
| 871 | updatedAt: event.payload.updatedAt, | |
| 872 | }); | |
| 873 | return; | |
| 874 | } | |
| 875 | ||
| 876 | case "thread.pull-request-unlinked": { | |
| 877 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 878 | threadId: event.payload.threadId, | |
| 879 | }); | |
| 880 | if (Option.isNone(existingRow)) { | |
| 881 | return; | |
| 882 | } | |
| 883 | yield* projectionThreadPullRequestRepository.delete({ | |
| 884 | threadId: event.payload.threadId, | |
| 885 | host: event.payload.host.toLowerCase(), | |
| 886 | repository: event.payload.repository.toLowerCase(), | |
| 887 | number: event.payload.number, | |
| 888 | }); | |
| 889 | yield* projectionThreadRepository.upsert({ | |
| 890 | ...existingRow.value, | |
| 891 | updatedAt: event.payload.updatedAt, | |
| 892 | }); | |
| 893 | return; | |
| 894 | } | |
| 895 | ||
| 896 | case "thread.pull-request-synced": { | |
| 897 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 898 | threadId: event.payload.threadId, | |
| 899 | }); | |
| 900 | if (Option.isNone(existingRow)) { | |
| 901 | return; | |
| 902 | } | |
| 903 | // A sync for a link the user removed in the meantime is stale; drop it. | |
| 904 | const links = yield* projectionThreadPullRequestRepository.listByThreadId({ | |
| 905 | threadId: event.payload.threadId, | |
| 906 | }); | |
| 907 | const link = links.find((candidate) => | |
| 908 | threadPullRequestKeysEqual(candidate, event.payload), | |
| 909 | ); | |
| 910 | if (link === undefined) { | |
| 911 | return; | |
| 912 | } | |
| 913 | yield* projectionThreadPullRequestRepository.upsert({ | |
| 914 | ...link, | |
| 915 | snapshot: event.payload.snapshot, | |
| 916 | stack: event.payload.stack, | |
| 917 | }); | |
| 918 | yield* projectionThreadRepository.upsert({ | |
| 919 | ...existingRow.value, | |
| 920 | updatedAt: event.payload.updatedAt, | |
| 921 | }); | |
| 823 | 922 | return; |
| 824 | 923 | } |
| 825 | 924 | |
| ... | ... | @@ -866,6 +965,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 866 | 965 | if (!recreatedLater) { |
| 867 | 966 | attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); |
| 868 | 967 | } |
| 968 | // A tombstoned thread must not show up as linked to a pull request. | |
| 969 | yield* projectionThreadPullRequestRepository.deleteByThreadId({ | |
| 970 | threadId: event.payload.threadId, | |
| 971 | }); | |
| 869 | 972 | const existingRow = yield* projectionThreadRepository.getById({ |
| 870 | 973 | threadId: event.payload.threadId, |
| 871 | 974 | }); |
| ... | ... | @@ -2068,6 +2171,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( |
| 2068 | 2171 | Layer.provideMerge(ProjectionThreadRepositoryLive), |
| 2069 | 2172 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), |
| 2070 | 2173 | Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), |
| 2174 | Layer.provideMerge(ProjectionThreadPullRequests.layer), | |
| 2071 | 2175 | Layer.provideMerge(ProjectionThreadActivityRepositoryLive), |
| 2072 | 2176 | Layer.provideMerge(ProjectionThreadSessionRepositoryLive), |
| 2073 | 2177 | Layer.provideMerge(ProjectionTurnRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+146-17| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | MessageId, |
| 7 | 7 | ProjectId, |
| 8 | 8 | ThreadId, |
| 9 | type ThreadPullRequestLink, | |
| 9 | 10 | ThreadLinkedPullRequest, |
| 10 | 11 | TurnId, |
| 11 | 12 | ProviderInstanceId, |
| ... | ... | @@ -66,6 +67,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 66 | 67 | yield* sql`DELETE FROM projection_projects`; |
| 67 | 68 | yield* sql`DELETE FROM projection_state`; |
| 68 | 69 | yield* sql`DELETE FROM projection_thread_proposed_plans`; |
| 70 | yield* sql`DELETE FROM projection_thread_pull_requests`; | |
| 69 | 71 | yield* sql`DELETE FROM projection_turns`; |
| 70 | 72 | |
| 71 | 73 | yield* sql` |
| ... | ... | @@ -91,6 +93,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 91 | 93 | ) |
| 92 | 94 | `; |
| 93 | 95 | |
| 96 | // A merged link plus a newer open one: the multi-link projection must | |
| 97 | // resolve to the open pull request, not the stale JSON column below. | |
| 98 | yield* sql` | |
| 99 | INSERT INTO projection_thread_pull_requests ( | |
| 100 | thread_id, | |
| 101 | host, | |
| 102 | repository, | |
| 103 | number, | |
| 104 | url, | |
| 105 | source, | |
| 106 | linked_at, | |
| 107 | snapshot_json, | |
| 108 | stack_json | |
| 109 | ) | |
| 110 | VALUES | |
| 111 | ( | |
| 112 | 'thread-1', | |
| 113 | 'github.com', | |
| 114 | 'pingdotgg/t3code', | |
| 115 | 41, | |
| 116 | 'https://github.com/pingdotgg/t3code/pull/41', | |
| 117 | 'created', | |
| 118 | '2026-02-24T00:00:02.500Z', | |
| 119 | '{"state":"merged","title":"Groundwork","headBranch":"feat/groundwork","baseBranch":"main","isDraft":false,"updatedAt":"2026-02-24T00:00:02.600Z","syncedAt":"2026-02-24T00:00:02.700Z"}', | |
| 120 | NULL | |
| 121 | ), | |
| 122 | ( | |
| 123 | 'thread-1', | |
| 124 | 'github.com', | |
| 125 | 'pingdotgg/t3code', | |
| 126 | 42, | |
| 127 | 'https://github.com/pingdotgg/t3code/pull/42', | |
| 128 | 'manual', | |
| 129 | '2026-02-24T00:00:03.000Z', | |
| 130 | NULL, | |
| 131 | NULL | |
| 132 | ) | |
| 133 | `; | |
| 134 | ||
| 94 | 135 | yield* sql` |
| 95 | 136 | INSERT INTO projection_threads ( |
| 96 | 137 | thread_id, |
| ... | ... | @@ -124,7 +165,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 124 | 165 | 'default', |
| 125 | 166 | NULL, |
| 126 | 167 | NULL, |
| 127 | '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', | |
| 168 | '{"projectId":"project-1","repository":"pingdotgg/t3code","number":41,"url":"https://github.com/pingdotgg/t3code/pull/41"}', | |
| 128 | 169 | ${encodeThreadLinkedPullRequest(branchPullRequest)}, |
| 129 | 170 | 'turn-1', |
| 130 | 171 | '2026-02-24T00:00:04.000Z', |
| ... | ... | @@ -286,6 +327,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 286 | 327 | sequence += 1; |
| 287 | 328 | } |
| 288 | 329 | |
| 330 | const expectedPullRequests: ReadonlyArray<ThreadPullRequestLink> = [ | |
| 331 | { | |
| 332 | host: "github.com", | |
| 333 | repository: "pingdotgg/t3code", | |
| 334 | number: 41, | |
| 335 | url: "https://github.com/pingdotgg/t3code/pull/41", | |
| 336 | source: "created", | |
| 337 | linkedAt: "2026-02-24T00:00:02.500Z", | |
| 338 | snapshot: { | |
| 339 | state: "merged", | |
| 340 | title: "Groundwork", | |
| 341 | headBranch: "feat/groundwork", | |
| 342 | baseBranch: "main", | |
| 343 | isDraft: false, | |
| 344 | updatedAt: "2026-02-24T00:00:02.600Z", | |
| 345 | syncedAt: "2026-02-24T00:00:02.700Z", | |
| 346 | }, | |
| 347 | stack: null, | |
| 348 | }, | |
| 349 | { | |
| 350 | host: "github.com", | |
| 351 | repository: "pingdotgg/t3code", | |
| 352 | number: 42, | |
| 353 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 354 | source: "manual", | |
| 355 | linkedAt: "2026-02-24T00:00:03.000Z", | |
| 356 | snapshot: null, | |
| 357 | stack: null, | |
| 358 | }, | |
| 359 | ]; | |
| 360 | ||
| 289 | 361 | const snapshot = yield* snapshotQuery.getSnapshot(); |
| 290 | 362 | |
| 291 | 363 | assert.equal(snapshot.snapshotSequence, 5); |
| ... | ... | @@ -331,12 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 331 | 403 | runtimeMode: "full-access", |
| 332 | 404 | branch: null, |
| 333 | 405 | 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, | |
| 340 | 407 | branchPullRequest, |
| 341 | 408 | latestTurn: { |
| 342 | 409 | turnId: asTurnId("turn-1"), |
| ... | ... | @@ -461,12 +528,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 461 | 528 | runtimeMode: "full-access", |
| 462 | 529 | branch: null, |
| 463 | 530 | 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, | |
| 470 | 532 | branchPullRequest, |
| 471 | 533 | latestTurn: { |
| 472 | 534 | turnId: asTurnId("turn-1"), |
| ... | ... | @@ -516,13 +578,27 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 516 | 578 | assert.deepEqual(threadDetail.value, snapshot.threads[0]); |
| 517 | 579 | } |
| 518 | 580 | |
| 519 | const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); | |
| 520 | assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq"); | |
| 521 | assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); | |
| 522 | 581 | const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); |
| 523 | 582 | assert.equal(threadShell._tag, "Some"); |
| 524 | 583 | if (threadShell._tag === "Some") { |
| 525 | assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest); | |
| 584 | assert.deepEqual(threadShell.value, shellSnapshot.threads[0]); | |
| 585 | } | |
| 586 | ||
| 587 | const commandReadModel = yield* snapshotQuery.getCommandReadModel(); | |
| 588 | assert.deepEqual(commandReadModel.threads[0]?.pullRequests, expectedPullRequests); | |
| 589 | assert.deepEqual( | |
| 590 | commandReadModel.threads[0]?.linkedPullRequest, | |
| 591 | snapshot.threads[0]?.linkedPullRequest, | |
| 592 | ); | |
| 593 | ||
| 594 | // Without link rows the legacy field is omitted, whatever the old JSON | |
| 595 | // column still holds. | |
| 596 | yield* sql`DELETE FROM projection_thread_pull_requests`; | |
| 597 | const unlinkedShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); | |
| 598 | assert.equal(unlinkedShell._tag, "Some"); | |
| 599 | if (unlinkedShell._tag === "Some") { | |
| 600 | assert.deepEqual(unlinkedShell.value.pullRequests, []); | |
| 601 | assert.equal("linkedPullRequest" in unlinkedShell.value, false); | |
| 526 | 602 | } |
| 527 | 603 | |
| 528 | 604 | yield* sql` |
| ... | ... | @@ -3259,3 +3335,56 @@ projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { |
| 3259 | 3335 | }), |
| 3260 | 3336 | ); |
| 3261 | 3337 | }); |
| 3338 | ||
| 3339 | it.effect("omits foreign-host PRs from legacy snapshots while preserving native links", () => { | |
| 3340 | const layer = OrchestrationProjectionSnapshotQueryLive.pipe( | |
| 3341 | Layer.provide(ThreadBackgroundLiveness.layer), | |
| 3342 | Layer.provide(ThreadPlanProgress.layer), | |
| 3343 | Layer.provide( | |
| 3344 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { | |
| 3345 | resolve: () => | |
| 3346 | Effect.succeed({ | |
| 3347 | canonicalKey: "github.com/acme/web", | |
| 3348 | provider: "github", | |
| 3349 | displayName: "acme/web", | |
| 3350 | locator: { | |
| 3351 | source: "git-remote" as const, | |
| 3352 | remoteName: "origin", | |
| 3353 | remoteUrl: "https://github.com/acme/web.git", | |
| 3354 | }, | |
| 3355 | }), | |
| 3356 | }), | |
| 3357 | ), | |
| 3358 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 3359 | ); | |
| 3360 | return Effect.gen(function* () { | |
| 3361 | const sql = yield* SqlClient.SqlClient; | |
| 3362 | const query = yield* ProjectionSnapshotQuery; | |
| 3363 | yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) | |
| 3364 | VALUES ('project-1', 'Project', '/repo', '[]', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`; | |
| 3365 | yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at) | |
| 3366 | VALUES ('thread-1', 'project-1', 'Thread', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`; | |
| 3367 | yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) | |
| 3368 | VALUES ('thread-1', 'github.enterprise.test', 'acme/web', 42, 'https://github.enterprise.test/acme/web/pull/42', 'manual', '2026-09-09T00:00:00Z')`; | |
| 3369 | const readThreads = Effect.gen(function* () { | |
| 3370 | const full = yield* query.getSnapshot(); | |
| 3371 | const shell = yield* query.getShellSnapshot(); | |
| 3372 | const detail = yield* query.getThreadDetailById(ThreadId.make("thread-1")); | |
| 3373 | const individual = yield* query.getThreadShellById(ThreadId.make("thread-1")); | |
| 3374 | return [ | |
| 3375 | full.threads[0]!, | |
| 3376 | shell.threads[0]!, | |
| 3377 | Option.getOrThrow(detail), | |
| 3378 | Option.getOrThrow(individual), | |
| 3379 | ]; | |
| 3380 | }); | |
| 3381 | for (const thread of yield* readThreads) { | |
| 3382 | assert.equal(thread.linkedPullRequest ?? null, null); | |
| 3383 | assert.equal(thread.pullRequests[0]?.host, "github.enterprise.test"); | |
| 3384 | } | |
| 3385 | yield* sql`UPDATE projection_thread_pull_requests SET host = 'github.com', url = 'https://github.com/acme/web/pull/42'`; | |
| 3386 | for (const thread of yield* readThreads) { | |
| 3387 | assert.equal(thread.linkedPullRequest?.url, "https://github.com/acme/web/pull/42"); | |
| 3388 | } | |
| 3389 | }).pipe(Effect.provide(layer)); | |
| 3390 | }); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+423-187| ... | ... | @@ -29,7 +29,11 @@ import { |
| 29 | 29 | ProjectId, |
| 30 | 30 | ThreadLinkedPullRequest, |
| 31 | 31 | ThreadId, |
| 32 | ThreadPullRequestSnapshot, | |
| 33 | ThreadPullRequestStack, | |
| 34 | type ThreadPullRequestLink, | |
| 32 | 35 | } from "@t3tools/contracts"; |
| 36 | import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests"; | |
| 33 | 37 | import * as Arr from "effect/Array"; |
| 34 | 38 | import * as Effect from "effect/Effect"; |
| 35 | 39 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -54,6 +58,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; |
| 54 | 58 | import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; |
| 55 | 59 | import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; |
| 56 | 60 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; |
| 61 | import { ProjectionThreadPullRequest } from "../../persistence/ProjectionThreadPullRequests.ts"; | |
| 57 | 62 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 58 | 63 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; |
| 59 | 64 | import { |
| ... | ... | @@ -112,6 +117,12 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema |
| 112 | 117 | Struct.assign({ hasOtherUserMessages: Schema.Number }), |
| 113 | 118 | ); |
| 114 | 119 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; |
| 120 | const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFields( | |
| 121 | Struct.assign({ | |
| 122 | snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), | |
| 123 | stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), | |
| 124 | }), | |
| 125 | ); | |
| 115 | 126 | const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( |
| 116 | 127 | Struct.assign({ |
| 117 | 128 | modelSelection: Schema.fromJsonString(ModelSelection), |
| ... | ... | @@ -403,6 +414,49 @@ function mapProposedPlanRow( |
| 403 | 414 | }; |
| 404 | 415 | } |
| 405 | 416 | |
| 417 | function 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 | ||
| 432 | function 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 | */ | |
| 448 | function 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 | ||
| 406 | 460 | function mapThreadActivityRow( |
| 407 | 461 | row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>, |
| 408 | 462 | ): OrchestrationThreadActivity { |
| ... | ... | @@ -649,6 +703,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 649 | 703 | `, |
| 650 | 704 | }); |
| 651 | 705 | |
| 706 | const listThreadPullRequestRows = SqlSchema.findAll({ | |
| 707 | Request: Schema.Void, | |
| 708 | Result: ProjectionThreadPullRequestDbRowSchema, | |
| 709 | execute: () => | |
| 710 | sql` | |
| 711 | SELECT | |
| 712 | thread_id AS "threadId", | |
| 713 | host, | |
| 714 | repository, | |
| 715 | number, | |
| 716 | url, | |
| 717 | source, | |
| 718 | linked_at AS "linkedAt", | |
| 719 | snapshot_json AS "snapshot", | |
| 720 | stack_json AS "stack" | |
| 721 | FROM projection_thread_pull_requests | |
| 722 | ORDER BY thread_id ASC, linked_at ASC, number ASC | |
| 723 | `, | |
| 724 | }); | |
| 725 | ||
| 726 | const listActiveThreadPullRequestRows = SqlSchema.findAll({ | |
| 727 | Request: Schema.Void, | |
| 728 | Result: ProjectionThreadPullRequestDbRowSchema, | |
| 729 | execute: () => | |
| 730 | sql` | |
| 731 | SELECT | |
| 732 | links.thread_id AS "threadId", | |
| 733 | links.host, | |
| 734 | links.repository, | |
| 735 | links.number, | |
| 736 | links.url, | |
| 737 | links.source, | |
| 738 | links.linked_at AS "linkedAt", | |
| 739 | links.snapshot_json AS "snapshot", | |
| 740 | links.stack_json AS "stack" | |
| 741 | FROM projection_thread_pull_requests links | |
| 742 | INNER JOIN projection_threads threads | |
| 743 | ON threads.thread_id = links.thread_id | |
| 744 | WHERE threads.deleted_at IS NULL | |
| 745 | AND threads.archived_at IS NULL | |
| 746 | ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC | |
| 747 | `, | |
| 748 | }); | |
| 749 | ||
| 750 | const listArchivedThreadPullRequestRows = SqlSchema.findAll({ | |
| 751 | Request: Schema.Void, | |
| 752 | Result: ProjectionThreadPullRequestDbRowSchema, | |
| 753 | execute: () => | |
| 754 | sql` | |
| 755 | SELECT | |
| 756 | links.thread_id AS "threadId", | |
| 757 | links.host, | |
| 758 | links.repository, | |
| 759 | links.number, | |
| 760 | links.url, | |
| 761 | links.source, | |
| 762 | links.linked_at AS "linkedAt", | |
| 763 | links.snapshot_json AS "snapshot", | |
| 764 | links.stack_json AS "stack" | |
| 765 | FROM projection_thread_pull_requests links | |
| 766 | INNER JOIN projection_threads threads | |
| 767 | ON threads.thread_id = links.thread_id | |
| 768 | WHERE threads.deleted_at IS NULL | |
| 769 | AND threads.archived_at IS NOT NULL | |
| 770 | ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC | |
| 771 | `, | |
| 772 | }); | |
| 773 | ||
| 652 | 774 | const listThreadActivityRows = SqlSchema.findAll({ |
| 653 | 775 | Request: Schema.Void, |
| 654 | 776 | Result: ProjectionThreadActivityDbRowSchema, |
| ... | ... | @@ -1208,6 +1330,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1208 | 1330 | `, |
| 1209 | 1331 | }); |
| 1210 | 1332 | |
| 1333 | const listThreadPullRequestRowsByThread = SqlSchema.findAll({ | |
| 1334 | Request: ThreadIdLookupInput, | |
| 1335 | Result: ProjectionThreadPullRequestDbRowSchema, | |
| 1336 | execute: ({ threadId }) => | |
| 1337 | sql` | |
| 1338 | SELECT | |
| 1339 | thread_id AS "threadId", | |
| 1340 | host, | |
| 1341 | repository, | |
| 1342 | number, | |
| 1343 | url, | |
| 1344 | source, | |
| 1345 | linked_at AS "linkedAt", | |
| 1346 | snapshot_json AS "snapshot", | |
| 1347 | stack_json AS "stack" | |
| 1348 | FROM projection_thread_pull_requests | |
| 1349 | WHERE thread_id = ${threadId} | |
| 1350 | ORDER BY linked_at ASC, number ASC | |
| 1351 | `, | |
| 1352 | }); | |
| 1353 | ||
| 1211 | 1354 | const listThreadActivityRowsByThread = SqlSchema.findAll({ |
| 1212 | 1355 | Request: ThreadIdLookupInput, |
| 1213 | 1356 | Result: ProjectionThreadActivityDbRowSchema, |
| ... | ... | @@ -1852,6 +1995,14 @@ pending_approval_requests AS ( |
| 1852 | 1995 | ), |
| 1853 | 1996 | ), |
| 1854 | 1997 | ), |
| 1998 | listThreadPullRequestRows(undefined).pipe( | |
| 1999 | Effect.mapError( | |
| 2000 | toPersistenceSqlOrDecodeError( | |
| 2001 | "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:query", | |
| 2002 | "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:decodeRows", | |
| 2003 | ), | |
| 2004 | ), | |
| 2005 | ), | |
| 1855 | 2006 | listThreadActivityRows(undefined).pipe( |
| 1856 | 2007 | Effect.mapError( |
| 1857 | 2008 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -1901,6 +2052,7 @@ pending_approval_requests AS ( |
| 1901 | 2052 | threadRows, |
| 1902 | 2053 | messageRows, |
| 1903 | 2054 | proposedPlanRows, |
| 2055 | pullRequestRows, | |
| 1904 | 2056 | activityRows, |
| 1905 | 2057 | sessionRows, |
| 1906 | 2058 | checkpointRows, |
| ... | ... | @@ -1910,6 +2062,7 @@ pending_approval_requests AS ( |
| 1910 | 2062 | Effect.gen(function* () { |
| 1911 | 2063 | const messagesByThread = new Map<string, Array<OrchestrationMessage>>(); |
| 1912 | 2064 | const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>(); |
| 2065 | const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); | |
| 1913 | 2066 | const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); |
| 1914 | 2067 | const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); |
| 1915 | 2068 | const sessionsByThread = new Map<string, OrchestrationSession>(); |
| ... | ... | @@ -2071,10 +2224,12 @@ pending_approval_requests AS ( |
| 2071 | 2224 | interactionMode: row.interactionMode, |
| 2072 | 2225 | branch: row.branch, |
| 2073 | 2226 | worktreePath: row.worktreePath, |
| 2227 | ...mapThreadPullRequests( | |
| 2228 | pullRequestsByThread.get(row.threadId) ?? [], | |
| 2229 | row.projectId, | |
| 2230 | repositoryIdentities.get(row.projectId), | |
| 2231 | ), | |
| 2074 | 2232 | branchPullRequest: row.branchPullRequest, |
| 2075 | ...(row.linkedPullRequest === null | |
| 2076 | ? {} | |
| 2077 | : { linkedPullRequest: row.linkedPullRequest }), | |
| 2078 | 2233 | latestTurn: latestTurnByThread.get(row.threadId) ?? null, |
| 2079 | 2234 | createdAt: row.createdAt, |
| 2080 | 2235 | updatedAt: row.updatedAt, |
| ... | ... | @@ -2146,6 +2301,14 @@ pending_approval_requests AS ( |
| 2146 | 2301 | ), |
| 2147 | 2302 | ), |
| 2148 | 2303 | ), |
| 2304 | listThreadPullRequestRows(undefined).pipe( | |
| 2305 | Effect.mapError( | |
| 2306 | toPersistenceSqlOrDecodeError( | |
| 2307 | "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:query", | |
| 2308 | "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:decodeRows", | |
| 2309 | ), | |
| 2310 | ), | |
| 2311 | ), | |
| 2149 | 2312 | listThreadSessionRows(undefined).pipe( |
| 2150 | 2313 | Effect.mapError( |
| 2151 | 2314 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -2174,8 +2337,25 @@ pending_approval_requests AS ( |
| 2174 | 2337 | ) |
| 2175 | 2338 | .pipe( |
| 2176 | 2339 | 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 | ); | |
| 2179 | 2359 | let updatedAt: string | null = null; |
| 2180 | 2360 | const projects: OrchestrationProject[] = []; |
| 2181 | 2361 | const threads: OrchestrationThread[] = []; |
| ... | ... | @@ -2190,6 +2370,7 @@ pending_approval_requests AS ( |
| 2190 | 2370 | id: row.projectId, |
| 2191 | 2371 | title: row.title, |
| 2192 | 2372 | workspaceRoot: row.workspaceRoot, |
| 2373 | repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, | |
| 2193 | 2374 | defaultModelSelection: row.defaultModelSelection, |
| 2194 | 2375 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 2195 | 2376 | autoPull: row.autoPull === 1, |
| ... | ... | @@ -2252,6 +2433,7 @@ pending_approval_requests AS ( |
| 2252 | 2433 | latestTurnByThread.set(row.threadId, mapLatestTurn(row)); |
| 2253 | 2434 | } |
| 2254 | 2435 | const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>(); |
| 2436 | const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); | |
| 2255 | 2437 | const sessionByThread = new Map<string, OrchestrationSession>(); |
| 2256 | 2438 | |
| 2257 | 2439 | for (let index = 0; index < sessionRows.length; index += 1) { |
| ... | ... | @@ -2286,10 +2468,12 @@ pending_approval_requests AS ( |
| 2286 | 2468 | interactionMode: row.interactionMode, |
| 2287 | 2469 | branch: row.branch, |
| 2288 | 2470 | worktreePath: row.worktreePath, |
| 2471 | ...mapThreadPullRequests( | |
| 2472 | pullRequestsByThread.get(row.threadId) ?? [], | |
| 2473 | row.projectId, | |
| 2474 | repositoryIdentities.get(row.projectId), | |
| 2475 | ), | |
| 2289 | 2476 | branchPullRequest: row.branchPullRequest, |
| 2290 | ...(row.linkedPullRequest === null | |
| 2291 | ? {} | |
| 2292 | : { linkedPullRequest: row.linkedPullRequest }), | |
| 2293 | 2477 | latestTurn: latestTurnByThread.get(row.threadId) ?? null, |
| 2294 | 2478 | createdAt: row.createdAt, |
| 2295 | 2479 | updatedAt: row.updatedAt, |
| ... | ... | @@ -2356,6 +2540,14 @@ pending_approval_requests AS ( |
| 2356 | 2540 | ), |
| 2357 | 2541 | ), |
| 2358 | 2542 | ), |
| 2543 | listActiveThreadPullRequestRows(undefined).pipe( | |
| 2544 | Effect.mapError( | |
| 2545 | toPersistenceSqlOrDecodeError( | |
| 2546 | "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query", | |
| 2547 | "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:decodeRows", | |
| 2548 | ), | |
| 2549 | ), | |
| 2550 | ), | |
| 2359 | 2551 | listActiveLatestTurnRows(undefined).pipe( |
| 2360 | 2552 | Effect.mapError( |
| 2361 | 2553 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -2375,99 +2567,104 @@ pending_approval_requests AS ( |
| 2375 | 2567 | ]), |
| 2376 | 2568 | ) |
| 2377 | 2569 | .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); | |
| 2394 | 2576 | } |
| 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); | |
| 2397 | 2594 | } |
| 2398 | } | |
| 2399 | for (const row of stateRows) { | |
| 2400 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 2401 | } | |
| 2402 | 2595 | |
| 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); | |
| 2410 | 2605 | |
| 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 | }; | |
| 2462 | 2659 | |
| 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 | ), | |
| 2467 | 2665 | ), |
| 2468 | ), | |
| 2469 | ); | |
| 2470 | }), | |
| 2666 | ); | |
| 2667 | }), | |
| 2471 | 2668 | ), |
| 2472 | 2669 | Effect.mapError((error) => { |
| 2473 | 2670 | if (isPersistenceError(error)) { |
| ... | ... | @@ -2505,6 +2702,14 @@ pending_approval_requests AS ( |
| 2505 | 2702 | ), |
| 2506 | 2703 | ), |
| 2507 | 2704 | ), |
| 2705 | listArchivedThreadPullRequestRows(undefined).pipe( | |
| 2706 | Effect.mapError( | |
| 2707 | toPersistenceSqlOrDecodeError( | |
| 2708 | "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:query", | |
| 2709 | "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:decodeRows", | |
| 2710 | ), | |
| 2711 | ), | |
| 2712 | ), | |
| 2508 | 2713 | listArchivedLatestTurnRows(undefined).pipe( |
| 2509 | 2714 | Effect.mapError( |
| 2510 | 2715 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -2524,98 +2729,102 @@ pending_approval_requests AS ( |
| 2524 | 2729 | ]), |
| 2525 | 2730 | ) |
| 2526 | 2731 | .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); | |
| 2543 | 2738 | } |
| 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); | |
| 2546 | 2756 | } |
| 2547 | } | |
| 2548 | for (const row of stateRows) { | |
| 2549 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 2550 | } | |
| 2551 | 2757 | |
| 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 | ); | |
| 2562 | 2769 | |
| 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, | |
| 2605 | 2778 | ), |
| 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 | }; | |
| 2610 | 2819 | |
| 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 | ), | |
| 2615 | 2825 | ), |
| 2616 | ), | |
| 2617 | ); | |
| 2618 | }), | |
| 2826 | ); | |
| 2827 | }), | |
| 2619 | 2828 | ), |
| 2620 | 2829 | Effect.mapError((error) => { |
| 2621 | 2830 | if (isPersistenceError(error)) { |
| ... | ... | @@ -2861,7 +3070,7 @@ pending_approval_requests AS ( |
| 2861 | 3070 | |
| 2862 | 3071 | const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => |
| 2863 | 3072 | Effect.gen(function* () { |
| 2864 | const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ | |
| 3073 | const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([ | |
| 2865 | 3074 | getActiveThreadRowById({ threadId }).pipe( |
| 2866 | 3075 | Effect.mapError( |
| 2867 | 3076 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -2886,6 +3095,14 @@ pending_approval_requests AS ( |
| 2886 | 3095 | ), |
| 2887 | 3096 | ), |
| 2888 | 3097 | ), |
| 3098 | listThreadPullRequestRowsByThread({ threadId }).pipe( | |
| 3099 | Effect.mapError( | |
| 3100 | toPersistenceSqlOrDecodeError( | |
| 3101 | "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query", | |
| 3102 | "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows", | |
| 3103 | ), | |
| 3104 | ), | |
| 3105 | ), | |
| 2889 | 3106 | ]); |
| 2890 | 3107 | |
| 2891 | 3108 | if (Option.isNone(threadRow)) { |
| ... | ... | @@ -2901,10 +3118,15 @@ pending_approval_requests AS ( |
| 2901 | 3118 | interactionMode: threadRow.value.interactionMode, |
| 2902 | 3119 | branch: threadRow.value.branch, |
| 2903 | 3120 | worktreePath: threadRow.value.worktreePath, |
| 3121 | ...mapThreadPullRequests( | |
| 3122 | pullRequestRows.map(mapPullRequestRow), | |
| 3123 | threadRow.value.projectId, | |
| 3124 | pullRequestRows.length === 0 | |
| 3125 | ? null | |
| 3126 | : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) | |
| 3127 | ?.repositoryIdentity, | |
| 3128 | ), | |
| 2904 | 3129 | branchPullRequest: threadRow.value.branchPullRequest, |
| 2905 | ...(threadRow.value.linkedPullRequest === null | |
| 2906 | ? {} | |
| 2907 | : { linkedPullRequest: threadRow.value.linkedPullRequest }), | |
| 2908 | 3130 | latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, |
| 2909 | 3131 | createdAt: threadRow.value.createdAt, |
| 2910 | 3132 | updatedAt: threadRow.value.updatedAt, |
| ... | ... | @@ -3112,6 +3334,7 @@ pending_approval_requests AS ( |
| 3112 | 3334 | threadRow, |
| 3113 | 3335 | messageRows, |
| 3114 | 3336 | proposedPlanRows, |
| 3337 | pullRequestRows, | |
| 3115 | 3338 | activities, |
| 3116 | 3339 | checkpointRows, |
| 3117 | 3340 | latestTurnRow, |
| ... | ... | @@ -3144,6 +3367,14 @@ pending_approval_requests AS ( |
| 3144 | 3367 | ), |
| 3145 | 3368 | ), |
| 3146 | 3369 | ), |
| 3370 | listThreadPullRequestRowsByThread({ threadId }).pipe( | |
| 3371 | Effect.mapError( | |
| 3372 | toPersistenceSqlOrDecodeError( | |
| 3373 | "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:query", | |
| 3374 | "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:decodeRows", | |
| 3375 | ), | |
| 3376 | ), | |
| 3377 | ), | |
| 3147 | 3378 | activitiesEffect, |
| 3148 | 3379 | listCheckpointRowsByThread({ threadId }).pipe( |
| 3149 | 3380 | Effect.mapError( |
| ... | ... | @@ -3184,10 +3415,15 @@ pending_approval_requests AS ( |
| 3184 | 3415 | interactionMode: threadRow.value.interactionMode, |
| 3185 | 3416 | branch: threadRow.value.branch, |
| 3186 | 3417 | worktreePath: threadRow.value.worktreePath, |
| 3418 | ...mapThreadPullRequests( | |
| 3419 | pullRequestRows.map(mapPullRequestRow), | |
| 3420 | threadRow.value.projectId, | |
| 3421 | pullRequestRows.length === 0 | |
| 3422 | ? null | |
| 3423 | : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) | |
| 3424 | ?.repositoryIdentity, | |
| 3425 | ), | |
| 3187 | 3426 | branchPullRequest: threadRow.value.branchPullRequest, |
| 3188 | ...(threadRow.value.linkedPullRequest === null | |
| 3189 | ? {} | |
| 3190 | : { linkedPullRequest: threadRow.value.linkedPullRequest }), | |
| 3191 | 3427 | latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, |
| 3192 | 3428 | createdAt: threadRow.value.createdAt, |
| 3193 | 3429 | updatedAt: threadRow.value.updatedAt, |
apps/server/src/orchestration/PullRequestSyncReactor.test.ts created+704| ... | ... | @@ -0,0 +1,704 @@ |
| 1 | import { | |
| 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"; | |
| 16 | import { assert, describe, it } from "@effect/vitest"; | |
| 17 | import * as Crypto from "effect/Crypto"; | |
| 18 | import * as Deferred from "effect/Deferred"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as Layer from "effect/Layer"; | |
| 21 | import * as Queue from "effect/Queue"; | |
| 22 | import * as Ref from "effect/Ref"; | |
| 23 | import * as Stream from "effect/Stream"; | |
| 24 | import { TestClock } from "effect/testing"; | |
| 25 | ||
| 26 | import { PullRequestService } from "../pullRequest/PullRequestService.ts"; | |
| 27 | import { ServerActivation } from "../serverActivation.ts"; | |
| 28 | import { | |
| 29 | OrchestrationEngineService, | |
| 30 | type OrchestrationEngineShape, | |
| 31 | } from "./Services/OrchestrationEngine.ts"; | |
| 32 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | |
| 33 | import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts"; | |
| 34 | ||
| 35 | const NOW = "2026-08-28T12:00:00.000Z"; | |
| 36 | const PROJECT_ID = ProjectId.make("sync-project"); | |
| 37 | ||
| 38 | type SyncCommand = Extract< | |
| 39 | OrchestrationCommand, | |
| 40 | { readonly type: "thread.pull-request-link.sync" } | |
| 41 | >; | |
| 42 | type LinkCommand = Extract<OrchestrationCommand, { readonly type: "thread.pull-request.link" }>; | |
| 43 | ||
| 44 | const testCrypto = Crypto.make({ | |
| 45 | randomBytes: (size) => new Uint8Array(size).fill(1), | |
| 46 | digest: (_algorithm, data) => Effect.succeed(data), | |
| 47 | }); | |
| 48 | ||
| 49 | function 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 | ||
| 61 | function 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 | ||
| 93 | function 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 | ||
| 123 | function 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 | ||
| 135 | function 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 | ||
| 154 | interface 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 | ||
| 165 | const 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 | ||
| 233 | type Harness = Effect.Success<ReturnType<typeof makeHarness>>; | |
| 234 | ||
| 235 | const 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 | ||
| 244 | const 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. */ | |
| 254 | function 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 | ||
| 275 | describe("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 @@ |
| 1 | import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 2 | import { | |
| 3 | CommandId, | |
| 4 | type OrchestrationThreadShell, | |
| 5 | type PullRequestSummary, | |
| 6 | type ThreadPullRequestKey, | |
| 7 | type ThreadPullRequestLink, | |
| 8 | type ThreadPullRequestSnapshot, | |
| 9 | type ThreadPullRequestStack, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | |
| 12 | import { | |
| 13 | threadPullRequestKeyOf, | |
| 14 | threadPullRequestKeysEqual, | |
| 15 | visibleThreadPullRequests, | |
| 16 | } from "@t3tools/shared/threadPullRequests"; | |
| 17 | import * as Cause from "effect/Cause"; | |
| 18 | import * as Context from "effect/Context"; | |
| 19 | import * as Crypto from "effect/Crypto"; | |
| 20 | import * as DateTime from "effect/DateTime"; | |
| 21 | import * as Effect from "effect/Effect"; | |
| 22 | import * as Layer from "effect/Layer"; | |
| 23 | import * as Schedule from "effect/Schedule"; | |
| 24 | import type * as Scope from "effect/Scope"; | |
| 25 | ||
| 26 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | |
| 27 | import { forkParked } from "../serverActivation.ts"; | |
| 28 | import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; | |
| 29 | import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; | |
| 30 | ||
| 31 | const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; | |
| 32 | ||
| 33 | type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">; | |
| 34 | ||
| 35 | interface LinkEntry { | |
| 36 | readonly thread: OrchestrationThreadShell; | |
| 37 | readonly link: ThreadPullRequestLink; | |
| 38 | } | |
| 39 | ||
| 40 | function 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 | ||
| 60 | function 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 | ||
| 81 | function 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 | ||
| 104 | function 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 | */ | |
| 114 | export 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. */ | |
| 125 | export 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 | ||
| 328 | export const layer = Layer.effect(PullRequestSyncReactor, make); |
apps/server/src/orchestration/Schemas.ts+6| ... | ... | @@ -16,6 +16,9 @@ import { |
| 16 | 16 | ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, |
| 17 | 17 | ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, |
| 18 | 18 | ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, |
| 19 | ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema, | |
| 20 | ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema, | |
| 21 | ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema, | |
| 19 | 22 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 20 | 23 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, |
| 21 | 24 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | ... | @@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; |
| 48 | 51 | export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; |
| 49 | 52 | export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; |
| 50 | 53 | export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; |
| 54 | export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; | |
| 55 | export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; | |
| 56 | export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; | |
| 51 | 57 | |
| 52 | 58 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 53 | 59 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; |
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+1| ... | ... | @@ -87,6 +87,7 @@ function thread( |
| 87 | 87 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 88 | 88 | runtimeMode: "full-access", |
| 89 | 89 | interactionMode: "default", |
| 90 | pullRequests: [], | |
| 90 | 91 | branch: "feature", |
| 91 | 92 | worktreePath: null, |
| 92 | 93 | latestTurn: null, |
apps/server/src/orchestration/ThreadPullRequestReactor.ts+6-13| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import { | |
| 2 | canonicalRepositoryKey, | |
| 3 | sourceControlRepositorySelector, | |
| 4 | } from "@t3tools/shared/sourceControl"; | |
| 1 | 5 | import { |
| 2 | 6 | CommandId, |
| 3 | 7 | type OrchestrationEvent, |
| ... | ... | @@ -53,18 +57,6 @@ interface RefreshRequest { |
| 53 | 57 | readonly backfill?: boolean; |
| 54 | 58 | } |
| 55 | 59 | |
| 56 | function canonicalRepositoryKey(key: string): string { | |
| 57 | return key | |
| 58 | .replace( | |
| 59 | /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, | |
| 60 | "dev.azure.com/$1/$2/_git/$3", | |
| 61 | ) | |
| 62 | .replace( | |
| 63 | /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, | |
| 64 | "dev.azure.com/$1/$2/_git/$3", | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | 60 | export function pullRequestMatchesProject( |
| 69 | 61 | pullRequest: GitManager.GitBranchPullRequest, |
| 70 | 62 | project: OrchestrationProjectShell, |
| ... | ... | @@ -138,7 +130,7 @@ export const make = Effect.gen(function* () { |
| 138 | 130 | const first = group[0]!; |
| 139 | 131 | const project = projects.get(first.projectId); |
| 140 | 132 | if (project === undefined) return finishBackfill(group); |
| 141 | const repository = PullRequestService.repositoryIdentityOf(project); | |
| 133 | const repository = sourceControlRepositorySelector(project.repositoryIdentity); | |
| 142 | 134 | if (first.branch !== null && repository === null) return finishBackfill(group); |
| 143 | 135 | const worktreeExists = |
| 144 | 136 | first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); |
| ... | ... | @@ -189,6 +181,7 @@ export const make = Effect.gen(function* () { |
| 189 | 181 | |
| 190 | 182 | let replacement: ThreadLinkedPullRequest | undefined; |
| 191 | 183 | if ( |
| 184 | thread.pullRequests.length === 0 && | |
| 192 | 185 | thread.linkedPullRequest != null && |
| 193 | 186 | detected?.state === "open" && |
| 194 | 187 | detectedReference !== null && |
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+74| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | ProjectId, |
| 6 | 6 | TurnId, |
| 7 | 7 | type OrchestrationThreadShell, |
| 8 | type ThreadPullRequestLink, | |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; |
| 10 | 11 | |
| ... | ... | @@ -18,6 +19,7 @@ const makeThread = ( |
| 18 | 19 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, |
| 19 | 20 | runtimeMode: "full-access", |
| 20 | 21 | interactionMode: "default", |
| 22 | pullRequests: [], | |
| 21 | 23 | branch: "feature", |
| 22 | 24 | worktreePath: "/repo", |
| 23 | 25 | latestTurn: null, |
| ... | ... | @@ -213,3 +215,75 @@ describe("resolveAutoSettlementAt", () => { |
| 213 | 215 | ).toBe(true); |
| 214 | 216 | }); |
| 215 | 217 | }); |
| 218 | ||
| 219 | function 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 | ||
| 235 | const 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 | ||
| 251 | describe("linked request settlement", () => { | |
| 252 | it.each(["closed", "merged"] as const)( | |
| 253 | "uses the latest actual %s transition despite later comments on another PR", | |
| 254 | (state) => { | |
| 255 | const old = linkedRequest(1, terminalSnapshot(state, "2026-08-19T00:00:00.000Z", NOW)); | |
| 256 | const recent = linkedRequest(2, terminalSnapshot(state, "2026-08-21T00:00:00.000Z")); | |
| 257 | expect(decide(makeThread({ pullRequests: [old, recent] }), null, { days: null })).toBe(true); | |
| 258 | expect(decide(makeThread({ pullRequests: [recent, old] }), null, { days: null })).toBe(true); | |
| 259 | expect(decide(makeThread({ pullRequests: [old] }), null, { days: null })).toBe(false); | |
| 260 | }, | |
| 261 | ); | |
| 262 | ||
| 263 | it("keeps unknown and open links active even after the inactivity window", () => { | |
| 264 | const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); | |
| 265 | const unknown = linkedRequest(2, null); | |
| 266 | const open = linkedRequest(3, { | |
| 267 | ...terminalSnapshot("closed", NOW), | |
| 268 | state: "open", | |
| 269 | closedAt: null, | |
| 270 | }); | |
| 271 | expect(decide(makeThread({ pullRequests: [merged, unknown] }))).toBe(false); | |
| 272 | expect(decide(makeThread({ pullRequests: [merged, open] }))).toBe(false); | |
| 273 | expect( | |
| 274 | decide(makeThread({ pullRequests: [merged, { ...unknown, source: "stack-dismissed" }] })), | |
| 275 | ).toBe(true); | |
| 276 | }); | |
| 277 | ||
| 278 | it("honors merge settings and ignores missing terminal timestamps", () => { | |
| 279 | const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); | |
| 280 | expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null, merge: false })).toBe( | |
| 281 | false, | |
| 282 | ); | |
| 283 | const missing = linkedRequest(2, { ...terminalSnapshot("merged", NOW), mergedAt: null }); | |
| 284 | expect(decide(makeThread({ pullRequests: [missing] }), null, { days: null })).toBe(false); | |
| 285 | expect(decide(makeThread({ pullRequests: [missing, merged] }), null, { days: null })).toBe( | |
| 286 | true, | |
| 287 | ); | |
| 288 | }); | |
| 289 | }); |
apps/server/src/orchestration/ThreadSettlementPolicy.ts+24-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; |
| 2 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; | |
| 2 | 3 | |
| 3 | 4 | export interface SettlementPullRequest { |
| 4 | 5 | readonly state: "open" | "closed" | "merged"; |
| ... | ... | @@ -72,7 +73,29 @@ export function resolveAutoSettlementAt(input: { |
| 72 | 73 | readonly autoSettleAfterDays: number | null; |
| 73 | 74 | readonly autoSettleOnMerge: boolean; |
| 74 | 75 | }): string | null { |
| 75 | const { thread, pullRequest } = input; | |
| 76 | const { thread } = input; | |
| 77 | let pullRequest = input.pullRequest; | |
| 78 | const links = visibleThreadPullRequests(thread.pullRequests); | |
| 79 | if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; | |
| 80 | if (links.length > 0) { | |
| 81 | const terminalTimestamp = (link: (typeof links)[number]) => { | |
| 82 | const snapshot = link.snapshot; | |
| 83 | const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; | |
| 84 | const timestamp = Date.parse(value ?? ""); | |
| 85 | return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; | |
| 86 | }; | |
| 87 | const latest = links.reduce((current, candidate) => | |
| 88 | terminalTimestamp(candidate) > terminalTimestamp(current) ? candidate : current, | |
| 89 | ); | |
| 90 | pullRequest = | |
| 91 | latest.snapshot === null | |
| 92 | ? null | |
| 93 | : { | |
| 94 | state: latest.snapshot.state, | |
| 95 | mergedAt: latest.snapshot.mergedAt ?? null, | |
| 96 | closedAt: latest.snapshot.closedAt ?? null, | |
| 97 | }; | |
| 98 | } | |
| 76 | 99 | if (!isAutoSettlementCandidate(thread, input.now)) return null; |
| 77 | 100 | const activityAt = latestTimestamp([ |
| 78 | 101 | thread.latestUserMessageAt, |
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+52| ... | ... | @@ -80,6 +80,7 @@ function makeThread( |
| 80 | 80 | }, |
| 81 | 81 | runtimeMode: "full-access", |
| 82 | 82 | interactionMode: "default", |
| 83 | pullRequests: [], | |
| 83 | 84 | branch: null, |
| 84 | 85 | worktreePath: null, |
| 85 | 86 | latestTurn: null, |
| ... | ... | @@ -301,6 +302,57 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( |
| 301 | 302 | }); |
| 302 | 303 | |
| 303 | 304 | describe("ThreadSettlementReactor", () => { |
| 305 | it.effect( | |
| 306 | "settles all-terminal links from snapshots and keeps open or unsynced links active", | |
| 307 | () => | |
| 308 | Effect.scoped( | |
| 309 | Effect.gen(function* () { | |
| 310 | yield* TestClock.setTime(Date.parse(NOW)); | |
| 311 | const link = (number: number, state: "open" | "merged" | null) => ({ | |
| 312 | host: "example.test", | |
| 313 | repository: "owner/repository", | |
| 314 | number, | |
| 315 | url: `https://example.test/owner/repository/pull/${number}`, | |
| 316 | source: "manual" as const, | |
| 317 | linkedAt: NOW, | |
| 318 | stack: null, | |
| 319 | snapshot: | |
| 320 | state === null | |
| 321 | ? null | |
| 322 | : { | |
| 323 | state, | |
| 324 | title: "Review", | |
| 325 | headBranch: "feature", | |
| 326 | baseBranch: "main", | |
| 327 | isDraft: false, | |
| 328 | updatedAt: NOW, | |
| 329 | syncedAt: NOW, | |
| 330 | mergedAt: state === "merged" ? NOW : null, | |
| 331 | }, | |
| 332 | }); | |
| 333 | const fixture = yield* makeHarness({ | |
| 334 | snapshot: makeSnapshot([ | |
| 335 | makeThread("merged", { pullRequests: [link(1, "merged"), link(2, "merged")] }), | |
| 336 | makeThread("open", { pullRequests: [link(1, "merged"), link(2, "open")] }), | |
| 337 | makeThread("unsynced", { pullRequests: [link(1, "merged"), link(2, null)] }), | |
| 338 | ]), | |
| 339 | settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleOnMerge: true }, | |
| 340 | branchPullRequest: () => Effect.die("linked threads must not query the branch"), | |
| 341 | pullRequestSummary: () => Effect.die("linked threads must use their snapshots"), | |
| 342 | }); | |
| 343 | yield* Effect.gen(function* () { | |
| 344 | const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 345 | yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 346 | assert.deepStrictEqual( | |
| 347 | (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), | |
| 348 | [ThreadId.make("merged")], | |
| 349 | ); | |
| 350 | assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); | |
| 351 | assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); | |
| 352 | }).pipe(Effect.provide(fixture.layer)); | |
| 353 | }), | |
| 354 | ), | |
| 355 | ); | |
| 304 | 356 | it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => |
| 305 | 357 | Effect.scoped( |
| 306 | 358 | Effect.gen(function* () { |
apps/server/src/orchestration/ThreadSettlementReactor.ts+3-1| ... | ... | @@ -103,7 +103,9 @@ export const make = Effect.gen(function* () { |
| 103 | 103 | { |
| 104 | 104 | concurrency: 8, |
| 105 | 105 | }, |
| 106 | )).filter((thread) => thread !== null); | |
| 106 | )) | |
| 107 | .filter((thread) => thread !== null) | |
| 108 | .filter((thread) => !thread.pullRequests.some((link) => link.source !== "stack-dismissed")); | |
| 107 | 109 | |
| 108 | 110 | // Use the same cwd as PR discovery so both paths share GitManager's cache. |
| 109 | 111 | const lookupCwdByThreadId = new Map<string, string>(); |
apps/server/src/orchestration/commandInvariants.test.ts+2| ... | ... | @@ -59,6 +59,7 @@ const readModel: OrchestrationReadModel = { |
| 59 | 59 | runtimeMode: "full-access", |
| 60 | 60 | branch: null, |
| 61 | 61 | worktreePath: null, |
| 62 | pullRequests: [], | |
| 62 | 63 | createdAt: now, |
| 63 | 64 | updatedAt: now, |
| 64 | 65 | archivedAt: null, |
| ... | ... | @@ -84,6 +85,7 @@ const readModel: OrchestrationReadModel = { |
| 84 | 85 | runtimeMode: "full-access", |
| 85 | 86 | branch: null, |
| 86 | 87 | worktreePath: null, |
| 88 | pullRequests: [], | |
| 87 | 89 | createdAt: now, |
| 88 | 90 | updatedAt: now, |
| 89 | 91 | archivedAt: null, |
apps/server/src/orchestration/decider.active-order.test.ts+1| ... | ... | @@ -33,6 +33,7 @@ function makeReadModel(overrides: Partial<OrchestrationThread> = {}): Orchestrat |
| 33 | 33 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 34 | 34 | runtimeMode: "full-access", |
| 35 | 35 | interactionMode: "default", |
| 36 | pullRequests: [], | |
| 36 | 37 | branch: null, |
| 37 | 38 | worktreePath: null, |
| 38 | 39 | latestTurn: null, |
apps/server/src/orchestration/decider.pinned.test.ts+1| ... | ... | @@ -36,6 +36,7 @@ function makeReadModel(input: { |
| 36 | 36 | interactionMode: "default", |
| 37 | 37 | branch: null, |
| 38 | 38 | worktreePath: null, |
| 39 | pullRequests: [], | |
| 39 | 40 | latestTurn: null, |
| 40 | 41 | createdAt: NOW, |
| 41 | 42 | updatedAt: NOW, |
apps/server/src/orchestration/decider.pullRequests.test.ts created+532| ... | ... | @@ -0,0 +1,532 @@ |
| 1 | import { | |
| 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"; | |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 13 | import { expect, it } from "@effect/vitest"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | ||
| 17 | import { decideOrchestrationCommand } from "./decider.ts"; | |
| 18 | import { projectEvent } from "./projector.ts"; | |
| 19 | import { isThreadDetailEvent } from "../ws.ts"; | |
| 20 | ||
| 21 | const decodeCommand = Schema.decodeUnknownEffect(OrchestrationCommand); | |
| 22 | ||
| 23 | type PlannedEvent = Omit<OrchestrationEvent, "sequence">; | |
| 24 | ||
| 25 | function 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 | ||
| 36 | const NOW = "2026-01-01T00:00:00.000Z"; | |
| 37 | const THREAD_ID = ThreadId.make("thread-1"); | |
| 38 | ||
| 39 | function 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 | ||
| 53 | function 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 | ||
| 107 | const 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 | ||
| 117 | it.layer(NodeServices.layer)("pull request link decider", (it) => { | |
| 118 | it.effect("legacy unlink cannot remove a newer cross-host link", () => | |
| 119 | Effect.gen(function* () { | |
| 120 | const own = makeLink(); | |
| 121 | const foreign = makeLink({ | |
| 122 | host: "github.enterprise.test", | |
| 123 | url: "https://github.enterprise.test/t3tools/t3code/pull/42", | |
| 124 | linkedAt: "2026-01-02T00:00:00Z", | |
| 125 | }); | |
| 126 | const command = yield* decodeCommand({ | |
| 127 | type: "thread.meta.update", | |
| 128 | commandId: "unlink", | |
| 129 | threadId: THREAD_ID, | |
| 130 | linkedPullRequest: null, | |
| 131 | }); | |
| 132 | const decided = yield* decideOrchestrationCommand({ | |
| 133 | readModel: makeReadModel([own, foreign]), | |
| 134 | command, | |
| 135 | }); | |
| 136 | const event = expectSingleEvent(decided, "thread.pull-request-unlinked"); | |
| 137 | expect(event.payload.host).toBe("github.com"); | |
| 138 | }), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("legacy replacement preserves unrelated manual links", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } }); | |
| 144 | const current = makeLink({ linkedAt: "2026-01-02T00:00:00Z" }); | |
| 145 | let model = makeReadModel([other, current]); | |
| 146 | const command = yield* decodeCommand({ | |
| 147 | type: "thread.meta.update", | |
| 148 | commandId: "replace", | |
| 149 | threadId: THREAD_ID, | |
| 150 | linkedPullRequest: { | |
| 151 | projectId: "project-1", | |
| 152 | repository: "t3tools/t3code", | |
| 153 | number: 99, | |
| 154 | url: "https://github.com/t3tools/t3code/pull/99", | |
| 155 | }, | |
| 156 | }); | |
| 157 | const decided = yield* decideOrchestrationCommand({ readModel: model, command }); | |
| 158 | const events = Array.isArray(decided) ? decided : [decided]; | |
| 159 | expect(events.map((event) => event.type)).toEqual([ | |
| 160 | "thread.pull-request-unlinked", | |
| 161 | "thread.pull-request-linked", | |
| 162 | ]); | |
| 163 | for (const event of events) | |
| 164 | model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); | |
| 165 | expect(model.threads[0]!.pullRequests.map((link) => link.number)).toEqual([7, 99]); | |
| 166 | }), | |
| 167 | ); | |
| 168 | it.effect("round-trips an Azure legacy link and unlinks only its organization", () => | |
| 169 | Effect.gen(function* () { | |
| 170 | const foreign = makeLink({ | |
| 171 | host: "dev.azure.com", | |
| 172 | repository: "org-b/project/_git/web", | |
| 173 | number: 7, | |
| 174 | url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7", | |
| 175 | }); | |
| 176 | let model = makeReadModel([foreign]); | |
| 177 | model = { | |
| 178 | ...model, | |
| 179 | projects: model.projects.map((project) => ({ | |
| 180 | ...project, | |
| 181 | repositoryIdentity: { | |
| 182 | ...project.repositoryIdentity!, | |
| 183 | provider: "azure-devops", | |
| 184 | canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web", | |
| 185 | displayName: "v3/org-a/project/web", | |
| 186 | name: "web", | |
| 187 | }, | |
| 188 | })), | |
| 189 | }; | |
| 190 | const legacy = { | |
| 191 | projectId: "project-1", | |
| 192 | repository: "web", | |
| 193 | number: 7, | |
| 194 | url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", | |
| 195 | }; | |
| 196 | for (const linkedPullRequest of [legacy, null]) { | |
| 197 | const command = yield* decodeCommand({ | |
| 198 | type: "thread.meta.update", | |
| 199 | commandId: linkedPullRequest === null ? "unlink-azure" : "link-azure", | |
| 200 | threadId: THREAD_ID, | |
| 201 | linkedPullRequest, | |
| 202 | }); | |
| 203 | const decided = yield* decideOrchestrationCommand({ readModel: model, command }); | |
| 204 | for (const event of Array.isArray(decided) ? decided : [decided]) { | |
| 205 | model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); | |
| 206 | } | |
| 207 | if (linkedPullRequest !== null) { | |
| 208 | expect(model.threads[0]!.linkedPullRequest).toEqual(legacy); | |
| 209 | expect(model.threads[0]!.pullRequests.map((link) => link.repository)).toEqual([ | |
| 210 | "org-b/project/_git/web", | |
| 211 | "org-a/project/_git/web", | |
| 212 | ]); | |
| 213 | } | |
| 214 | } | |
| 215 | expect(model.threads[0]!.pullRequests).toEqual([foreign]); | |
| 216 | expect(model.threads[0]!.linkedPullRequest).toBeNull(); | |
| 217 | }), | |
| 218 | ); | |
| 219 | it.effect("legacy unlink alone does not emit an empty metadata event", () => | |
| 220 | Effect.gen(function* () { | |
| 221 | const command = yield* decodeCommand({ | |
| 222 | type: "thread.meta.update", | |
| 223 | commandId: "unlink", | |
| 224 | threadId: THREAD_ID, | |
| 225 | linkedPullRequest: null, | |
| 226 | }); | |
| 227 | const decided = yield* decideOrchestrationCommand({ | |
| 228 | readModel: makeReadModel([makeLink()]), | |
| 229 | command, | |
| 230 | }); | |
| 231 | const events = Array.isArray(decided) ? decided : [decided]; | |
| 232 | expect(events.map((event) => event.type)).toEqual(["thread.pull-request-unlinked"]); | |
| 233 | }), | |
| 234 | ); | |
| 235 | ||
| 236 | for (const source of ["manual", "agent", "created", "stack"] as const) { | |
| 237 | it.effect(`legacy unlink removes the visible ${source} link and preserves other requests`, () => | |
| 238 | Effect.gen(function* () { | |
| 239 | const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } }); | |
| 240 | const current = makeLink({ source, linkedAt: "2026-01-02T00:00:00.000Z" }); | |
| 241 | let model = makeReadModel([other, current]); | |
| 242 | // This is the pre-array command shape sent by older clients. | |
| 243 | const command = yield* decodeCommand({ | |
| 244 | type: "thread.meta.update", | |
| 245 | commandId: "legacy-unlink", | |
| 246 | threadId: THREAD_ID, | |
| 247 | linkedPullRequest: null, | |
| 248 | title: "Renamed by old client", | |
| 249 | }); | |
| 250 | const decided = yield* decideOrchestrationCommand({ readModel: model, command }); | |
| 251 | const events = Array.isArray(decided) ? decided : [decided]; | |
| 252 | for (const planned of events) { | |
| 253 | const event = { ...planned, sequence: model.snapshotSequence + 1 }; | |
| 254 | const encoded = yield* Schema.encodeEffect(OrchestrationEvent)(event); | |
| 255 | const decoded = yield* Schema.decodeUnknownEffect(OrchestrationEvent)(encoded); | |
| 256 | // Older detail-event unions must never receive the new PR discriminants. | |
| 257 | expect(isThreadDetailEvent(decoded)).toBe(false); | |
| 258 | model = yield* projectEvent(model, decoded); | |
| 259 | } | |
| 260 | const thread = model.threads[0]!; | |
| 261 | expect(thread.title).toBe("Renamed by old client"); | |
| 262 | expect(thread.pullRequests).toEqual( | |
| 263 | source === "stack" ? [other, { ...current, source: "stack-dismissed" }] : [other], | |
| 264 | ); | |
| 265 | // The old single-link field continues to track the remaining visible request. | |
| 266 | expect(thread.linkedPullRequest?.number).toBe(7); | |
| 267 | }), | |
| 268 | ); | |
| 269 | } | |
| 270 | ||
| 271 | it.effect("links a pull request with a normalized key and empty host state", () => | |
| 272 | Effect.gen(function* () { | |
| 273 | const decided = yield* decideOrchestrationCommand({ | |
| 274 | command: { | |
| 275 | type: "thread.pull-request.link", | |
| 276 | commandId: CommandId.make("cmd-link"), | |
| 277 | threadId: THREAD_ID, | |
| 278 | host: " GitHub.com ", | |
| 279 | repository: "T3Tools/T3Code", | |
| 280 | number: 42, | |
| 281 | url: "https://github.com/t3tools/t3code/pull/42", | |
| 282 | source: "manual", | |
| 283 | }, | |
| 284 | readModel: makeReadModel([]), | |
| 285 | }); | |
| 286 | expect(Array.isArray(decided)).toBe(false); | |
| 287 | const event = expectSingleEvent(decided, "thread.pull-request-linked"); | |
| 288 | expect(event.payload.link).toEqual({ | |
| 289 | host: "github.com", | |
| 290 | repository: "t3tools/t3code", | |
| 291 | number: 42, | |
| 292 | url: "https://github.com/t3tools/t3code/pull/42", | |
| 293 | source: "manual", | |
| 294 | linkedAt: event.payload.updatedAt, | |
| 295 | snapshot: null, | |
| 296 | stack: null, | |
| 297 | }); | |
| 298 | expect(event.payload.updatedAt).not.toBe(NOW); | |
| 299 | }), | |
| 300 | ); | |
| 301 | ||
| 302 | it.effect("rejects linking a pull request that is already linked", () => | |
| 303 | Effect.gen(function* () { | |
| 304 | const error = yield* decideOrchestrationCommand({ | |
| 305 | command: { | |
| 306 | type: "thread.pull-request.link", | |
| 307 | commandId: CommandId.make("cmd-link-dup"), | |
| 308 | threadId: THREAD_ID, | |
| 309 | host: "GITHUB.COM", | |
| 310 | repository: "t3tools/t3code", | |
| 311 | number: 42, | |
| 312 | url: "https://github.com/t3tools/t3code/pull/42", | |
| 313 | source: "agent", | |
| 314 | }, | |
| 315 | readModel: makeReadModel([makeLink()]), | |
| 316 | }).pipe(Effect.flip); | |
| 317 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 318 | }), | |
| 319 | ); | |
| 320 | ||
| 321 | it.effect("re-linking a dismissed stack member un-dismisses it", () => | |
| 322 | Effect.gen(function* () { | |
| 323 | const dismissed = makeLink({ | |
| 324 | source: "stack-dismissed", | |
| 325 | snapshot, | |
| 326 | stack: { | |
| 327 | kind: "native", | |
| 328 | id: "stack-1", | |
| 329 | number: 1, | |
| 330 | url: "https://github.com/t3tools/t3code/stack/1", | |
| 331 | base: "main", | |
| 332 | layers: [{ number: 42, headBranch: "feat/links", state: "open" }], | |
| 333 | }, | |
| 334 | }); | |
| 335 | const decided = yield* decideOrchestrationCommand({ | |
| 336 | command: { | |
| 337 | type: "thread.pull-request.link", | |
| 338 | commandId: CommandId.make("cmd-relink"), | |
| 339 | threadId: THREAD_ID, | |
| 340 | host: "github.com", | |
| 341 | repository: "t3tools/t3code", | |
| 342 | number: 42, | |
| 343 | url: "https://github.com/t3tools/t3code/pull/42", | |
| 344 | source: "manual", | |
| 345 | }, | |
| 346 | readModel: makeReadModel([dismissed]), | |
| 347 | }); | |
| 348 | const event = expectSingleEvent(decided, "thread.pull-request-linked"); | |
| 349 | // Host state survives the flip; only the source changes. | |
| 350 | expect(event.payload.link).toEqual({ ...dismissed, source: "manual" }); | |
| 351 | }), | |
| 352 | ); | |
| 353 | ||
| 354 | it.effect("rejects a stack sync re-adding a dismissed stack member", () => | |
| 355 | Effect.gen(function* () { | |
| 356 | const error = yield* decideOrchestrationCommand({ | |
| 357 | command: { | |
| 358 | type: "thread.pull-request.link", | |
| 359 | commandId: CommandId.make("cmd-stack-readd"), | |
| 360 | threadId: THREAD_ID, | |
| 361 | host: "github.com", | |
| 362 | repository: "t3tools/t3code", | |
| 363 | number: 42, | |
| 364 | url: "https://github.com/t3tools/t3code/pull/42", | |
| 365 | source: "stack", | |
| 366 | }, | |
| 367 | readModel: makeReadModel([makeLink({ source: "stack-dismissed" })]), | |
| 368 | }).pipe(Effect.flip); | |
| 369 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 370 | }), | |
| 371 | ); | |
| 372 | ||
| 373 | it.effect("unlinks a manual pull request", () => | |
| 374 | Effect.gen(function* () { | |
| 375 | const decided = yield* decideOrchestrationCommand({ | |
| 376 | command: { | |
| 377 | type: "thread.pull-request.unlink", | |
| 378 | commandId: CommandId.make("cmd-unlink"), | |
| 379 | threadId: THREAD_ID, | |
| 380 | host: "GitHub.com", | |
| 381 | repository: "t3tools/t3code", | |
| 382 | number: 42, | |
| 383 | }, | |
| 384 | readModel: makeReadModel([makeLink()]), | |
| 385 | }); | |
| 386 | const event = expectSingleEvent(decided, "thread.pull-request-unlinked"); | |
| 387 | expect(event.payload).toMatchObject({ | |
| 388 | threadId: THREAD_ID, | |
| 389 | host: "github.com", | |
| 390 | repository: "t3tools/t3code", | |
| 391 | number: 42, | |
| 392 | }); | |
| 393 | }), | |
| 394 | ); | |
| 395 | ||
| 396 | it.effect("unlinking a stack member leaves a stack-dismissed tombstone", () => | |
| 397 | Effect.gen(function* () { | |
| 398 | const member = makeLink({ source: "stack", snapshot }); | |
| 399 | const decided = yield* decideOrchestrationCommand({ | |
| 400 | command: { | |
| 401 | type: "thread.pull-request.unlink", | |
| 402 | commandId: CommandId.make("cmd-unlink-stack"), | |
| 403 | threadId: THREAD_ID, | |
| 404 | host: "github.com", | |
| 405 | repository: "t3tools/t3code", | |
| 406 | number: 42, | |
| 407 | }, | |
| 408 | readModel: makeReadModel([member]), | |
| 409 | }); | |
| 410 | const event = expectSingleEvent(decided, "thread.pull-request-linked"); | |
| 411 | expect(event.payload.link).toEqual({ ...member, source: "stack-dismissed" }); | |
| 412 | }), | |
| 413 | ); | |
| 414 | ||
| 415 | for (const source of ["manual", "agent", "created"] as const) { | |
| 416 | it.effect(`unlinking a ${source} member prevents its sibling rediscovering it`, () => | |
| 417 | Effect.gen(function* () { | |
| 418 | const member = makeLink({ source }); | |
| 419 | const sibling = makeLink({ | |
| 420 | number: 43, | |
| 421 | source: "stack", | |
| 422 | stack: { | |
| 423 | kind: "native", | |
| 424 | id: "stack-1", | |
| 425 | number: 1, | |
| 426 | url: "https://github.com/t3tools/t3code/stack/1", | |
| 427 | base: "main", | |
| 428 | layers: [ | |
| 429 | { number: 42, headBranch: "first", state: "open" }, | |
| 430 | { number: 43, headBranch: "second", state: "open" }, | |
| 431 | ], | |
| 432 | }, | |
| 433 | }); | |
| 434 | let model = makeReadModel([member, sibling]); | |
| 435 | const decided = yield* decideOrchestrationCommand({ | |
| 436 | readModel: model, | |
| 437 | command: { | |
| 438 | type: "thread.pull-request.unlink", | |
| 439 | commandId: CommandId.make("remove"), | |
| 440 | threadId: THREAD_ID, | |
| 441 | host: member.host, | |
| 442 | repository: member.repository, | |
| 443 | number: member.number, | |
| 444 | }, | |
| 445 | }); | |
| 446 | const event = expectSingleEvent(decided, "thread.pull-request-linked"); | |
| 447 | model = yield* projectEvent(model, { ...event, sequence: 1 }); | |
| 448 | expect(model.threads[0]!.pullRequests).toEqual([ | |
| 449 | { ...member, source: "stack-dismissed" }, | |
| 450 | sibling, | |
| 451 | ]); | |
| 452 | const rediscovered = yield* decideOrchestrationCommand({ | |
| 453 | readModel: model, | |
| 454 | command: { | |
| 455 | type: "thread.pull-request.link", | |
| 456 | commandId: CommandId.make("rediscovered"), | |
| 457 | threadId: THREAD_ID, | |
| 458 | host: member.host, | |
| 459 | repository: member.repository, | |
| 460 | number: member.number, | |
| 461 | url: member.url, | |
| 462 | source: "stack", | |
| 463 | }, | |
| 464 | }).pipe(Effect.result); | |
| 465 | expect(rediscovered._tag).toBe("Failure"); | |
| 466 | }), | |
| 467 | ); | |
| 468 | } | |
| 469 | ||
| 470 | it.effect("rejects unlinking a pull request that is not linked", () => | |
| 471 | Effect.gen(function* () { | |
| 472 | const error = yield* decideOrchestrationCommand({ | |
| 473 | command: { | |
| 474 | type: "thread.pull-request.unlink", | |
| 475 | commandId: CommandId.make("cmd-unlink-missing"), | |
| 476 | threadId: THREAD_ID, | |
| 477 | host: "github.com", | |
| 478 | repository: "t3tools/t3code", | |
| 479 | number: 7, | |
| 480 | }, | |
| 481 | readModel: makeReadModel([makeLink()]), | |
| 482 | }).pipe(Effect.flip); | |
| 483 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 484 | }), | |
| 485 | ); | |
| 486 | ||
| 487 | it.effect("rejects syncing a pull request that is not linked", () => | |
| 488 | Effect.gen(function* () { | |
| 489 | const error = yield* decideOrchestrationCommand({ | |
| 490 | command: { | |
| 491 | type: "thread.pull-request-link.sync", | |
| 492 | commandId: CommandId.make("cmd-sync-missing"), | |
| 493 | threadId: THREAD_ID, | |
| 494 | host: "github.com", | |
| 495 | repository: "t3tools/t3code", | |
| 496 | number: 42, | |
| 497 | snapshot, | |
| 498 | stack: null, | |
| 499 | }, | |
| 500 | readModel: makeReadModel([]), | |
| 501 | }).pipe(Effect.flip); | |
| 502 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | |
| 503 | }), | |
| 504 | ); | |
| 505 | ||
| 506 | it.effect("sync emits the host snapshot for a linked pull request", () => | |
| 507 | Effect.gen(function* () { | |
| 508 | const decided = yield* decideOrchestrationCommand({ | |
| 509 | command: { | |
| 510 | type: "thread.pull-request-link.sync", | |
| 511 | commandId: CommandId.make("cmd-sync"), | |
| 512 | threadId: THREAD_ID, | |
| 513 | host: "GitHub.com", | |
| 514 | repository: "t3tools/t3code", | |
| 515 | number: 42, | |
| 516 | snapshot, | |
| 517 | stack: null, | |
| 518 | }, | |
| 519 | readModel: makeReadModel([makeLink()]), | |
| 520 | }); | |
| 521 | const event = expectSingleEvent(decided, "thread.pull-request-synced"); | |
| 522 | expect(event.payload).toMatchObject({ | |
| 523 | threadId: THREAD_ID, | |
| 524 | host: "github.com", | |
| 525 | repository: "t3tools/t3code", | |
| 526 | number: 42, | |
| 527 | snapshot, | |
| 528 | stack: null, | |
| 529 | }); | |
| 530 | }), | |
| 531 | ); | |
| 532 | }); |
apps/server/src/orchestration/decider.questionAttachments.test.ts+1| ... | ... | @@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { |
| 26 | 26 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 27 | 27 | runtimeMode: "full-access", |
| 28 | 28 | interactionMode: "default", |
| 29 | pullRequests: [], | |
| 29 | 30 | branch: null, |
| 30 | 31 | worktreePath: null, |
| 31 | 32 | latestTurn: null, |
apps/server/src/orchestration/decider.settled.test.ts+1| ... | ... | @@ -47,6 +47,7 @@ function makeReadModel( |
| 47 | 47 | interactionMode: "default", |
| 48 | 48 | branch: null, |
| 49 | 49 | worktreePath: null, |
| 50 | pullRequests: [], | |
| 50 | 51 | latestTurn: null, |
| 51 | 52 | createdAt: NOW, |
| 52 | 53 | updatedAt: NOW, |
apps/server/src/orchestration/decider.snoozed.test.ts+1| ... | ... | @@ -41,6 +41,7 @@ function makeReadModel(input: { |
| 41 | 41 | interactionMode: "default", |
| 42 | 42 | branch: null, |
| 43 | 43 | worktreePath: null, |
| 44 | pullRequests: [], | |
| 44 | 45 | latestTurn: null, |
| 45 | 46 | createdAt: NOW, |
| 46 | 47 | updatedAt: NOW, |
apps/server/src/orchestration/decider.titleRegeneration.test.ts+1| ... | ... | @@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { |
| 26 | 26 | interactionMode: "default", |
| 27 | 27 | branch: null, |
| 28 | 28 | worktreePath: null, |
| 29 | pullRequests: [], | |
| 29 | 30 | latestTurn: null, |
| 30 | 31 | createdAt: UPDATED_AT, |
| 31 | 32 | updatedAt: UPDATED_AT, |
apps/server/src/orchestration/decider.ts+221| ... | ... | @@ -10,8 +10,16 @@ import { |
| 10 | 10 | type OrchestrationEvent, |
| 11 | 11 | type OrchestrationReadModel, |
| 12 | 12 | type OrchestrationThread, |
| 13 | type ThreadPullRequestKey, | |
| 14 | type ThreadPullRequestLink, | |
| 13 | 15 | type OrchestrationThreadActivity, |
| 14 | 16 | } from "@t3tools/contracts"; |
| 17 | import { | |
| 18 | legacyLinkedPullRequestOf, | |
| 19 | legacyThreadPullRequestKey, | |
| 20 | normalizeThreadPullRequestKey, | |
| 21 | threadPullRequestKeysEqual, | |
| 22 | } from "@t3tools/shared/threadPullRequests"; | |
| 15 | 23 | import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; |
| 16 | 24 | import * as DateTime from "effect/DateTime"; |
| 17 | 25 | import * as Crypto from "effect/Crypto"; |
| ... | ... | @@ -121,6 +129,13 @@ function hasQueuedTurnStartForThread( |
| 121 | 129 | ); |
| 122 | 130 | } |
| 123 | 131 | |
| 132 | function findPullRequestLink( | |
| 133 | thread: Pick<OrchestrationThread, "pullRequests">, | |
| 134 | key: ThreadPullRequestKey, | |
| 135 | ): ThreadPullRequestLink | undefined { | |
| 136 | return thread.pullRequests.find((link) => threadPullRequestKeysEqual(link, key)); | |
| 137 | } | |
| 138 | ||
| 124 | 139 | function withEventBase( |
| 125 | 140 | input: Pick<OrchestrationCommand, "commandId"> & { |
| 126 | 141 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; |
| ... | ... | @@ -876,6 +891,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 876 | 891 | command, |
| 877 | 892 | threadId: command.threadId, |
| 878 | 893 | }); |
| 894 | // Old clients only see the derived single link. Unlink that request through | |
| 895 | // the same command path as modern clients, including stack dismissal, while | |
| 896 | // retaining other links they cannot see. Historical metadata events still replay unchanged. | |
| 897 | const legacy = legacyLinkedPullRequestOf( | |
| 898 | thread.pullRequests, | |
| 899 | thread.projectId, | |
| 900 | readModel.projects.find((project) => project.id === thread.projectId)?.repositoryIdentity, | |
| 901 | ); | |
| 902 | const currentPullRequest = | |
| 903 | legacy === null | |
| 904 | ? null | |
| 905 | : (thread.pullRequests.find( | |
| 906 | (link) => link.url === legacy.url && link.number === legacy.number, | |
| 907 | ) ?? null); | |
| 908 | if (command.linkedPullRequest != null) { | |
| 909 | const { linkedPullRequest: linked, ...metadata } = command; | |
| 910 | const project = readModel.projects.find((project) => project.id === thread.projectId); | |
| 911 | let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"; | |
| 912 | try { | |
| 913 | host = new URL(linked.url).hostname; | |
| 914 | } catch { | |
| 915 | // Historical clients can send links without a parseable URL. | |
| 916 | } | |
| 917 | const hasMetadata = Object.entries(metadata).some( | |
| 918 | ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, | |
| 919 | ); | |
| 920 | return yield* decideCommandSequence({ | |
| 921 | readModel, | |
| 922 | commands: [ | |
| 923 | ...(hasMetadata ? [metadata] : []), | |
| 924 | ...(currentPullRequest?.source === "manual" | |
| 925 | ? [ | |
| 926 | { | |
| 927 | type: "thread.pull-request.unlink" as const, | |
| 928 | commandId: command.commandId, | |
| 929 | threadId: command.threadId, | |
| 930 | host: currentPullRequest.host, | |
| 931 | repository: currentPullRequest.repository, | |
| 932 | number: currentPullRequest.number, | |
| 933 | }, | |
| 934 | ] | |
| 935 | : []), | |
| 936 | { | |
| 937 | type: "thread.pull-request.link", | |
| 938 | commandId: command.commandId, | |
| 939 | threadId: command.threadId, | |
| 940 | ...legacyThreadPullRequestKey(linked, host), | |
| 941 | url: linked.url, | |
| 942 | source: "manual", | |
| 943 | }, | |
| 944 | ], | |
| 945 | }); | |
| 946 | } | |
| 947 | ||
| 948 | if (command.linkedPullRequest === null && currentPullRequest !== null) { | |
| 949 | const { linkedPullRequest: _linkedPullRequest, ...metadata } = command; | |
| 950 | const hasMetadata = Object.entries(metadata).some( | |
| 951 | ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, | |
| 952 | ); | |
| 953 | return yield* decideCommandSequence({ | |
| 954 | readModel, | |
| 955 | commands: [ | |
| 956 | ...(hasMetadata ? [metadata] : []), | |
| 957 | { | |
| 958 | type: "thread.pull-request.unlink", | |
| 959 | commandId: command.commandId, | |
| 960 | threadId: command.threadId, | |
| 961 | host: currentPullRequest.host, | |
| 962 | repository: currentPullRequest.repository, | |
| 963 | number: currentPullRequest.number, | |
| 964 | }, | |
| 965 | ], | |
| 966 | }); | |
| 967 | } | |
| 879 | 968 | const branch = |
| 880 | 969 | command.branch !== undefined && |
| 881 | 970 | command.expectedBranch !== undefined && |
| ... | ... | @@ -920,6 +1009,138 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 920 | 1009 | }; |
| 921 | 1010 | } |
| 922 | 1011 | |
| 1012 | case "thread.pull-request.link": { | |
| 1013 | const thread = yield* requireThread({ | |
| 1014 | readModel, | |
| 1015 | command, | |
| 1016 | threadId: command.threadId, | |
| 1017 | }); | |
| 1018 | const key = normalizeThreadPullRequestKey(command); | |
| 1019 | const existing = findPullRequestLink(thread, key); | |
| 1020 | // An explicit link on a dismissed stack member un-dismisses it; any | |
| 1021 | // other duplicate is a no-op the engine would reject as zero-event. | |
| 1022 | const undismisses = | |
| 1023 | existing?.source === "stack-dismissed" && | |
| 1024 | (command.source === "manual" || command.source === "agent" || command.source === "created"); | |
| 1025 | if (existing !== undefined && !undismisses) { | |
| 1026 | return yield* new OrchestrationCommandInvariantError({ | |
| 1027 | commandType: command.type, | |
| 1028 | detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`, | |
| 1029 | }); | |
| 1030 | } | |
| 1031 | const occurredAt = yield* nowIso; | |
| 1032 | return { | |
| 1033 | ...(yield* withEventBase({ | |
| 1034 | aggregateKind: "thread", | |
| 1035 | aggregateId: command.threadId, | |
| 1036 | occurredAt, | |
| 1037 | commandId: command.commandId, | |
| 1038 | })), | |
| 1039 | type: "thread.pull-request-linked", | |
| 1040 | payload: { | |
| 1041 | threadId: command.threadId, | |
| 1042 | link: | |
| 1043 | existing !== undefined | |
| 1044 | ? { ...existing, url: command.url, source: command.source } | |
| 1045 | : { | |
| 1046 | ...key, | |
| 1047 | url: command.url, | |
| 1048 | source: command.source, | |
| 1049 | linkedAt: occurredAt, | |
| 1050 | snapshot: null, | |
| 1051 | stack: null, | |
| 1052 | }, | |
| 1053 | updatedAt: occurredAt, | |
| 1054 | }, | |
| 1055 | }; | |
| 1056 | } | |
| 1057 | ||
| 1058 | case "thread.pull-request.unlink": { | |
| 1059 | const thread = yield* requireThread({ | |
| 1060 | readModel, | |
| 1061 | command, | |
| 1062 | threadId: command.threadId, | |
| 1063 | }); | |
| 1064 | const key = normalizeThreadPullRequestKey(command); | |
| 1065 | const existing = findPullRequestLink(thread, key); | |
| 1066 | if (existing === undefined) { | |
| 1067 | return yield* new OrchestrationCommandInvariantError({ | |
| 1068 | commandType: command.type, | |
| 1069 | detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, | |
| 1070 | }); | |
| 1071 | } | |
| 1072 | const occurredAt = yield* nowIso; | |
| 1073 | const eventBase = yield* withEventBase({ | |
| 1074 | aggregateKind: "thread", | |
| 1075 | aggregateId: command.threadId, | |
| 1076 | occurredAt, | |
| 1077 | commandId: command.commandId, | |
| 1078 | }); | |
| 1079 | // Any known native-stack member needs a tombstone, regardless of who linked it. | |
| 1080 | // A sibling can rediscover it even before this link has its own stack snapshot. | |
| 1081 | const belongsToStack = | |
| 1082 | existing.source === "stack" || | |
| 1083 | existing.stack !== null || | |
| 1084 | thread.pullRequests.some( | |
| 1085 | (link) => | |
| 1086 | link.host.toLowerCase() === key.host && | |
| 1087 | link.repository.toLowerCase() === key.repository && | |
| 1088 | link.stack?.layers.some((layer) => layer.number === key.number), | |
| 1089 | ); | |
| 1090 | if (belongsToStack) { | |
| 1091 | return { | |
| 1092 | ...eventBase, | |
| 1093 | type: "thread.pull-request-linked", | |
| 1094 | payload: { | |
| 1095 | threadId: command.threadId, | |
| 1096 | link: { ...existing, source: "stack-dismissed" }, | |
| 1097 | updatedAt: occurredAt, | |
| 1098 | }, | |
| 1099 | }; | |
| 1100 | } | |
| 1101 | return { | |
| 1102 | ...eventBase, | |
| 1103 | type: "thread.pull-request-unlinked", | |
| 1104 | payload: { | |
| 1105 | threadId: command.threadId, | |
| 1106 | ...key, | |
| 1107 | updatedAt: occurredAt, | |
| 1108 | }, | |
| 1109 | }; | |
| 1110 | } | |
| 1111 | ||
| 1112 | case "thread.pull-request-link.sync": { | |
| 1113 | const thread = yield* requireThread({ | |
| 1114 | readModel, | |
| 1115 | command, | |
| 1116 | threadId: command.threadId, | |
| 1117 | }); | |
| 1118 | const key = normalizeThreadPullRequestKey(command); | |
| 1119 | if (findPullRequestLink(thread, key) === undefined) { | |
| 1120 | return yield* new OrchestrationCommandInvariantError({ | |
| 1121 | commandType: command.type, | |
| 1122 | detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, | |
| 1123 | }); | |
| 1124 | } | |
| 1125 | const occurredAt = yield* nowIso; | |
| 1126 | return { | |
| 1127 | ...(yield* withEventBase({ | |
| 1128 | aggregateKind: "thread", | |
| 1129 | aggregateId: command.threadId, | |
| 1130 | occurredAt, | |
| 1131 | commandId: command.commandId, | |
| 1132 | })), | |
| 1133 | type: "thread.pull-request-synced", | |
| 1134 | payload: { | |
| 1135 | threadId: command.threadId, | |
| 1136 | ...key, | |
| 1137 | snapshot: command.snapshot, | |
| 1138 | stack: command.stack, | |
| 1139 | updatedAt: occurredAt, | |
| 1140 | }, | |
| 1141 | }; | |
| 1142 | } | |
| 1143 | ||
| 923 | 1144 | case "thread.pull-request.sync": { |
| 924 | 1145 | const thread = yield* requireThreadNotArchived({ |
| 925 | 1146 | readModel, |
apps/server/src/orchestration/decider.userInputDismiss.test.ts+1| ... | ... | @@ -49,6 +49,7 @@ function makeReadModel( |
| 49 | 49 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 50 | 50 | runtimeMode: "full-access", |
| 51 | 51 | interactionMode: "default", |
| 52 | pullRequests: [], | |
| 52 | 53 | branch: null, |
| 53 | 54 | worktreePath: null, |
| 54 | 55 | latestTurn: null, |
apps/server/src/orchestration/projector.pullRequests.test.ts created+417| ... | ... | @@ -0,0 +1,417 @@ |
| 1 | import { | |
| 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"; | |
| 12 | import { expect, it } from "@effect/vitest"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | ||
| 15 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | |
| 16 | ||
| 17 | const NOW = "2026-01-01T00:00:00.000Z"; | |
| 18 | const LATER = "2026-01-02T00:00:00.000Z"; | |
| 19 | const THREAD_ID = ThreadId.make("thread-1"); | |
| 20 | const PROJECT_ID = ProjectId.make("project-1"); | |
| 21 | ||
| 22 | function 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 | ||
| 42 | function 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 | ||
| 56 | const 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 | ||
| 66 | const 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 | ||
| 87 | const 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 | ||
| 113 | it.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 | ||
| 121 | it.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 | ||
| 209 | it.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 | ||
| 242 | it.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 | ||
| 326 | it.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 | ||
| 350 | it.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 | ||
| 375 | it.effect("replays Azure legacy selectors as full repository keys", () => | |
| 376 | Effect.gen(function* () { | |
| 377 | const withProject = yield* createProject(createEmptyReadModel(NOW), { | |
| 378 | canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web", | |
| 379 | provider: "azure-devops", | |
| 380 | displayName: "v3/org-a/project/web", | |
| 381 | name: "web", | |
| 382 | locator: { | |
| 383 | source: "git-remote", | |
| 384 | remoteName: "origin", | |
| 385 | remoteUrl: "git@ssh.dev.azure.com:v3/org-a/project/web", | |
| 386 | }, | |
| 387 | }); | |
| 388 | const created = yield* createThread(withProject); | |
| 389 | const legacy = { | |
| 390 | projectId: PROJECT_ID, | |
| 391 | repository: "web", | |
| 392 | number: 7, | |
| 393 | url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", | |
| 394 | }; | |
| 395 | const model = yield* projectEvent( | |
| 396 | created, | |
| 397 | makeEvent({ | |
| 398 | sequence: 3, | |
| 399 | type: "thread.meta-updated", | |
| 400 | payload: { threadId: THREAD_ID, linkedPullRequest: legacy, updatedAt: LATER }, | |
| 401 | }), | |
| 402 | ); | |
| 403 | expect(model.threads[0]?.pullRequests).toEqual([ | |
| 404 | { | |
| 405 | host: "dev.azure.com", | |
| 406 | repository: "org-a/project/_git/web", | |
| 407 | number: 7, | |
| 408 | url: legacy.url, | |
| 409 | source: "manual", | |
| 410 | linkedAt: LATER, | |
| 411 | snapshot: null, | |
| 412 | stack: null, | |
| 413 | }, | |
| 414 | ]); | |
| 415 | expect(model.threads[0]?.linkedPullRequest).toEqual(legacy); | |
| 416 | }), | |
| 417 | ); |
apps/server/src/orchestration/projector.test.ts+26-1| ... | ... | @@ -86,6 +86,7 @@ describe("orchestration projector", () => { |
| 86 | 86 | interactionMode: "default", |
| 87 | 87 | branch: null, |
| 88 | 88 | worktreePath: null, |
| 89 | pullRequests: [], | |
| 89 | 90 | branchPullRequest: null, |
| 90 | 91 | latestTurn: null, |
| 91 | 92 | createdAt: now, |
| ... | ... | @@ -117,7 +118,31 @@ describe("orchestration projector", () => { |
| 117 | 118 | commandId: null, |
| 118 | 119 | }; |
| 119 | 120 | let model = yield* projectEvent( |
| 120 | createEmptyReadModel(now), | |
| 121 | { | |
| 122 | ...createEmptyReadModel(now), | |
| 123 | projects: [ | |
| 124 | { | |
| 125 | id: ProjectId.make("project-1"), | |
| 126 | title: "T3 Code", | |
| 127 | workspaceRoot: "/repo", | |
| 128 | defaultModelSelection: null, | |
| 129 | scripts: [], | |
| 130 | createdAt: now, | |
| 131 | updatedAt: now, | |
| 132 | deletedAt: null, | |
| 133 | repositoryIdentity: { | |
| 134 | canonicalKey: "github.com/pingdotgg/t3code", | |
| 135 | provider: "github", | |
| 136 | displayName: "pingdotgg/t3code", | |
| 137 | locator: { | |
| 138 | source: "git-remote", | |
| 139 | remoteName: "origin", | |
| 140 | remoteUrl: "https://github.com/pingdotgg/t3code.git", | |
| 141 | }, | |
| 142 | }, | |
| 143 | }, | |
| 144 | ], | |
| 145 | }, | |
| 121 | 146 | makeEvent({ |
| 122 | 147 | ...eventFields, |
| 123 | 148 | sequence: 1, |
apps/server/src/orchestration/projector.ts+212-25| ... | ... | @@ -1,4 +1,12 @@ |
| 1 | import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | OrchestrationEvent, | |
| 3 | OrchestrationProject, | |
| 4 | OrchestrationReadModel, | |
| 5 | ThreadId, | |
| 6 | ThreadLinkedPullRequest, | |
| 7 | ThreadPullRequestKey, | |
| 8 | ThreadPullRequestLink, | |
| 9 | } from "@t3tools/contracts"; | |
| 2 | 10 | import { |
| 3 | 11 | isImportedAgentSessionMessageId, |
| 4 | 12 | OrchestrationCheckpointSummary, |
| ... | ... | @@ -6,6 +14,11 @@ import { |
| 6 | 14 | OrchestrationSession, |
| 7 | 15 | OrchestrationThread, |
| 8 | 16 | } from "@t3tools/contracts"; |
| 17 | import { | |
| 18 | legacyLinkedPullRequestOf, | |
| 19 | legacyThreadPullRequestKey, | |
| 20 | threadPullRequestKeysEqual, | |
| 21 | } from "@t3tools/shared/threadPullRequests"; | |
| 9 | 22 | import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; |
| 10 | 23 | import * as Effect from "effect/Effect"; |
| 11 | 24 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -28,6 +41,9 @@ import { |
| 28 | 41 | ThreadSettledPayload, |
| 29 | 42 | ThreadPinnedPayload, |
| 30 | 43 | ThreadPinReorderedPayload, |
| 44 | ThreadPullRequestLinkedPayload, | |
| 45 | ThreadPullRequestSyncedPayload, | |
| 46 | ThreadPullRequestUnlinkedPayload, | |
| 31 | 47 | ThreadSnoozedPayload, |
| 32 | 48 | ThreadUnpinnedPayload, |
| 33 | 49 | ThreadUnarchivedPayload, |
| ... | ... | @@ -101,6 +117,77 @@ function updateThread( |
| 101 | 117 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); |
| 102 | 118 | } |
| 103 | 119 | |
| 120 | /** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */ | |
| 121 | function 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 | ||
| 136 | function 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 | ||
| 146 | function 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 | */ | |
| 158 | function 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 | ||
| 171 | function 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 | ||
| 104 | 191 | function decodeForEvent<A>( |
| 105 | 192 | schema: Schema.Decoder<A, never>, |
| 106 | 193 | value: unknown, |
| ... | ... | @@ -336,6 +423,7 @@ export function projectEvent( |
| 336 | 423 | interactionMode: payload.interactionMode, |
| 337 | 424 | branch: payload.branch, |
| 338 | 425 | worktreePath: payload.worktreePath, |
| 426 | pullRequests: [], | |
| 339 | 427 | branchPullRequest: null, |
| 340 | 428 | latestTurn: null, |
| 341 | 429 | createdAt: payload.createdAt, |
| ... | ... | @@ -498,30 +586,129 @@ export function projectEvent( |
| 498 | 586 | |
| 499 | 587 | case "thread.meta-updated": |
| 500 | 588 | 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 | }), | |
| 525 | 712 | ); |
| 526 | 713 | |
| 527 | 714 | case "thread.runtime-mode-set": |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+167-1| ... | ... | @@ -15,15 +15,21 @@ import * as Statement from "effect/unstable/sql/Statement"; |
| 15 | 15 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; |
| 16 | 16 | import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; |
| 17 | 17 | import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; |
| 18 | import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; | |
| 18 | import * as ProjectionThreadPullRequests from "../ProjectionThreadPullRequests.ts"; | |
| 19 | 19 | import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; |
| 20 | 20 | import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; |
| 21 | import { | |
| 22 | ProjectionThreadPullRequestRepository, | |
| 23 | type ProjectionThreadPullRequest, | |
| 24 | } from "../ProjectionThreadPullRequests.ts"; | |
| 25 | import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; | |
| 21 | 26 | import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts"; |
| 22 | 27 | |
| 23 | 28 | const projectionRepositoriesLayer = it.layer( |
| 24 | 29 | Layer.mergeAll( |
| 25 | 30 | ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), |
| 26 | 31 | ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), |
| 32 | ProjectionThreadPullRequests.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 27 | 33 | ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), |
| 28 | 34 | SqlitePersistenceMemory, |
| 29 | 35 | ), |
| ... | ... | @@ -529,4 +535,164 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 529 | 535 | assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest); |
| 530 | 536 | }), |
| 531 | 537 | ); |
| 538 | ||
| 539 | it.effect("uses one Azure identity for repository writes, lookups, and deletion", () => | |
| 540 | Effect.gen(function* () { | |
| 541 | const pullRequests = yield* ProjectionThreadPullRequestRepository; | |
| 542 | const threadId = ThreadId.make("azure-alias-link"); | |
| 543 | const row: ProjectionThreadPullRequest = { | |
| 544 | threadId, | |
| 545 | host: "org.visualstudio.com", | |
| 546 | repository: "project/_git/web", | |
| 547 | number: 7, | |
| 548 | url: "https://org.visualstudio.com/project/_git/web/pullrequest/7", | |
| 549 | source: "manual", | |
| 550 | linkedAt: "2026-09-09T00:00:00.000Z", | |
| 551 | snapshot: null, | |
| 552 | stack: null, | |
| 553 | }; | |
| 554 | yield* pullRequests.upsert(row); | |
| 555 | yield* pullRequests.upsert({ | |
| 556 | ...row, | |
| 557 | host: "dev.azure.com", | |
| 558 | repository: "org/project/_git/web", | |
| 559 | }); | |
| 560 | const found = yield* pullRequests.listByPullRequest({ | |
| 561 | host: "ssh.dev.azure.com", | |
| 562 | repository: "v3/org/project/web", | |
| 563 | number: 7, | |
| 564 | }); | |
| 565 | assert.deepStrictEqual(found, [ | |
| 566 | { ...row, host: "dev.azure.com", repository: "org/project/_git/web" }, | |
| 567 | ]); | |
| 568 | assert.deepStrictEqual( | |
| 569 | yield* pullRequests.listByPullRequest({ | |
| 570 | host: "dev.azure.com", | |
| 571 | repository: "other/project/_git/web", | |
| 572 | number: 7, | |
| 573 | }), | |
| 574 | [], | |
| 575 | ); | |
| 576 | yield* pullRequests.delete({ | |
| 577 | threadId, | |
| 578 | host: "vs-ssh.visualstudio.com", | |
| 579 | repository: "v3/org/project/web", | |
| 580 | number: 7, | |
| 581 | }); | |
| 582 | assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []); | |
| 583 | }), | |
| 584 | ); | |
| 585 | ||
| 586 | it.effect("round-trips pull request links with JSON snapshot and stack columns", () => | |
| 587 | Effect.gen(function* () { | |
| 588 | const pullRequests = yield* ProjectionThreadPullRequestRepository; | |
| 589 | const sql = yield* SqlClient.SqlClient; | |
| 590 | const threadId = ThreadId.make("thread-pr-links"); | |
| 591 | const otherThreadId = ThreadId.make("thread-pr-links-other"); | |
| 592 | ||
| 593 | const unsynced: ProjectionThreadPullRequest = { | |
| 594 | threadId, | |
| 595 | host: "github.com", | |
| 596 | repository: "pingdotgg/t3code", | |
| 597 | number: 42, | |
| 598 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 599 | source: "manual", | |
| 600 | linkedAt: "2026-03-24T00:00:00.000Z", | |
| 601 | snapshot: null, | |
| 602 | stack: null, | |
| 603 | }; | |
| 604 | const synced: ProjectionThreadPullRequest = { | |
| 605 | threadId, | |
| 606 | host: "github.com", | |
| 607 | repository: "pingdotgg/t3code", | |
| 608 | number: 7, | |
| 609 | url: "https://github.com/pingdotgg/t3code/pull/7", | |
| 610 | source: "stack", | |
| 611 | linkedAt: "2026-03-23T00:00:00.000Z", | |
| 612 | snapshot: { | |
| 613 | state: "open", | |
| 614 | title: "Add links", | |
| 615 | headBranch: "feat/links", | |
| 616 | baseBranch: "main", | |
| 617 | isDraft: false, | |
| 618 | updatedAt: "2026-03-23T01:00:00.000Z", | |
| 619 | syncedAt: "2026-03-23T02:00:00.000Z", | |
| 620 | }, | |
| 621 | stack: { | |
| 622 | kind: "native", | |
| 623 | id: "stack-1", | |
| 624 | number: 1, | |
| 625 | url: "https://github.com/pingdotgg/t3code/stack/1", | |
| 626 | base: "main", | |
| 627 | layers: [ | |
| 628 | { number: 7, headBranch: "feat/links", state: "open" }, | |
| 629 | { number: 42, headBranch: "feat/links-ui", state: "open" }, | |
| 630 | ], | |
| 631 | }, | |
| 632 | }; | |
| 633 | const sharedOnOtherThread: ProjectionThreadPullRequest = { | |
| 634 | ...unsynced, | |
| 635 | threadId: otherThreadId, | |
| 636 | source: "agent", | |
| 637 | linkedAt: "2026-03-25T00:00:00.000Z", | |
| 638 | }; | |
| 639 | ||
| 640 | yield* pullRequests.upsert(unsynced); | |
| 641 | yield* pullRequests.upsert(synced); | |
| 642 | yield* pullRequests.upsert(sharedOnOtherThread); | |
| 643 | ||
| 644 | const rawRows = yield* sql<{ | |
| 645 | readonly number: number; | |
| 646 | readonly snapshotJson: string | null; | |
| 647 | readonly stackJson: string | null; | |
| 648 | }>` | |
| 649 | SELECT number, snapshot_json AS "snapshotJson", stack_json AS "stackJson" | |
| 650 | FROM projection_thread_pull_requests | |
| 651 | WHERE thread_id = ${threadId} | |
| 652 | ORDER BY number ASC | |
| 653 | `; | |
| 654 | assert.strictEqual(rawRows[0]?.number, 7); | |
| 655 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 656 | assert.deepStrictEqual(JSON.parse(rawRows[0]?.snapshotJson ?? "null"), synced.snapshot); | |
| 657 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 658 | assert.deepStrictEqual(JSON.parse(rawRows[0]?.stackJson ?? "null"), synced.stack); | |
| 659 | assert.strictEqual(rawRows[1]?.snapshotJson, null); | |
| 660 | assert.strictEqual(rawRows[1]?.stackJson, null); | |
| 661 | ||
| 662 | // Ordered by linked_at, then number. | |
| 663 | assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, unsynced]); | |
| 664 | ||
| 665 | // One pull request across threads, ordered by linked_at. | |
| 666 | assert.deepStrictEqual( | |
| 667 | yield* pullRequests.listByPullRequest({ | |
| 668 | host: "github.com", | |
| 669 | repository: "pingdotgg/t3code", | |
| 670 | number: 42, | |
| 671 | }), | |
| 672 | [unsynced, sharedOnOtherThread], | |
| 673 | ); | |
| 674 | ||
| 675 | // Upsert on the composite key replaces snapshot and stack in place. | |
| 676 | const resynced = { ...unsynced, snapshot: synced.snapshot, stack: null } as const; | |
| 677 | yield* pullRequests.upsert(resynced); | |
| 678 | assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, resynced]); | |
| 679 | ||
| 680 | yield* pullRequests.delete({ | |
| 681 | threadId, | |
| 682 | host: "github.com", | |
| 683 | repository: "pingdotgg/t3code", | |
| 684 | number: 7, | |
| 685 | }); | |
| 686 | assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [resynced]); | |
| 687 | ||
| 688 | yield* pullRequests.deleteByThreadIdAndSource({ threadId, source: "manual" }); | |
| 689 | assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []); | |
| 690 | assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), [ | |
| 691 | sharedOnOtherThread, | |
| 692 | ]); | |
| 693 | ||
| 694 | yield* pullRequests.deleteByThreadId({ threadId: otherThreadId }); | |
| 695 | assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), []); | |
| 696 | }), | |
| 697 | ); | |
| 532 | 698 | }); |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps. |
| 61 | 61 | import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; |
| 62 | 62 | import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; |
| 63 | 63 | import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; |
| 64 | import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; | |
| 64 | 65 | |
| 65 | 66 | /** |
| 66 | 67 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -122,6 +123,7 @@ const migrationEntries = [ |
| 122 | 123 | [47, "ProjectionProjectIcon", Migration0047], |
| 123 | 124 | [48, "ProjectionThreadBranchPullRequest", Migration0048], |
| 124 | 125 | [49, "ProjectionThreadsActiveOrderKey", Migration0049], |
| 126 | [50, "ProjectionThreadPullRequests", Migration0050], | |
| 125 | 127 | ] as const; |
| 126 | 128 | |
| 127 | 129 | export 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 6 | ||
| 7 | import { runMigrations } from "../Migrations.ts"; | |
| 8 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | |
| 9 | ||
| 10 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 11 | ||
| 12 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | |
| 13 | ||
| 14 | interface 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 | ||
| 26 | layer("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 | ||
| 159 | it.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 @@ |
| 1 | import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | ||
| 5 | interface LegacyLinkedThreadRow { | |
| 6 | readonly threadId: string; | |
| 7 | readonly updatedAt: string; | |
| 8 | readonly linkedPullRequestJson: string; | |
| 9 | } | |
| 10 | ||
| 11 | interface LegacyLinkedPullRequest { | |
| 12 | readonly repository: string; | |
| 13 | readonly number: number; | |
| 14 | readonly url: string; | |
| 15 | } | |
| 16 | ||
| 17 | function 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 | ||
| 31 | export 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 @@ |
| 1 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | |
| 2 | import { | |
| 3 | IsoDateTime, | |
| 4 | PositiveInt, | |
| 5 | ThreadId, | |
| 6 | ThreadPullRequestKey, | |
| 7 | ThreadPullRequestLinkSource, | |
| 8 | ThreadPullRequestSnapshot, | |
| 9 | ThreadPullRequestStack, | |
| 10 | TrimmedNonEmptyString, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | import * as Context from "effect/Context"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | ||
| 16 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "./Errors.ts"; | |
| 17 | ||
| 18 | import * as Layer from "effect/Layer"; | |
| 19 | import * as Struct from "effect/Struct"; | |
| 20 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 21 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 22 | ||
| 23 | export 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 | }); | |
| 34 | export type ProjectionThreadPullRequest = typeof ProjectionThreadPullRequest.Type; | |
| 35 | ||
| 36 | export const ListProjectionThreadPullRequestsInput = Schema.Struct({ | |
| 37 | threadId: ThreadId, | |
| 38 | }); | |
| 39 | export type ListProjectionThreadPullRequestsInput = | |
| 40 | typeof ListProjectionThreadPullRequestsInput.Type; | |
| 41 | ||
| 42 | export const ListProjectionThreadPullRequestsByPullRequestInput = ThreadPullRequestKey; | |
| 43 | export type ListProjectionThreadPullRequestsByPullRequestInput = | |
| 44 | typeof ListProjectionThreadPullRequestsByPullRequestInput.Type; | |
| 45 | ||
| 46 | export const DeleteProjectionThreadPullRequestInput = Schema.Struct({ | |
| 47 | threadId: ThreadId, | |
| 48 | ...ThreadPullRequestKey.fields, | |
| 49 | }); | |
| 50 | export type DeleteProjectionThreadPullRequestInput = | |
| 51 | typeof DeleteProjectionThreadPullRequestInput.Type; | |
| 52 | ||
| 53 | export const DeleteProjectionThreadPullRequestsInput = Schema.Struct({ | |
| 54 | threadId: ThreadId, | |
| 55 | }); | |
| 56 | export type DeleteProjectionThreadPullRequestsInput = | |
| 57 | typeof DeleteProjectionThreadPullRequestsInput.Type; | |
| 58 | ||
| 59 | export const DeleteProjectionThreadPullRequestsBySourceInput = Schema.Struct({ | |
| 60 | threadId: ThreadId, | |
| 61 | source: ThreadPullRequestLinkSource, | |
| 62 | }); | |
| 63 | export type DeleteProjectionThreadPullRequestsBySourceInput = | |
| 64 | typeof DeleteProjectionThreadPullRequestsBySourceInput.Type; | |
| 65 | ||
| 66 | const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields( | |
| 67 | Struct.assign({ | |
| 68 | snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), | |
| 69 | stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), | |
| 70 | }), | |
| 71 | ); | |
| 72 | ||
| 73 | export 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. */ | |
| 98 | export 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 | ||
| 266 | export const layer = Layer.effect(ProjectionThreadPullRequestRepository, make); |
apps/server/src/project/AgentSessionImporter.test.ts+1| ... | ... | @@ -124,6 +124,7 @@ const makeProjectedThread = (input: { |
| 124 | 124 | modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, |
| 125 | 125 | runtimeMode: "full-access", |
| 126 | 126 | interactionMode: "default", |
| 127 | pullRequests: [], | |
| 127 | 128 | branch: null, |
| 128 | 129 | worktreePath: null, |
| 129 | 130 | latestTurn: null, |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+2-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; | |
| 1 | 2 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | 3 | import * as NodeFS from "node:fs"; |
| 3 | 4 | import * as NodeOS from "node:os"; |
| ... | ... | @@ -392,8 +393,7 @@ describe("ClaudeAdapterLive", () => { |
| 392 | 393 | assert.deepEqual(createInput?.options.systemPrompt, { |
| 393 | 394 | type: "preset", |
| 394 | 395 | 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" }), | |
| 397 | 397 | }); |
| 398 | 398 | assert.equal(createInput?.options.permissionMode, "bypassPermissions"); |
| 399 | 399 | assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); |
apps/server/src/provider/Layers/CodexAdapter.ts+1| ... | ... | @@ -2283,6 +2283,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 2283 | 2283 | "-c", |
| 2284 | 2284 | 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', |
| 2285 | 2285 | ], |
| 2286 | browserToolsAvailable: mcpSession.preview, | |
| 2286 | 2287 | } |
| 2287 | 2288 | : {}), |
| 2288 | 2289 | }; |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+11-2| ... | ... | @@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions { |
| 166 | 166 | readonly serviceTier?: CodexServiceTier | undefined; |
| 167 | 167 | readonly resumeCursor?: CodexResumeCursor; |
| 168 | 168 | readonly appServerArgs?: ReadonlyArray<string>; |
| 169 | /** | |
| 170 | * Whether the attached `t3-code` MCP server exposes the preview tools. The | |
| 171 | * server is attached for every session now (the pull request toolkit is | |
| 172 | * always on), so its presence in `appServerArgs` no longer implies browser | |
| 173 | * access; the credential's own capability decides the developer prompt. | |
| 174 | */ | |
| 175 | readonly browserToolsAvailable?: boolean; | |
| 169 | 176 | } |
| 170 | 177 | |
| 171 | 178 | export interface CodexSessionRuntimeSendTurnInput { |
| ... | ... | @@ -2349,10 +2356,12 @@ export const makeCodexSessionRuntime = ( |
| 2349 | 2356 | ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), |
| 2350 | 2357 | ...(input.effort ? { effort: input.effort } : {}), |
| 2351 | 2358 | ...(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 | |
| 2353 | 2360 | // setting, so the prompt describes the tools this turn actually |
| 2354 | 2361 | // has even if the setting changed after the session started. |
| 2355 | browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs), | |
| 2362 | browserToolsAvailable: | |
| 2363 | hasConfiguredMcpServer(options.appServerArgs) && | |
| 2364 | (options.browserToolsAvailable ?? true), | |
| 2356 | 2365 | }); |
| 2357 | 2366 | const rawResponse = yield* client.raw.request("turn/start", params); |
| 2358 | 2367 | const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( |
apps/server/src/provider/Layers/ProviderService.test.ts+14-30| ... | ... | @@ -4797,7 +4797,6 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { |
| 4797 | 4797 | const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); |
| 4798 | 4798 | |
| 4799 | 4799 | describe("agent browser access", () => { |
| 4800 | const revokedThreads: Array<ThreadId> = []; | |
| 4801 | 4800 | const projectId = ProjectId.make("project-browser-access"); |
| 4802 | 4801 | |
| 4803 | 4802 | const startSessionWith = ( |
| ... | ... | @@ -4806,7 +4805,7 @@ describe("agent browser access", () => { |
| 4806 | 4805 | projectOverride?: boolean, |
| 4807 | 4806 | ) => |
| 4808 | 4807 | Effect.gen(function* () { |
| 4809 | const issued: Array<ThreadId> = []; | |
| 4808 | const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = []; | |
| 4810 | 4809 | const codex = makeFakeCodexAdapter(); |
| 4811 | 4810 | const providerAdapterLayer = Layer.succeed( |
| 4812 | 4811 | ProviderAdapterRegistry.ProviderAdapterRegistry, |
| ... | ... | @@ -4865,10 +4864,9 @@ describe("agent browser access", () => { |
| 4865 | 4864 | const providerLayer = makeProviderServiceLive({ |
| 4866 | 4865 | issueMcpCredential: (request) => |
| 4867 | 4866 | Effect.sync(() => { |
| 4868 | issued.push(request.threadId); | |
| 4867 | issued.push({ threadId: request.threadId, preview: request.preview }); | |
| 4869 | 4868 | return undefined; |
| 4870 | 4869 | }), |
| 4871 | revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)), | |
| 4872 | 4870 | }).pipe( |
| 4873 | 4871 | Layer.provide(providerAdapterLayer), |
| 4874 | 4872 | Layer.provide(directoryLayer), |
| ... | ... | @@ -4903,48 +4901,34 @@ describe("agent browser access", () => { |
| 4903 | 4901 | return issued; |
| 4904 | 4902 | }); |
| 4905 | 4903 | |
| 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", () => | |
| 4910 | 4908 | Effect.gen(function* () { |
| 4911 | const issued = yield* startSessionWith(false, asThreadId("thread-browser-off")); | |
| 4909 | const threadId = asThreadId("thread-browser-off"); | |
| 4912 | 4910 | |
| 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); | |
| 4923 | 4912 | |
| 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 }]); | |
| 4928 | 4914 | }).pipe(Effect.provide(NodeServices.layer)), |
| 4929 | 4915 | ); |
| 4930 | 4916 | |
| 4931 | it.effect("requests an MCP credential when agent browser access is on", () => | |
| 4917 | it.effect("issues a credential with preview when agent browser access is on", () => | |
| 4932 | 4918 | Effect.gen(function* () { |
| 4933 | 4919 | const threadId = asThreadId("thread-browser-on"); |
| 4934 | 4920 | |
| 4935 | 4921 | const issued = yield* startSessionWith(true, threadId); |
| 4936 | 4922 | |
| 4937 | assert.deepEqual(issued, [threadId]); | |
| 4923 | assert.deepEqual(issued, [{ threadId, preview: true }]); | |
| 4938 | 4924 | }).pipe(Effect.provide(NodeServices.layer)), |
| 4939 | 4925 | ); |
| 4940 | 4926 | |
| 4941 | it.effect("withholds and revokes MCP credentials when the project disables browser access", () => | |
| 4927 | it.effect("issues a credential without preview when the project disables browser access", () => | |
| 4942 | 4928 | Effect.gen(function* () { |
| 4943 | 4929 | const threadId = asThreadId("thread-project-browser-off"); |
| 4944 | revokedThreads.length = 0; | |
| 4945 | 4930 | const issued = yield* startSessionWith(true, threadId, false); |
| 4946 | assert.deepEqual(issued, []); | |
| 4947 | assert.deepEqual(revokedThreads, [threadId]); | |
| 4931 | assert.deepEqual(issued, [{ threadId, preview: false }]); | |
| 4948 | 4932 | }).pipe(Effect.provide(NodeServices.layer)), |
| 4949 | 4933 | ); |
| 4950 | 4934 | |
| ... | ... | @@ -4952,7 +4936,7 @@ describe("agent browser access", () => { |
| 4952 | 4936 | Effect.gen(function* () { |
| 4953 | 4937 | const threadId = asThreadId("thread-project-browser-on"); |
| 4954 | 4938 | const issued = yield* startSessionWith(false, threadId, true); |
| 4955 | assert.deepEqual(issued, [threadId]); | |
| 4939 | assert.deepEqual(issued, [{ threadId, preview: true }]); | |
| 4956 | 4940 | }).pipe(Effect.provide(NodeServices.layer)), |
| 4957 | 4941 | ); |
| 4958 | 4942 | }); |
apps/server/src/provider/Layers/ProviderService.ts+13-23| ... | ... | @@ -249,8 +249,6 @@ export interface ProviderServiceLiveOptions { |
| 249 | 249 | * test see whether a credential was requested at all. |
| 250 | 250 | */ |
| 251 | 251 | readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; |
| 252 | /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */ | |
| 253 | readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread; | |
| 254 | 252 | } |
| 255 | 253 | |
| 256 | 254 | interface TurnAnalyticsMetadata { |
| ... | ... | @@ -479,8 +477,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 479 | 477 | ); |
| 480 | 478 | const issueMcpCredential = |
| 481 | 479 | options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; |
| 482 | const revokeMcpCredential = | |
| 483 | options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; | |
| 484 | 480 | const fileSystem = yield* FileSystem.FileSystem; |
| 485 | 481 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 486 | 482 | const pendingCompactions = new Map<ThreadId, PendingCompaction>(); |
| ... | ... | @@ -853,14 +849,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 853 | 849 | yield* recordCompletedTurnProperties(properties); |
| 854 | 850 | }); |
| 855 | 851 | /** |
| 856 | * Attach the `t3-code` MCP server to the session that is about to start. | |
| 852 | * Whether the credential minted below may drive the user's browser. | |
| 857 | 853 | * |
| 858 | * This is the only place a credential is minted, so withholding one here is | |
| 859 | * what disables agent browser access everywhere: every adapter already | |
| 860 | * treats a missing session as "no MCP server", and the `/mcp` endpoint | |
| 861 | * accepts nothing but tokens issued from this path. | |
| 862 | */ | |
| 863 | /** | |
| 864 | 854 | * Deny on an unreadable settings file rather than letting the read failure |
| 865 | 855 | * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen |
| 866 | 856 | * a union every caller handles, for a branch that only decides whether one |
| ... | ... | @@ -889,20 +879,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 889 | 879 | ), |
| 890 | 880 | ); |
| 891 | 881 | |
| 882 | /** | |
| 883 | * Attach the `t3-code` MCP server to the session that is about to start. | |
| 884 | * | |
| 885 | * Every session gets a credential: the pull request toolkit is always on, | |
| 886 | * since it only registers links on the session's own thread. Browser access | |
| 887 | * is a capability on that credential, so turning the setting off withholds | |
| 888 | * the preview tools without taking the server away. `issueActiveMcpCredential` | |
| 889 | * revokes the thread's previous token first, which matters because a session | |
| 890 | * restart (runtime mode, cwd, model) re-prepares without stopping. | |
| 891 | */ | |
| 892 | 892 | const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => |
| 893 | 893 | 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 }); | |
| 906 | 896 | if (credential) { |
| 907 | 897 | yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)); |
| 908 | 898 | } |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | ... | @@ -98,6 +98,7 @@ function makeReadModel( |
| 98 | 98 | runtimeMode: "full-access" as const, |
| 99 | 99 | branch: null, |
| 100 | 100 | worktreePath: null, |
| 101 | pullRequests: [], | |
| 101 | 102 | createdAt: now, |
| 102 | 103 | updatedAt: now, |
| 103 | 104 | archivedAt: null, |
apps/server/src/provider/RuntimeInstructions.test.ts+8| ... | ... | @@ -2,6 +2,14 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; |
| 3 | 3 | |
| 4 | 4 | describe("buildRuntimeInstructions", () => { |
| 5 | it("requires explicit registration of every PR and stack layer", () => { | |
| 6 | const instructions = buildRuntimeInstructions({ harness: "Codex" }); | |
| 7 | expect(instructions).toContain("When the t3-code MCP server exposes link_pull_request"); | |
| 8 | expect(instructions).toContain("with the full PR URL immediately after creating a PR"); | |
| 9 | expect(instructions).toContain("For a stack, call it for every layer"); | |
| 10 | expect(instructions).toContain("call list_thread_pull_requests and link any PR"); | |
| 11 | }); | |
| 12 | ||
| 5 | 13 | it("keeps known model and effort metadata on one line", () => { |
| 6 | 14 | expect( |
| 7 | 15 | buildRuntimeInstructions({ |
apps/server/src/provider/RuntimeInstructions.ts+5-1| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking> | |
| 2 | When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. | |
| 3 | </pull_request_linking>`; | |
| 4 | ||
| 1 | 5 | /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ |
| 2 | 6 | export function buildRuntimeInstructions(runtime: { |
| 3 | 7 | readonly harness: string; |
| ... | ... | @@ -9,7 +13,7 @@ export function buildRuntimeInstructions(runtime: { |
| 9 | 13 | const effort = toSingleLine(runtime.reasoningEffort ?? ""); |
| 10 | 14 | const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; |
| 11 | 15 | const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; |
| 12 | return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>`; | |
| 16 | return `<runtime_info>In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; | |
| 13 | 17 | } |
| 14 | 18 | |
| 15 | 19 | function toSingleLine(value: string): string { |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+227-26| ... | ... | @@ -183,20 +183,37 @@ afterEach(() => { |
| 183 | 183 | }); |
| 184 | 184 | |
| 185 | 185 | layer("GitHubPullRequestCli.layer", (it) => { |
| 186 | it.effect("reads linked pull request status through one narrow request", () => | |
| 186 | it.effect("reads linked pull request status with the overview fields in one request", () => | |
| 187 | 187 | 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 | ), | |
| 200 | 217 | ); |
| 201 | 218 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 202 | 219 | |
| ... | ... | @@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 207 | 224 | number: 7, |
| 208 | 225 | }); |
| 209 | 226 | |
| 210 | assert.deepStrictEqual(summary, { | |
| 227 | assert.deepStrictEqual( | |
| 228 | { | |
| 229 | number: summary.number, | |
| 230 | state: summary.state, | |
| 231 | headBranch: summary.headBranch, | |
| 232 | isDraft: summary.isDraft, | |
| 233 | author: summary.author?.login, | |
| 234 | additions: summary.additions, | |
| 235 | deletions: summary.deletions, | |
| 236 | changedFiles: summary.changedFiles, | |
| 237 | reviewDecision: summary.reviewDecision, | |
| 238 | checksState: summary.checksState, | |
| 239 | mergeability: summary.mergeability, | |
| 240 | }, | |
| 241 | { | |
| 242 | number: 7, | |
| 243 | state: "open", | |
| 244 | headBranch: "feat/summary", | |
| 245 | isDraft: false, | |
| 246 | author: "octocat", | |
| 247 | additions: 12, | |
| 248 | deletions: 3, | |
| 249 | changedFiles: 2, | |
| 250 | reviewDecision: "approved", | |
| 251 | checksState: "passing", | |
| 252 | mergeability: "mergeable", | |
| 253 | }, | |
| 254 | ); | |
| 255 | expect(mockedExecute).toHaveBeenCalledOnce(); | |
| 256 | expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ | |
| 257 | "pr", | |
| 258 | "view", | |
| 259 | "7", | |
| 260 | "--repo", | |
| 261 | "github.com/acme/web", | |
| 262 | "--json", | |
| 263 | expect.stringContaining("statusCheckRollup"), | |
| 264 | ]); | |
| 265 | expect(mockedGetPullRequest).not.toHaveBeenCalled(); | |
| 266 | }), | |
| 267 | ); | |
| 268 | ||
| 269 | it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => | |
| 270 | Effect.gen(function* () { | |
| 271 | mockedExecute.mockReturnValueOnce( | |
| 272 | Effect.succeed( | |
| 273 | output( | |
| 274 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 275 | JSON.stringify([ | |
| 276 | { | |
| 277 | id: 42, | |
| 278 | number: 3, | |
| 279 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 280 | base: { ref: "main" }, | |
| 281 | pull_requests: [ | |
| 282 | { | |
| 283 | number: 6, | |
| 284 | head: { ref: "feat/one" }, | |
| 285 | state: "closed", | |
| 286 | merged_at: "2026-09-02", | |
| 287 | }, | |
| 288 | { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 289 | ], | |
| 290 | }, | |
| 291 | ]), | |
| 292 | ), | |
| 293 | ), | |
| 294 | ); | |
| 295 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 296 | ||
| 297 | const stack = yield* cli.getPullRequestStack({ | |
| 298 | cwd: "/w", | |
| 299 | repository: "acme/web", | |
| 300 | host: "ghe.example.com", | |
| 211 | 301 | number: 7, |
| 212 | title: "Reuse the summary", | |
| 213 | url: "https://github.com/acme/web/pull/7", | |
| 214 | headBranch: "feat/summary", | |
| 215 | baseBranch: "main", | |
| 216 | state: "merged", | |
| 217 | closedAt: "2026-08-23T10:00:00Z", | |
| 218 | mergedAt: "2026-08-23T10:00:00Z", | |
| 219 | updatedAt: "2026-08-24T12:34:56.000Z", | |
| 220 | 302 | }); |
| 221 | expect(mockedGetPullRequest).toHaveBeenCalledOnce(); | |
| 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({ | |
| 223 | 329 | cwd: "/w", |
| 224 | reference: "https://github.com/acme/web/pull/7", | |
| 330 | repository: "acme/web", | |
| 331 | host: "github.com", | |
| 332 | number: 7, | |
| 333 | }); | |
| 334 | ||
| 335 | assert.isNull(stack); | |
| 336 | }), | |
| 337 | ); | |
| 338 | ||
| 339 | it.effect("reads a host that refuses the stacks preview as not stacked", () => | |
| 340 | Effect.gen(function* () { | |
| 341 | // The CLI classifies a missing preview endpoint as not found. | |
| 342 | mockedExecute.mockReturnValueOnce( | |
| 343 | Effect.fail( | |
| 344 | new GitHubCli.GitHubPullRequestNotFoundError({ | |
| 345 | command: "gh", | |
| 346 | cwd: "/w", | |
| 347 | cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), | |
| 348 | }), | |
| 349 | ), | |
| 350 | ); | |
| 351 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 352 | ||
| 353 | const stack = yield* cli.getPullRequestStack({ | |
| 354 | cwd: "/w", | |
| 355 | repository: "acme/web", | |
| 356 | host: "github.com", | |
| 357 | number: 7, | |
| 358 | }); | |
| 359 | ||
| 360 | assert.isNull(stack); | |
| 361 | }), | |
| 362 | ); | |
| 363 | ||
| 364 | it.effect("does not read a signed-out gh as an unstacked pull request", () => | |
| 365 | Effect.gen(function* () { | |
| 366 | mockedExecute.mockReturnValueOnce( | |
| 367 | Effect.fail( | |
| 368 | new GitHubCli.GitHubCliAuthenticationError({ | |
| 369 | command: "gh", | |
| 370 | cwd: "/w", | |
| 371 | cause: new Error("gh auth login"), | |
| 372 | }), | |
| 373 | ), | |
| 374 | ); | |
| 375 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 376 | ||
| 377 | const error = yield* Effect.flip( | |
| 378 | cli.getPullRequestStack({ | |
| 379 | cwd: "/w", | |
| 380 | repository: "acme/web", | |
| 381 | host: "github.com", | |
| 382 | number: 7, | |
| 383 | }), | |
| 384 | ); | |
| 385 | ||
| 386 | assert.strictEqual(error._tag, "GitHubCliAuthenticationError"); | |
| 387 | }), | |
| 388 | ); | |
| 389 | ||
| 390 | it.effect("preserves transient stack failures instead of reporting no stack", () => | |
| 391 | Effect.gen(function* () { | |
| 392 | const failure = new GitHubCli.GitHubCliCommandError({ | |
| 393 | command: "gh", | |
| 394 | cwd: "/w", | |
| 395 | cause: new Error("HTTP 503"), | |
| 225 | 396 | }); |
| 226 | expect(mockedExecute).not.toHaveBeenCalled(); | |
| 397 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); | |
| 398 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 399 | const error = yield* Effect.flip( | |
| 400 | cli.getPullRequestStack({ | |
| 401 | cwd: "/w", | |
| 402 | repository: "acme/web", | |
| 403 | host: "github.com", | |
| 404 | number: 7, | |
| 405 | }), | |
| 406 | ); | |
| 407 | assert.strictEqual(error, failure); | |
| 408 | }), | |
| 409 | ); | |
| 410 | ||
| 411 | it.effect("reports a stacks answer it cannot read against the stack read", () => | |
| 412 | Effect.gen(function* () { | |
| 413 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); | |
| 414 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 415 | ||
| 416 | const error = yield* Effect.flip( | |
| 417 | cli.getPullRequestStack({ | |
| 418 | cwd: "/w", | |
| 419 | repository: "acme/web", | |
| 420 | host: "github.com", | |
| 421 | number: 7, | |
| 422 | }), | |
| 423 | ); | |
| 424 | ||
| 425 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); | |
| 426 | if (error._tag !== "GitHubPullRequestReadError") return; | |
| 427 | assert.strictEqual(error.operation, "getPullRequestStack"); | |
| 227 | 428 | }), |
| 228 | 429 | ); |
| 229 | 430 |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+94-42| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | decodePullRequestListJson, |
| 40 | 40 | decodePullRequestNodeIdJson, |
| 41 | 41 | decodePullRequestSearchJson, |
| 42 | decodePullRequestStacksJson, | |
| 42 | 43 | decodePullRequestStatsJson, |
| 43 | 44 | decodeReactionSubjectScopeJson, |
| 44 | 45 | decodeRepositoryAccessJson, |
| ... | ... | @@ -84,6 +85,7 @@ import { |
| 84 | 85 | type GitHubPullRequestHead, |
| 85 | 86 | type GitHubPullRequestListItem, |
| 86 | 87 | type GitHubPullRequestSearchItem, |
| 88 | type GitHubPullRequestStack, | |
| 87 | 89 | type GitHubReviewThreadComments, |
| 88 | 90 | type GitHubRepositoryAccess, |
| 89 | 91 | type GitHubWorkflowRunApproval, |
| ... | ... | @@ -91,7 +93,7 @@ import { |
| 91 | 93 | type GitHubReviewThreadPage, |
| 92 | 94 | type GitHubViewerAccess, |
| 93 | 95 | } from "./gitHubPullRequestJson.ts"; |
| 94 | import type { ProviderListCursor } from "./PullRequestProvider.ts"; | |
| 96 | import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; | |
| 95 | 97 | |
| 96 | 98 | /** |
| 97 | 99 | * Names the read that produced unusable output, so a failure reports the call it came from |
| ... | ... | @@ -460,21 +462,7 @@ export class GitHubPullRequestCli extends Context.Service< |
| 460 | 462 | readonly repository: string; |
| 461 | 463 | readonly host: string; |
| 462 | 464 | 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>; | |
| 478 | 466 | |
| 479 | 467 | readonly getPullRequestDetail: (input: { |
| 480 | 468 | readonly cwd: string; |
| ... | ... | @@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service< |
| 494 | 482 | readonly isCrossRepository: true; |
| 495 | 483 | }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>; |
| 496 | 484 | |
| 485 | /** | |
| 486 | * The host-native stack this pull request is in, or null when it is in none — which is also | |
| 487 | * the answer for a host that refuses the stacks preview altogether. | |
| 488 | */ | |
| 489 | readonly getPullRequestStack: (input: { | |
| 490 | readonly cwd: string; | |
| 491 | readonly repository: string; | |
| 492 | readonly host: string; | |
| 493 | readonly number: number; | |
| 494 | }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>; | |
| 495 | ||
| 497 | 496 | /** |
| 498 | 497 | * How far the branch trails its base, and whether this viewer may update it. Its own read |
| 499 | 498 | * because the comparison needs the head ref the detail answers with — a fork's branch is not |
| ... | ... | @@ -807,8 +806,8 @@ function matchesFilters( |
| 807 | 806 | viewer: string, |
| 808 | 807 | ): boolean { |
| 809 | 808 | 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()); | |
| 812 | 811 | return ( |
| 813 | 812 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && |
| 814 | 813 | (filters.review === undefined || |
| ... | ... | @@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () { |
| 1630 | 1629 | ).pipe(Effect.map((results) => results.flat())); |
| 1631 | 1630 | }, |
| 1632 | 1631 | |
| 1632 | // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands | |
| 1633 | // the thread overview its author, diff stat, review decision and checks in the same read. | |
| 1633 | 1634 | getPullRequestSummary: (input) => |
| 1634 | 1635 | github |
| 1635 | .getPullRequest({ | |
| 1636 | .execute({ | |
| 1636 | 1637 | cwd: input.cwd, |
| 1637 | reference: `https://${input.host}/${input.repository}/pull/${input.number}`, | |
| 1638 | args: [ | |
| 1639 | "pr", | |
| 1640 | "view", | |
| 1641 | String(input.number), | |
| 1642 | ...repositoryArgs(input), | |
| 1643 | "--json", | |
| 1644 | PULL_REQUEST_DETAIL_JSON_FIELDS, | |
| 1645 | ], | |
| 1638 | 1646 | }) |
| 1639 | 1647 | .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, | |
| 1661 | 1657 | }), |
| 1662 | ), | |
| 1658 | ); | |
| 1659 | } | |
| 1660 | const detail = decoded.success; | |
| 1661 | return Effect.succeed({ | |
| 1662 | number: detail.number, | |
| 1663 | title: detail.title, | |
| 1664 | url: detail.url, | |
| 1665 | headBranch: detail.headBranch, | |
| 1666 | baseBranch: detail.baseBranch, | |
| 1667 | state: detail.state, | |
| 1668 | updatedAt: detail.updatedAt, | |
| 1669 | closedAt: detail.closedAt ?? null, | |
| 1670 | mergedAt: detail.mergedAt ?? null, | |
| 1671 | isDraft: detail.isDraft, | |
| 1672 | author: detail.author, | |
| 1673 | additions: detail.additions, | |
| 1674 | deletions: detail.deletions, | |
| 1675 | changedFiles: detail.changedFiles, | |
| 1676 | reviewDecision: detail.reviewDecision, | |
| 1677 | checksState: detail.checksState, | |
| 1678 | mergeability: detail.mergeability, | |
| 1679 | }); | |
| 1680 | }), | |
| 1663 | 1681 | ), |
| 1664 | 1682 | |
| 1665 | 1683 | getPullRequestDetail, |
| 1666 | 1684 | listWorkflowRunsRequiringApproval, |
| 1667 | 1685 | |
| 1686 | getPullRequestStack: (input) => { | |
| 1687 | const { owner, name } = parseRepositorySelector(input.repository); | |
| 1688 | return github | |
| 1689 | .execute({ | |
| 1690 | cwd: input.cwd, | |
| 1691 | args: [ | |
| 1692 | "api", | |
| 1693 | "--hostname", | |
| 1694 | input.host, | |
| 1695 | `repos/${owner}/${name}/stacks?pull_request=${input.number}`, | |
| 1696 | ], | |
| 1697 | }) | |
| 1698 | .pipe( | |
| 1699 | Effect.flatMap((result) => { | |
| 1700 | const decoded = decodePullRequestStacksJson(result.stdout.trim()); | |
| 1701 | return Result.isSuccess(decoded) | |
| 1702 | ? Effect.succeed(decoded.success) | |
| 1703 | : Effect.fail( | |
| 1704 | new GitHubPullRequestReadError({ | |
| 1705 | command: "gh", | |
| 1706 | cwd: input.cwd, | |
| 1707 | operation: "getPullRequestStack", | |
| 1708 | cause: decoded.failure, | |
| 1709 | }), | |
| 1710 | ); | |
| 1711 | }), | |
| 1712 | // Hosts without the stacks preview return 404. Other failures must preserve the | |
| 1713 | // previously synced stack and let the caller retry. | |
| 1714 | Effect.catchTags({ | |
| 1715 | GitHubPullRequestNotFoundError: () => Effect.succeed(null), | |
| 1716 | }), | |
| 1717 | ); | |
| 1718 | }, | |
| 1719 | ||
| 1668 | 1720 | getPullRequestBaseComparison: (input) => { |
| 1669 | 1721 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1670 | 1722 | return graphqlRead({ |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+61| ... | ... | @@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => |
| 25 | 25 | baseBranch: "main", |
| 26 | 26 | state: "open" as const, |
| 27 | 27 | updatedAt: "2026-08-24T12:34:56.000Z", |
| 28 | author: { login: "octocat", name: null, avatarUrl: null }, | |
| 28 | 29 | }; |
| 29 | 30 | }), |
| 30 | 31 | }), |
| ... | ... | @@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () => |
| 42 | 43 | |
| 43 | 44 | expect(summary.state).toBe("open"); |
| 44 | 45 | expect(summaryReads).toBe(1); |
| 46 | // The author's avatar comes from the login-shaped URL, not a second request. | |
| 47 | expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); | |
| 48 | }), | |
| 49 | ); | |
| 50 | ||
| 51 | it.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 | ||
| 80 | it.effect("reports a failed stack read against its own operation", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | const provider = yield* make.pipe( | |
| 83 | Effect.provide( | |
| 84 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 85 | getPullRequestStack: () => | |
| 86 | Effect.fail( | |
| 87 | new GitHubPullRequestCli.GitHubPullRequestReadError({ | |
| 88 | command: "gh", | |
| 89 | cwd: "/w", | |
| 90 | operation: "getPullRequestStack", | |
| 91 | cause: new Error("unreadable"), | |
| 92 | }), | |
| 93 | ), | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | ||
| 98 | const readStack = provider.getChangeRequestStack; | |
| 99 | if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); | |
| 100 | const error = yield* Effect.flip( | |
| 101 | readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), | |
| 102 | ); | |
| 103 | ||
| 104 | expect(error.operation).toBe("getChangeRequestStack"); | |
| 105 | expect(error.reason).toBe("failed"); | |
| 45 | 106 | }), |
| 46 | 107 | ); |
| 47 | 108 |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+15-1| ... | ... | @@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 47 | 47 | }, |
| 48 | 48 | reviewers: { request: true, listCandidates: true }, |
| 49 | 49 | edit: { changeRequest: true, comment: true }, |
| 50 | stacks: true, | |
| 50 | 51 | labels: true, |
| 51 | 52 | }; |
| 52 | 53 | |
| ... | ... | @@ -292,7 +293,20 @@ export const make = Effect.gen(function* () { |
| 292 | 293 | .pipe(Effect.mapError(fail("listChangeRequestStats"))), |
| 293 | 294 | |
| 294 | 295 | getChangeRequestSummary: (input) => |
| 295 | cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), | |
| 296 | cli.getPullRequestSummary(input).pipe( | |
| 297 | // `gh pr view` names the author without an avatar; the login-shaped URL every user | |
| 298 | // has stands in, without the second request the listing spends on it. | |
| 299 | Effect.map((summary) => ({ | |
| 300 | ...summary, | |
| 301 | ...(summary.author === undefined | |
| 302 | ? {} | |
| 303 | : { author: withAvatar(summary.author, new Map(), input.host) }), | |
| 304 | })), | |
| 305 | Effect.mapError(fail("getChangeRequestSummary")), | |
| 306 | ), | |
| 307 | ||
| 308 | getChangeRequestStack: (input) => | |
| 309 | cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), | |
| 296 | 310 | |
| 297 | 311 | getChangeRequest: (input) => |
| 298 | 312 | Effect.all( |
apps/server/src/pullRequest/PullRequestProvider.ts+36| ... | ... | @@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary { |
| 103 | 103 | readonly closedAt?: string | null; |
| 104 | 104 | readonly mergedAt?: string | null; |
| 105 | 105 | readonly updatedAt: string; |
| 106 | /** Overview fields, present where the host's single read returns them at no extra cost. */ | |
| 107 | readonly author?: PullRequestActor | null | undefined; | |
| 108 | readonly additions?: number | undefined; | |
| 109 | readonly deletions?: number | undefined; | |
| 110 | readonly changedFiles?: number | undefined; | |
| 111 | readonly reviewDecision?: PullRequestReviewDecision | null | undefined; | |
| 112 | readonly checksState?: PullRequestChecksState | null | undefined; | |
| 113 | readonly mergeability?: PullRequestMergeability | undefined; | |
| 114 | } | |
| 115 | ||
| 116 | /** One layer of a host-native stack, bottom to top order is the array's. */ | |
| 117 | export 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 | */ | |
| 128 | export interface ProviderChangeRequestStack { | |
| 129 | readonly id: string; | |
| 130 | readonly number: number; | |
| 131 | readonly url: string; | |
| 132 | readonly base: string; | |
| 133 | readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>; | |
| 106 | 134 | } |
| 107 | 135 | |
| 108 | 136 | export interface ProviderChangeRequestPage { |
| ... | ... | @@ -331,6 +359,14 @@ export interface PullRequestProviderApi { |
| 331 | 359 | input: ProviderRepositoryRef & { readonly number: number }, |
| 332 | 360 | ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>; |
| 333 | 361 | |
| 362 | /** | |
| 363 | * The host-native stack a change request belongs to, or null when it is not stacked. Optional | |
| 364 | * because most hosts have no such object; the service derives chains from base branches there. | |
| 365 | */ | |
| 366 | readonly getChangeRequestStack?: ( | |
| 367 | input: ProviderRepositoryRef & { readonly number: number }, | |
| 368 | ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>; | |
| 369 | ||
| 334 | 370 | /** Comments, line threads, and commits, kept off the critical path for the core detail. */ |
| 335 | 371 | readonly getChangeRequestActivity: ( |
| 336 | 372 | input: ProviderRepositoryRef & { readonly number: number }, |
apps/server/src/pullRequest/PullRequestService.test.ts+323-39| ... | ... | @@ -32,6 +32,7 @@ function project(input: { |
| 32 | 32 | readonly repository?: string; |
| 33 | 33 | readonly provider?: string; |
| 34 | 34 | readonly host?: string; |
| 35 | readonly remoteUrl?: string; | |
| 35 | 36 | }): OrchestrationProjectShell { |
| 36 | 37 | // The host defaults from the provider, so a fixture only names one when the point of the |
| 37 | 38 | // test is two hosts of the same kind. |
| ... | ... | @@ -47,7 +48,7 @@ function project(input: { |
| 47 | 48 | locator: { |
| 48 | 49 | source: "git-remote" as const, |
| 49 | 50 | remoteName: "origin", |
| 50 | remoteUrl: `https://${host}/${input.repository}.git`, | |
| 51 | remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, | |
| 51 | 52 | }, |
| 52 | 53 | provider: input.provider ?? "github", |
| 53 | 54 | displayName: input.repository, |
| ... | ... | @@ -470,7 +471,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" |
| 470 | 471 | |
| 471 | 472 | // Keyed by the selector Azure is actually asked with, which is the repository's own name. |
| 472 | 473 | assert.deepStrictEqual(result.nextCursors, { |
| 473 | "dev.azure.com web": "2026-07-02T00:00:00Z|4|7", | |
| 474 | "dev.azure.com dev.azure.com/acme/web": "2026-07-02T00:00:00Z|4|7", | |
| 474 | 475 | }); |
| 475 | 476 | }), |
| 476 | 477 | ); |
| ... | ... | @@ -1563,6 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project", |
| 1563 | 1564 | }), |
| 1564 | 1565 | ); |
| 1565 | 1566 | |
| 1567 | it.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 | ||
| 1612 | it.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 | ||
| 1640 | it.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 | ||
| 1680 | for (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 | ||
| 1748 | it.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 | ||
| 1783 | it.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 | ||
| 1566 | 1808 | it.effect("refuses a diff on a host that cannot produce one", () => |
| 1567 | 1809 | Effect.gen(function* () { |
| 1568 | 1810 | const service = yield* makeService({ |
| ... | ... | @@ -3377,6 +3619,48 @@ it.effect("does not ask the host again for a linked summary it already holds", ( |
| 3377 | 3619 | }), |
| 3378 | 3620 | ); |
| 3379 | 3621 | |
| 3622 | it.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 | ||
| 3380 | 3664 | it.effect("reuses an observed merged state for strict settlement reads", () => |
| 3381 | 3665 | Effect.gen(function* () { |
| 3382 | 3666 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; |
| ... | ... | @@ -3587,43 +3871,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil |
| 3587 | 3871 | }), |
| 3588 | 3872 | ); |
| 3589 | 3873 | |
| 3590 | it("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 | ||
| 3605 | it("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 | ||
| 3615 | it("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 | ||
| 3627 | 3874 | it.effect("narrows the rows of a host that ignored the filters it was handed", () => |
| 3628 | 3875 | Effect.gen(function* () { |
| 3629 | 3876 | const service = yield* makeService({ |
| ... | ... | @@ -4137,3 +4384,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the |
| 4137 | 4384 | assert.strictEqual(unnamed.viewer, undefined); |
| 4138 | 4385 | }), |
| 4139 | 4386 | ); |
| 4387 | ||
| 4388 | it.effect("keeps Azure continuation cursors separate for repositories with the same name", () => | |
| 4389 | Effect.gen(function* () { | |
| 4390 | const seen: string[] = []; | |
| 4391 | const service = yield* makeService({ | |
| 4392 | projects: ["org-a", "org-b"].map((organization) => | |
| 4393 | project({ | |
| 4394 | id: organization, | |
| 4395 | title: organization, | |
| 4396 | workspaceRoot: `/${organization}`, | |
| 4397 | repository: `${organization}/project/_git/web`, | |
| 4398 | provider: "azure-devops", | |
| 4399 | host: "dev.azure.com", | |
| 4400 | }), | |
| 4401 | ), | |
| 4402 | providers: [ | |
| 4403 | fakeProvider("azure-devops", { | |
| 4404 | listChangeRequests: (input) => | |
| 4405 | Effect.sync(() => { | |
| 4406 | seen.push(input.cwd); | |
| 4407 | return { | |
| 4408 | items: [changeRequest(7, "2026-07-02T00:00:00Z")], | |
| 4409 | truncated: true, | |
| 4410 | continues: true, | |
| 4411 | }; | |
| 4412 | }), | |
| 4413 | }), | |
| 4414 | ], | |
| 4415 | }); | |
| 4416 | const first = yield* service.list({ state: "open" }); | |
| 4417 | assert.lengthOf(Object.keys(first.nextCursors), 2); | |
| 4418 | const key = Object.keys(first.nextCursors).find((key) => key.includes("org-b"))!; | |
| 4419 | seen.length = 0; | |
| 4420 | yield* service.list({ state: "open", cursors: { [key]: first.nextCursors[key]! } }); | |
| 4421 | assert.deepStrictEqual(seen, ["/org-b"]); | |
| 4422 | }), | |
| 4423 | ); |
apps/server/src/pullRequest/PullRequestService.ts+202-59| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import { | |
| 2 | canonicalRepositoryKey, | |
| 3 | sourceControlRepositorySelector, | |
| 4 | } from "@t3tools/shared/sourceControl"; | |
| 1 | 5 | import * as Cache from "effect/Cache"; |
| 2 | 6 | import * as Clock from "effect/Clock"; |
| 3 | 7 | import * as Context from "effect/Context"; |
| ... | ... | @@ -46,6 +50,7 @@ import { |
| 46 | 50 | type PullRequestLabelCandidateList, |
| 47 | 51 | type PullRequestLabelChangeInput, |
| 48 | 52 | type PullRequestSubmitReviewInput, |
| 53 | type PullRequestStack, | |
| 49 | 54 | type PullRequestSummary, |
| 50 | 55 | type PullRequestThreadReplyInput, |
| 51 | 56 | type PullRequestThreadResolutionInput, |
| ... | ... | @@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service< |
| 144 | 149 | input: PullRequestRef, |
| 145 | 150 | options?: { readonly recoverTransientFailure?: boolean }, |
| 146 | 151 | ) => Effect.Effect<PullRequestSummary, PullRequestError>; |
| 152 | /** | |
| 153 | * The host-native stack the pull request belongs to, or null when it is not in one or the | |
| 154 | * host keeps no such object. Cached like a summary; a stack changes about as often. | |
| 155 | */ | |
| 156 | readonly stack: ( | |
| 157 | input: PullRequestRef, | |
| 158 | ) => Effect.Effect<PullRequestStack | null, PullRequestError>; | |
| 147 | 159 | readonly subscribeMerges: Effect.Effect< |
| 148 | 160 | Stream.Stream<PullRequestMergeEvent>, |
| 149 | 161 | never, |
| ... | ... | @@ -241,6 +253,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang |
| 241 | 253 | |
| 242 | 254 | /** A project this page can read: its remote is on a host with an implementation. */ |
| 243 | 255 | interface SupportedProject { |
| 256 | readonly cursorKey: string; | |
| 244 | 257 | readonly project: OrchestrationProjectShell; |
| 245 | 258 | readonly api: PullRequestProviderApi; |
| 246 | 259 | readonly repository: string; |
| ... | ... | @@ -453,7 +466,7 @@ function withRateLimitBackoff( |
| 453 | 466 | call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>, |
| 454 | 467 | ) => wrap(operation, call, true); |
| 455 | 468 | |
| 456 | return { | |
| 469 | const wrapped = { | |
| 457 | 470 | kind: api.kind, |
| 458 | 471 | capabilities: api.capabilities, |
| 459 | 472 | getViewer: wrap("getViewer", api.getViewer), |
| ... | ... | @@ -474,6 +487,9 @@ function withRateLimitBackoff( |
| 474 | 487 | : { |
| 475 | 488 | getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), |
| 476 | 489 | }), |
| 490 | ...(api.getChangeRequestStack === undefined | |
| 491 | ? {} | |
| 492 | : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }), | |
| 477 | 493 | getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), |
| 478 | 494 | ...(api.getReviewThreadComments === undefined |
| 479 | 495 | ? {} |
| ... | ... | @@ -506,30 +522,9 @@ function withRateLimitBackoff( |
| 506 | 522 | setReaction: interactive("setReaction", api.setReaction), |
| 507 | 523 | setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), |
| 508 | 524 | }; |
| 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 | */ | |
| 524 | export 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>; | |
| 533 | 528 | } |
| 534 | 529 | |
| 535 | 530 | export const make = Effect.gen(function* () { |
| ... | ... | @@ -554,7 +549,11 @@ export const make = Effect.gen(function* () { |
| 554 | 549 | for (const project of projects) { |
| 555 | 550 | if (filter.projectId !== undefined && project.id !== filter.projectId) continue; |
| 556 | 551 | const identity = project.repositoryIdentity; |
| 557 | if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue; | |
| 552 | if ( | |
| 553 | identity?.provider !== "unknown" || | |
| 554 | sourceControlRepositorySelector(project.repositoryIdentity) === null | |
| 555 | ) | |
| 556 | continue; | |
| 558 | 557 | const host = pullRequestHostOf(identity, "unknown"); |
| 559 | 558 | // A legacy identity has no canonical host until its provider is refined, so it must reach |
| 560 | 559 | // the refinement before a host filter can decide whether it belongs in the result. |
| ... | ... | @@ -628,7 +627,7 @@ export const make = Effect.gen(function* () { |
| 628 | 627 | if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; |
| 629 | 628 | const identity = project.repositoryIdentity; |
| 630 | 629 | let kind = identity?.provider as SourceControlProviderKind | undefined; |
| 631 | const repository = repositoryIdentityOf(project); | |
| 630 | const repository = sourceControlRepositorySelector(project.repositoryIdentity); | |
| 632 | 631 | if (!identity || kind === undefined || repository === null) continue; |
| 633 | 632 | // Worktrees of one repository are separate projects; reading the remote once keeps |
| 634 | 633 | // the page from repeating every change request per local checkout. The host is part |
| ... | ... | @@ -647,7 +646,10 @@ export const make = Effect.gen(function* () { |
| 647 | 646 | if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); |
| 648 | 647 | else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); |
| 649 | 648 | } |
| 650 | const key = listCursorKey(host, repository); | |
| 649 | const key = listCursorKey( | |
| 650 | host, | |
| 651 | kind === "azure-devops" ? identity.canonicalKey : repository, | |
| 652 | ); | |
| 651 | 653 | if (seen.has(key)) continue; |
| 652 | 654 | seen.add(key); |
| 653 | 655 | if (api === null) { |
| ... | ... | @@ -657,6 +659,7 @@ export const make = Effect.gen(function* () { |
| 657 | 659 | continue; |
| 658 | 660 | } |
| 659 | 661 | supported.push({ |
| 662 | cursorKey: key, | |
| 660 | 663 | project, |
| 661 | 664 | api: withRateLimitBackoff(api, host, rateLimits), |
| 662 | 665 | repository, |
| ... | ... | @@ -667,16 +670,30 @@ export const make = Effect.gen(function* () { |
| 667 | 670 | }), |
| 668 | 671 | ); |
| 669 | 672 | |
| 673 | /** | |
| 674 | * The project whose checkout and credentials serve a reference. The project's own | |
| 675 | * repository is the default; a reference that names a `host` may instead point at any | |
| 676 | * repository on that host. Prefer its own checkout; providers with explicit repository | |
| 677 | * targeting can fall back to another checkout on the host. Azure derives its organization | |
| 678 | * from the checkout, so it requires a matching repository. | |
| 679 | */ | |
| 670 | 680 | const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> => |
| 671 | 681 | listWorkspaceProjects({ projectId: ref.projectId }).pipe( |
| 672 | 682 | 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); | |
| 676 | 690 | } |
| 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. | |
| 680 | 697 | return Effect.fail( |
| 681 | 698 | new PullRequestOperationError({ |
| 682 | 699 | operation: "resolveRepository", |
| ... | ... | @@ -684,7 +701,39 @@ export const make = Effect.gen(function* () { |
| 684 | 701 | }), |
| 685 | 702 | ); |
| 686 | 703 | } |
| 687 | return Effect.succeed(match); | |
| 704 | const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); | |
| 705 | // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare | |
| 706 | // the complete repository identity before narrowing those checkouts by host. | |
| 707 | return listWorkspaceProjects( | |
| 708 | repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, | |
| 709 | ).pipe( | |
| 710 | Effect.flatMap(({ supported }) => { | |
| 711 | const onHost = supported.filter((candidate) => candidate.host === host); | |
| 712 | const route = | |
| 713 | supported.find( | |
| 714 | (candidate) => | |
| 715 | candidate.api.kind === "azure-devops" && | |
| 716 | candidate.project.repositoryIdentity != null && | |
| 717 | canonicalRepositoryKey( | |
| 718 | candidate.project.repositoryIdentity.canonicalKey.toLowerCase(), | |
| 719 | ) === repositoryKey, | |
| 720 | ) ?? | |
| 721 | onHost.find( | |
| 722 | (candidate) => | |
| 723 | candidate.api.kind !== "azure-devops" && | |
| 724 | candidate.repository.toLowerCase() === repository.toLowerCase(), | |
| 725 | ) ?? | |
| 726 | onHost.find((candidate) => candidate.api.kind !== "azure-devops"); | |
| 727 | if (route === undefined) { | |
| 728 | return Effect.fail( | |
| 729 | new PullRequestUnavailableError({ reason: "provider-unsupported" }), | |
| 730 | ); | |
| 731 | } | |
| 732 | return Effect.succeed( | |
| 733 | route.api.kind === "azure-devops" ? route : { ...route, repository }, | |
| 734 | ); | |
| 735 | }), | |
| 736 | ); | |
| 688 | 737 | }), |
| 689 | 738 | ); |
| 690 | 739 | |
| ... | ... | @@ -829,8 +878,8 @@ export const make = Effect.gen(function* () { |
| 829 | 878 | viewer: string, |
| 830 | 879 | ): boolean => { |
| 831 | 880 | 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()); | |
| 834 | 883 | return ( |
| 835 | 884 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && |
| 836 | 885 | // Judged on the provider row rather than the entry, because the two absences mean |
| ... | ... | @@ -949,9 +998,7 @@ export const make = Effect.gen(function* () { |
| 949 | 998 | const selected = |
| 950 | 999 | continuation === null |
| 951 | 1000 | ? projects |
| 952 | : projects.filter(({ host, repository }) => | |
| 953 | continuation.has(listCursorKey(host, repository)), | |
| 954 | ); | |
| 1001 | : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); | |
| 955 | 1002 | const readable = selected.filter(({ host }) => viewers[host] !== undefined); |
| 956 | 1003 | // A host that could not be read still has projects, and they are absent from the list. |
| 957 | 1004 | // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. |
| ... | ... | @@ -993,7 +1040,7 @@ export const make = Effect.gen(function* () { |
| 993 | 1040 | |
| 994 | 1041 | const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; |
| 995 | 1042 | const cursorOf = (project: SupportedProject): ListCursor | undefined => |
| 996 | continuation?.get(listCursorKey(project.host, project.repository)); | |
| 1043 | continuation?.get(project.cursorKey); | |
| 997 | 1044 | |
| 998 | 1045 | /** |
| 999 | 1046 | * One repository asked on its own. What every host without a search across repositories |
| ... | ... | @@ -1002,7 +1049,7 @@ export const make = Effect.gen(function* () { |
| 1002 | 1049 | const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => { |
| 1003 | 1050 | { |
| 1004 | 1051 | const viewer = viewers[project.host]!; |
| 1005 | const key = listCursorKey(project.host, project.repository); | |
| 1052 | const key = project.cursorKey; | |
| 1006 | 1053 | const cursor = cursorOf(project); |
| 1007 | 1054 | return project.api |
| 1008 | 1055 | .listChangeRequests({ |
| ... | ... | @@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () { |
| 1161 | 1208 | !cursorHere.seenAt.includes(item.number), |
| 1162 | 1209 | ); |
| 1163 | 1210 | return Effect.succeed({ |
| 1164 | key: listCursorKey(project.host, project.repository), | |
| 1211 | key: project.cursorKey, | |
| 1165 | 1212 | entries: items |
| 1166 | 1213 | .filter((item) => matchesRowFilters(item, input.filters, viewer)) |
| 1167 | 1214 | .map((item) => toEntry({ project, item, viewer })), |
| ... | ... | @@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () { |
| 1254 | 1301 | title: changeRequest.title, |
| 1255 | 1302 | url: changeRequest.url, |
| 1256 | 1303 | state: changeRequest.state, |
| 1257 | ...(changeRequest.isDraft === true ? { isDraft: true } : {}), | |
| 1258 | 1304 | headBranch: changeRequest.headBranch, |
| 1259 | 1305 | baseBranch: changeRequest.baseBranch, |
| 1260 | 1306 | closedAt: changeRequest.closedAt ?? null, |
| 1261 | 1307 | mergedAt: changeRequest.mergedAt ?? null, |
| 1262 | 1308 | updatedAt: changeRequest.updatedAt, |
| 1309 | ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), | |
| 1310 | ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), | |
| 1311 | ...(changeRequest.additions === undefined | |
| 1312 | ? {} | |
| 1313 | : { additions: changeRequest.additions }), | |
| 1314 | ...(changeRequest.deletions === undefined | |
| 1315 | ? {} | |
| 1316 | : { deletions: changeRequest.deletions }), | |
| 1317 | ...(changeRequest.changedFiles === undefined | |
| 1318 | ? {} | |
| 1319 | : { changedFiles: changeRequest.changedFiles }), | |
| 1320 | ...(changeRequest.reviewDecision === undefined | |
| 1321 | ? {} | |
| 1322 | : { reviewDecision: changeRequest.reviewDecision }), | |
| 1323 | ...(changeRequest.checksState === undefined | |
| 1324 | ? {} | |
| 1325 | : { checksState: changeRequest.checksState }), | |
| 1326 | ...(changeRequest.mergeability === undefined | |
| 1327 | ? {} | |
| 1328 | : { mergeability: changeRequest.mergeability }), | |
| 1263 | 1329 | })), |
| 1264 | 1330 | ); |
| 1265 | 1331 | }), |
| 1266 | 1332 | ); |
| 1267 | 1333 | |
| 1334 | const stackUncached: PullRequestService["Service"]["stack"] = (input) => | |
| 1335 | requireProject(input).pipe( | |
| 1336 | Effect.flatMap((project) => { | |
| 1337 | const read = project.api.getChangeRequestStack; | |
| 1338 | if (read === undefined) return Effect.succeed(null); | |
| 1339 | return read({ | |
| 1340 | cwd: project.project.workspaceRoot, | |
| 1341 | repository: project.repository, | |
| 1342 | host: project.host, | |
| 1343 | number: input.number, | |
| 1344 | }).pipe( | |
| 1345 | Effect.mapError(toPullRequestError("stack")), | |
| 1346 | Effect.map((stack): PullRequestStack | null => | |
| 1347 | stack === null | |
| 1348 | ? null | |
| 1349 | : { | |
| 1350 | id: stack.id, | |
| 1351 | number: stack.number, | |
| 1352 | url: stack.url, | |
| 1353 | base: stack.base, | |
| 1354 | layers: stack.layers.map((layer) => ({ | |
| 1355 | number: layer.number, | |
| 1356 | headBranch: layer.headBranch, | |
| 1357 | state: layer.state, | |
| 1358 | })), | |
| 1359 | }, | |
| 1360 | ), | |
| 1361 | ); | |
| 1362 | }), | |
| 1363 | ); | |
| 1364 | ||
| 1268 | 1365 | const detailUncached: PullRequestService["Service"]["detail"] = (input) => |
| 1269 | 1366 | requireProject(input).pipe( |
| 1270 | 1367 | Effect.flatMap((project) => |
| ... | ... | @@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () { |
| 1507 | 1604 | }) |
| 1508 | 1605 | .pipe( |
| 1509 | 1606 | Effect.mapError(toPullRequestError("runAction")), |
| 1510 | Effect.as(project.repository), | |
| 1607 | Effect.as( | |
| 1608 | project.api.kind === "azure-devops" | |
| 1609 | ? input.repository.trim() | |
| 1610 | : project.repository, | |
| 1611 | ), | |
| 1511 | 1612 | ); |
| 1512 | 1613 | }), |
| 1513 | 1614 | ); |
| ... | ... | @@ -2133,11 +2234,35 @@ export const make = Effect.gen(function* () { |
| 2133 | 2234 | let turnRefreshEpoch = 0; |
| 2134 | 2235 | const refEpochs = new Map<string, number>(); |
| 2135 | 2236 | const REF_EPOCH_CAPACITY = 2_048; |
| 2136 | const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; | |
| 2237 | const refScope = (ref: PullRequestRef) => | |
| 2238 | `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`; | |
| 2137 | 2239 | const refEpoch = (ref: PullRequestRef) => |
| 2138 | 2240 | Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); |
| 2241 | // Keys carry the reference back out of the cache loader, so the slot layout is shared with | |
| 2242 | // `refOfCacheKey` rather than read positionally at every loader. | |
| 2139 | 2243 | const refCacheKey = (ref: PullRequestRef) => |
| 2140 | JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); | |
| 2244 | JSON.stringify([ | |
| 2245 | refEpoch(ref), | |
| 2246 | ref.projectId, | |
| 2247 | ref.host?.toLowerCase() ?? null, | |
| 2248 | ref.repository.toLowerCase(), | |
| 2249 | ref.number, | |
| 2250 | ]); | |
| 2251 | const refOfCacheKey = (key: string): PullRequestRef => { | |
| 2252 | const [, projectId, host, repository, number] = JSON.parse(key) as [ | |
| 2253 | number, | |
| 2254 | string, | |
| 2255 | string | null, | |
| 2256 | string, | |
| 2257 | number, | |
| 2258 | ]; | |
| 2259 | return { | |
| 2260 | projectId, | |
| 2261 | ...(host === null ? {} : { host }), | |
| 2262 | repository, | |
| 2263 | number, | |
| 2264 | } as PullRequestRef; | |
| 2265 | }; | |
| 2141 | 2266 | // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse |
| 2142 | 2267 | // them; explicit refreshes, mutations, and turns strand old and in-flight results. |
| 2143 | 2268 | const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]); |
| ... | ... | @@ -2181,8 +2306,7 @@ export const make = Effect.gen(function* () { |
| 2181 | 2306 | |
| 2182 | 2307 | const summaryCache = yield* Cache.makeWith( |
| 2183 | 2308 | (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)); | |
| 2186 | 2310 | }, |
| 2187 | 2311 | { |
| 2188 | 2312 | capacity: DETAIL_CACHE_CAPACITY, |
| ... | ... | @@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () { |
| 2203 | 2327 | ); |
| 2204 | 2328 | }; |
| 2205 | 2329 | |
| 2330 | const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), { | |
| 2331 | capacity: DETAIL_CACHE_CAPACITY, | |
| 2332 | timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), | |
| 2333 | }); | |
| 2334 | const stack: PullRequestService["Service"]["stack"] = (input) => | |
| 2335 | Cache.get(stackCache, refCacheKey(input)); | |
| 2336 | ||
| 2206 | 2337 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| 2207 | 2338 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. |
| 2208 | 2339 | // The continuation cursors are part of the key, entries sorted so one continuation is one |
| ... | ... | @@ -2283,8 +2414,7 @@ export const make = Effect.gen(function* () { |
| 2283 | 2414 | const detailCache = yield* Cache.makeWith( |
| 2284 | 2415 | (key: string) => { |
| 2285 | 2416 | 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( | |
| 2288 | 2418 | Effect.tap( |
| 2289 | 2419 | Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { |
| 2290 | 2420 | recordStats( |
| ... | ... | @@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () { |
| 2307 | 2437 | timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), |
| 2308 | 2438 | }, |
| 2309 | 2439 | ); |
| 2310 | const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({ | |
| 2440 | const summaryFromDetail = ( | |
| 2441 | detail: PullRequestDetail, | |
| 2442 | previous: PullRequestSummary | undefined, | |
| 2443 | ): PullRequestSummary => ({ | |
| 2444 | // Detail does not carry review/check summaries. Keep the last summary observation. | |
| 2445 | ...previous, | |
| 2311 | 2446 | provider: detail.provider, |
| 2312 | 2447 | projectId: detail.projectId, |
| 2313 | 2448 | repository: detail.repository, |
| ... | ... | @@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () { |
| 2315 | 2450 | title: detail.title, |
| 2316 | 2451 | url: detail.url, |
| 2317 | 2452 | state: detail.state, |
| 2318 | ...(detail.isDraft === true ? { isDraft: true } : {}), | |
| 2453 | isDraft: detail.isDraft, | |
| 2454 | author: detail.author, | |
| 2455 | additions: detail.additions, | |
| 2456 | deletions: detail.deletions, | |
| 2457 | changedFiles: detail.changedFiles, | |
| 2458 | mergeability: detail.mergeability, | |
| 2319 | 2459 | headBranch: detail.headBranch, |
| 2320 | 2460 | baseBranch: detail.baseBranch, |
| 2321 | 2461 | closedAt: detail.closedAt, |
| ... | ... | @@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () { |
| 2338 | 2478 | key, |
| 2339 | 2479 | Cache.get(detailCache, key).pipe( |
| 2340 | 2480 | Effect.tap((value) => { |
| 2341 | const summary = summaryFromDetail(value); | |
| 2481 | const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); | |
| 2342 | 2482 | return shouldReplaceHeldSummary(key, summary) |
| 2343 | 2483 | ? lastGoodSummary.record(key, summary) |
| 2344 | 2484 | : Effect.void; |
| ... | ... | @@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () { |
| 2350 | 2490 | |
| 2351 | 2491 | const activityCache = yield* Cache.makeWith( |
| 2352 | 2492 | (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)); | |
| 2355 | 2494 | }, |
| 2356 | 2495 | { |
| 2357 | 2496 | capacity: DETAIL_CACHE_CAPACITY, |
| ... | ... | @@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () { |
| 2365 | 2504 | |
| 2366 | 2505 | const diffCache = yield* Cache.makeWith( |
| 2367 | 2506 | (key: string) => { |
| 2368 | const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [ | |
| 2507 | const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [ | |
| 2369 | 2508 | number, |
| 2370 | 2509 | string, |
| 2510 | string | null, | |
| 2371 | 2511 | string, |
| 2372 | 2512 | number, |
| 2373 | 2513 | string | null, |
| ... | ... | @@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () { |
| 2375 | 2515 | ]; |
| 2376 | 2516 | return diffUncached({ |
| 2377 | 2517 | projectId, |
| 2518 | ...(host === null ? {} : { host }), | |
| 2378 | 2519 | repository, |
| 2379 | 2520 | number, |
| 2380 | 2521 | ...(cursor === null ? {} : { cursor }), |
| ... | ... | @@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () { |
| 2385 | 2526 | capacity: DIFF_CACHE_CAPACITY, |
| 2386 | 2527 | timeToLive: (exit, key) => { |
| 2387 | 2528 | if (!Exit.isSuccess(exit)) return Duration.zero; |
| 2388 | const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[5]; | |
| 2529 | const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[6]; | |
| 2389 | 2530 | return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; |
| 2390 | 2531 | }, |
| 2391 | 2532 | }, |
| ... | ... | @@ -2394,7 +2535,8 @@ export const make = Effect.gen(function* () { |
| 2394 | 2535 | const key = JSON.stringify([ |
| 2395 | 2536 | refEpoch(input), |
| 2396 | 2537 | input.projectId, |
| 2397 | input.repository, | |
| 2538 | input.host?.toLowerCase() ?? null, | |
| 2539 | input.repository.toLowerCase(), | |
| 2398 | 2540 | input.number, |
| 2399 | 2541 | input.cursor ?? null, |
| 2400 | 2542 | input.commit ?? null, |
| ... | ... | @@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () { |
| 2523 | 2665 | list, |
| 2524 | 2666 | listStats, |
| 2525 | 2667 | summary, |
| 2668 | stack, | |
| 2526 | 2669 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( |
| 2527 | 2670 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
| 2528 | 2671 | ), |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+79| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | decodePullRequestListJson, |
| 12 | 12 | decodePullRequestNodeIdJson, |
| 13 | 13 | decodePullRequestSearchJson, |
| 14 | decodePullRequestStacksJson, | |
| 14 | 15 | decodeLabelCandidatesJson, |
| 15 | 16 | decodeRepositoryAccessJson, |
| 16 | 17 | decodeReviewerCandidatesJson, |
| ... | ... | @@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => { |
| 1500 | 1501 | expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); |
| 1501 | 1502 | }); |
| 1502 | 1503 | }); |
| 1504 | ||
| 1505 | describe("host-native stack decoding", () => { | |
| 1506 | /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ | |
| 1507 | function stack(overrides: Record<string, unknown> = {}) { | |
| 1508 | return { | |
| 1509 | id: 42, | |
| 1510 | number: 3, | |
| 1511 | node_id: "STK_kwDO", | |
| 1512 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 1513 | base: { ref: "main", sha: "abc" }, | |
| 1514 | open: true, | |
| 1515 | created_at: "2026-09-01T00:00:00Z", | |
| 1516 | pull_requests: [ | |
| 1517 | { | |
| 1518 | number: 10, | |
| 1519 | head: { ref: "feat/one" }, | |
| 1520 | state: "closed", | |
| 1521 | merged_at: "2026-09-02T00:00:00Z", | |
| 1522 | }, | |
| 1523 | { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, | |
| 1524 | { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, | |
| 1525 | ], | |
| 1526 | ...overrides, | |
| 1527 | }; | |
| 1528 | } | |
| 1529 | ||
| 1530 | /** The one stack a listing answered with, which these reads all expect to find. */ | |
| 1531 | function expectStack(overrides: Record<string, unknown> = {}) { | |
| 1532 | const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); | |
| 1533 | if (decoded === null) throw new Error("expected a stack"); | |
| 1534 | return decoded; | |
| 1535 | } | |
| 1536 | ||
| 1537 | it("reads the first stack, bottom to top, with merged_at outranking state", () => { | |
| 1538 | expect(expectStack()).toEqual({ | |
| 1539 | id: "42", | |
| 1540 | number: 3, | |
| 1541 | url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 1542 | base: "main", | |
| 1543 | layers: [ | |
| 1544 | { number: 10, headBranch: "feat/one", state: "merged" }, | |
| 1545 | { number: 11, headBranch: "feat/two", state: "open" }, | |
| 1546 | { number: 12, headBranch: "feat/three", state: "closed" }, | |
| 1547 | ], | |
| 1548 | }); | |
| 1549 | }); | |
| 1550 | ||
| 1551 | it("accepts a base named as a bare branch, which is what the preview started out sending", () => { | |
| 1552 | expect(expectStack({ base: "develop" }).base).toBe("develop"); | |
| 1553 | }); | |
| 1554 | ||
| 1555 | it("prefers the page a person opens over the API URL, where the host reports one", () => { | |
| 1556 | expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( | |
| 1557 | "https://github.com/acme/web/stacks/3", | |
| 1558 | ); | |
| 1559 | }); | |
| 1560 | ||
| 1561 | it("falls back to the node id, then the number, for a stack without an id", () => { | |
| 1562 | expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); | |
| 1563 | expect(expectStack({ id: null, node_id: null }).id).toBe("3"); | |
| 1564 | }); | |
| 1565 | ||
| 1566 | it("reads an empty listing as not stacked", () => { | |
| 1567 | expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); | |
| 1568 | }); | |
| 1569 | ||
| 1570 | it("refuses a stack without a number or without its pull requests", () => { | |
| 1571 | expect( | |
| 1572 | Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), | |
| 1573 | ).toBe(false); | |
| 1574 | expect( | |
| 1575 | Result.isSuccess( | |
| 1576 | decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), | |
| 1577 | ), | |
| 1578 | ).toBe(false); | |
| 1579 | expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); | |
| 1580 | }); | |
| 1581 | }); |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+66| ... | ... | @@ -2445,3 +2445,69 @@ export function decodePullRequestFilesJson( |
| 2445 | 2445 | omittedFileStats, |
| 2446 | 2446 | }); |
| 2447 | 2447 | } |
| 2448 | ||
| 2449 | /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ | |
| 2450 | const 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 | */ | |
| 2463 | const 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 | ||
| 2473 | const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); | |
| 2474 | ||
| 2475 | export interface GitHubPullRequestStackLayer { | |
| 2476 | readonly number: number; | |
| 2477 | readonly headBranch: string; | |
| 2478 | readonly state: PullRequestState; | |
| 2479 | } | |
| 2480 | ||
| 2481 | export 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 | */ | |
| 2494 | export 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 @@ |
| 1 | import { assert, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | ||
| 5 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 6 | import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; | |
| 7 | ||
| 8 | it.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 @@ |
| 1 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | |
| 2 | import { | |
| 3 | PullRequestLinkedThreadsResult, | |
| 4 | PullRequestOperationError, | |
| 5 | type ThreadPullRequestKey, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 10 | ||
| 11 | const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult); | |
| 12 | ||
| 13 | export 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 @@ |
| 1 | import { ProjectId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; | |
| 5 | ||
| 6 | describe("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 @@ |
| 1 | import { | |
| 2 | pullRequestHostOf, | |
| 3 | type PullRequestRef, | |
| 4 | type RepositoryIdentity, | |
| 5 | type SourceControlProviderKind, | |
| 6 | type ThreadPullRequestKey, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | |
| 9 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | |
| 10 | ||
| 11 | /** Convert checkout-scoped references to the host-level identity used by linked threads. */ | |
| 12 | export function pullRequestSyncKey( | |
| 13 | reference: PullRequestRef, | |
| 14 | identity?: RepositoryIdentity | null, | |
| 15 | ): ThreadPullRequestKey | null { | |
| 16 | if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) { | |
| 17 | if ( | |
| 18 | reference.repository.toLowerCase() !== | |
| 19 | sourceControlRepositorySelector(identity)?.toLowerCase() || | |
| 20 | (reference.host !== undefined && | |
| 21 | reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops")) | |
| 22 | ) | |
| 23 | return null; | |
| 24 | const [host, ...repository] = identity.canonicalKey.split("/"); | |
| 25 | if (!host || repository.length === 0) return null; | |
| 26 | return normalizeThreadPullRequestKey({ | |
| 27 | host, | |
| 28 | repository: repository.join("/"), | |
| 29 | number: reference.number, | |
| 30 | }); | |
| 31 | } | |
| 32 | const host = | |
| 33 | reference.host ?? | |
| 34 | (identity | |
| 35 | ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) | |
| 36 | : undefined); | |
| 37 | return host === undefined | |
| 38 | ? null | |
| 39 | : normalizeThreadPullRequestKey({ | |
| 40 | host, | |
| 41 | repository: reference.repository, | |
| 42 | number: reference.number, | |
| 43 | }); | |
| 44 | } |
apps/server/src/relay/AgentAwarenessRelay.test.ts+3| ... | ... | @@ -334,6 +334,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 334 | 334 | interactionMode: "default", |
| 335 | 335 | branch: null, |
| 336 | 336 | worktreePath: null, |
| 337 | pullRequests: [], | |
| 337 | 338 | latestTurn: null, |
| 338 | 339 | createdAt: now, |
| 339 | 340 | updatedAt: now, |
| ... | ... | @@ -477,6 +478,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 477 | 478 | interactionMode: "default", |
| 478 | 479 | branch: null, |
| 479 | 480 | worktreePath: null, |
| 481 | pullRequests: [], | |
| 480 | 482 | latestTurn: { |
| 481 | 483 | turnId: "turn-1" as TurnId, |
| 482 | 484 | state: "running", |
| ... | ... | @@ -668,6 +670,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 668 | 670 | interactionMode: "default", |
| 669 | 671 | branch: null, |
| 670 | 672 | worktreePath: null, |
| 673 | pullRequests: [], | |
| 671 | 674 | latestTurn: { |
| 672 | 675 | turnId: "turn-1" as TurnId, |
| 673 | 676 | state: "running", |
apps/server/src/server.test.ts+10-1| ... | ... | @@ -117,6 +117,7 @@ import { |
| 117 | 117 | } from "./orchestration/Errors.ts"; |
| 118 | 118 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 119 | 119 | import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; |
| 120 | import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; | |
| 120 | 121 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 121 | 122 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts"; |
| 122 | 123 | import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; |
| ... | ... | @@ -331,6 +332,7 @@ const makeDefaultOrchestrationReadModel = () => { |
| 331 | 332 | runtimeMode: "full-access" as const, |
| 332 | 333 | branch: null, |
| 333 | 334 | worktreePath: null, |
| 335 | pullRequests: [], | |
| 334 | 336 | createdAt: now, |
| 335 | 337 | updatedAt: now, |
| 336 | 338 | archivedAt: null, |
| ... | ... | @@ -361,6 +363,7 @@ const makeDefaultOrchestrationThreadShell = ( |
| 361 | 363 | interactionMode: "default", |
| 362 | 364 | branch: null, |
| 363 | 365 | worktreePath: null, |
| 366 | pullRequests: [], | |
| 364 | 367 | latestTurn: null, |
| 365 | 368 | createdAt: now, |
| 366 | 369 | updatedAt: now, |
| ... | ... | @@ -384,7 +387,7 @@ const browserOtlpTracingLayer = Layer.mergeAll( |
| 384 | 387 | |
| 385 | 388 | const makeAuthTestLayer = () => |
| 386 | 389 | EnvironmentAuth.layer.pipe( |
| 387 | Layer.provide(SqlitePersistenceMemory), | |
| 390 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 388 | 391 | Layer.provide(ServerSecretStore.layer), |
| 389 | 392 | Layer.provide( |
| 390 | 393 | Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ |
| ... | ... | @@ -956,6 +959,11 @@ const buildAppUnderTest = (options?: { |
| 956 | 959 | drainThrough: () => Effect.void, |
| 957 | 960 | ...options?.layers?.threadDeletionReactor, |
| 958 | 961 | }), |
| 962 | Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({ | |
| 963 | start: () => Effect.void, | |
| 964 | drain: Effect.void, | |
| 965 | requestSync: () => Effect.void, | |
| 966 | }), | |
| 959 | 967 | ), |
| 960 | 968 | ), |
| 961 | 969 | Layer.provide( |
| ... | ... | @@ -8104,6 +8112,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 8104 | 8112 | runtimeMode: "full-access" as const, |
| 8105 | 8113 | branch: null, |
| 8106 | 8114 | worktreePath: null, |
| 8115 | pullRequests: [], | |
| 8107 | 8116 | createdAt: now, |
| 8108 | 8117 | updatedAt: now, |
| 8109 | 8118 | archivedAt: null, |
apps/server/src/server.ts+2| ... | ... | @@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma |
| 71 | 71 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 72 | 72 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; |
| 73 | 73 | import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; |
| 74 | import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; | |
| 74 | 75 | import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; |
| 75 | 76 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 76 | 77 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| ... | ... | @@ -280,6 +281,7 @@ const ReactorLayerLive = Layer.empty.pipe( |
| 280 | 281 | Layer.provideMerge(CheckpointReactorLive), |
| 281 | 282 | Layer.provideMerge(ThreadDeletionReactorLive), |
| 282 | 283 | Layer.provideMerge(ThreadSettlementReactor.layer), |
| 284 | Layer.provideMerge(PullRequestSyncReactor.layer), | |
| 283 | 285 | Layer.provideMerge(ThreadPullRequestReactor.layer), |
| 284 | 286 | Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), |
| 285 | 287 | Layer.provideMerge(RuntimeReceiptBusLive), |
apps/server/src/ws.ts+89-8| ... | ... | @@ -70,6 +70,7 @@ import { |
| 70 | 70 | type TerminalError, |
| 71 | 71 | type TerminalEvent, |
| 72 | 72 | type TerminalMetadataStreamEvent, |
| 73 | type PullRequestRef, | |
| 73 | 74 | WS_METHODS, |
| 74 | 75 | WsRpcGroup, |
| 75 | 76 | } from "@t3tools/contracts"; |
| ... | ... | @@ -124,6 +125,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 124 | 125 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 125 | 126 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 126 | 127 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 128 | import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; | |
| 127 | 129 | import * as ReviewService from "./review/ReviewService.ts"; |
| 128 | 130 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 129 | 131 | import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; |
| ... | ... | @@ -142,6 +144,10 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; |
| 142 | 144 | import * as UsageService from "./usage/UsageService.ts"; |
| 143 | 145 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 144 | 146 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| 147 | import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; | |
| 148 | import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; | |
| 149 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 150 | import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; | |
| 145 | 151 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 146 | 152 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 147 | 153 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| ... | ... | @@ -476,7 +482,18 @@ const makeWsRpcLayer = ( |
| 476 | 482 | Effect.gen(function* () { |
| 477 | 483 | const currentSessionId = currentSession.sessionId; |
| 478 | 484 | const crypto = yield* Crypto.Crypto; |
| 485 | const sql = yield* SqlClient.SqlClient; | |
| 479 | 486 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 487 | /** A reference's host-level link key; the project's own host where the ref names none. */ | |
| 488 | const resolvePullRequestSyncKey = (reference: PullRequestRef) => | |
| 489 | reference.host !== undefined && reference.repository.includes("/") | |
| 490 | ? Effect.succeed(pullRequestSyncKey(reference)) | |
| 491 | : projectionSnapshotQuery.getProjectShellById(reference.projectId).pipe( | |
| 492 | Effect.map((project) => | |
| 493 | pullRequestSyncKey(reference, Option.getOrUndefined(project)?.repositoryIdentity), | |
| 494 | ), | |
| 495 | Effect.orElseSucceed(() => null), | |
| 496 | ); | |
| 480 | 497 | const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; |
| 481 | 498 | const threadDeletionReactor = yield* ThreadDeletionReactor; |
| 482 | 499 | const analytics = yield* AnalyticsService.AnalyticsService; |
| ... | ... | @@ -604,6 +621,7 @@ const makeWsRpcLayer = ( |
| 604 | 621 | const sourceControlRepositories = |
| 605 | 622 | yield* SourceControlRepositoryService.SourceControlRepositoryService; |
| 606 | 623 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 624 | const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; | |
| 607 | 625 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 608 | 626 | const sessions = yield* SessionStore.SessionStore; |
| 609 | 627 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| ... | ... | @@ -2133,6 +2151,24 @@ const makeWsRpcLayer = ( |
| 2133 | 2151 | observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { |
| 2134 | 2152 | "rpc.aggregate": "pull-requests", |
| 2135 | 2153 | }), |
| 2154 | [WS_METHODS.pullRequestsStack]: (input) => | |
| 2155 | observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { | |
| 2156 | "rpc.aggregate": "pull-requests", | |
| 2157 | }), | |
| 2158 | [WS_METHODS.pullRequestsLinkedThreads]: (input) => | |
| 2159 | observeRpcEffect( | |
| 2160 | WS_METHODS.pullRequestsLinkedThreads, | |
| 2161 | resolvePullRequestSyncKey(input).pipe( | |
| 2162 | Effect.flatMap((key) => | |
| 2163 | key === null | |
| 2164 | ? Effect.succeed({ threads: [] }) | |
| 2165 | : listLinkedPullRequestThreads(key).pipe( | |
| 2166 | Effect.provideService(SqlClient.SqlClient, sql), | |
| 2167 | ), | |
| 2168 | ), | |
| 2169 | ), | |
| 2170 | { "rpc.aggregate": "pull-requests" }, | |
| 2171 | ), | |
| 2136 | 2172 | [WS_METHODS.pullRequestsDetail]: (input) => |
| 2137 | 2173 | observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { |
| 2138 | 2174 | "rpc.aggregate": "pull-requests", |
| ... | ... | @@ -2156,9 +2192,21 @@ const makeWsRpcLayer = ( |
| 2156 | 2192 | { "rpc.aggregate": "pull-requests" }, |
| 2157 | 2193 | ), |
| 2158 | 2194 | [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 | ), | |
| 2162 | 2210 | [WS_METHODS.pullRequestsUpdate]: (input) => |
| 2163 | 2211 | observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), { |
| 2164 | 2212 | "rpc.aggregate": "pull-requests", |
| ... | ... | @@ -2196,9 +2244,23 @@ const makeWsRpcLayer = ( |
| 2196 | 2244 | "rpc.aggregate": "pull-requests", |
| 2197 | 2245 | }), |
| 2198 | 2246 | [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 | ), | |
| 2202 | 2264 | [WS_METHODS.pullRequestsSubscribeRefreshes]: () => |
| 2203 | 2265 | observeRpcStream( |
| 2204 | 2266 | WS_METHODS.pullRequestsSubscribeRefreshes, |
| ... | ... | @@ -2507,8 +2569,25 @@ const makeWsRpcLayer = ( |
| 2507 | 2569 | .pipe( |
| 2508 | 2570 | Effect.matchCauseEffect({ |
| 2509 | 2571 | 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)), | |
| 2512 | 2591 | Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), |
| 2513 | 2592 | ), |
| 2514 | 2593 | }), |
| ... | ... | @@ -2932,6 +3011,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2932 | 3011 | ), |
| 2933 | 3012 | }); |
| 2934 | 3013 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 3014 | const sql = yield* SqlClient.SqlClient; | |
| 2935 | 3015 | return HttpRouter.add( |
| 2936 | 3016 | "GET", |
| 2937 | 3017 | "/ws", |
| ... | ... | @@ -2966,6 +3046,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2966 | 3046 | previewAutomationBroker, |
| 2967 | 3047 | ).pipe( |
| 2968 | 3048 | Layer.provideMerge(RpcSerialization.layerJson), |
| 3049 | Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), | |
| 2969 | 3050 | Layer.provide(AgentSessionScanner.layer), |
| 2970 | 3051 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 2971 | 3052 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), |
apps/web/src/components/ChatMarkdown.test.tsx+2-2| ... | ... | @@ -43,6 +43,7 @@ vi.mock("../state/session", async (importOriginal) => ({ |
| 43 | 43 | vi.mock("../state/entities", () => ({ |
| 44 | 44 | readThreadShell: () => null, |
| 45 | 45 | useProjects: () => [], |
| 46 | useServerConfigs: () => new Map(), | |
| 46 | 47 | })); |
| 47 | 48 | vi.mock("../remoteOpen", () => ({ |
| 48 | 49 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), |
| ... | ... | @@ -52,8 +53,7 @@ vi.mock("../editorPreferences", () => ({ |
| 52 | 53 | usePreferredEditor: () => [null, vi.fn()], |
| 53 | 54 | })); |
| 54 | 55 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 55 | findProjectForChangeRequest: () => undefined, | |
| 56 | matchesLinkedPullRequestUrl: () => false, | |
| 56 | findProjectOnChangeRequestHost: () => undefined, | |
| 57 | 57 | parseChangeRequestUrl: () => null, |
| 58 | 58 | useOpenChangeRequestLink: () => vi.fn(), |
| 59 | 59 | })); |
apps/web/src/components/ChatMarkdown.tsx+25-50| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { |
| 3 | 4 | CheckIcon, |
| ... | ... | @@ -26,7 +27,7 @@ import type { |
| 26 | 27 | EnvironmentId, |
| 27 | 28 | ScopedThreadRef, |
| 28 | 29 | ServerProviderSkill, |
| 29 | ThreadLinkedPullRequest, | |
| 30 | ThreadPullRequestKey, | |
| 30 | 31 | } from "@t3tools/contracts"; |
| 31 | 32 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; |
| 32 | 33 | import { |
| ... | ... | @@ -155,7 +156,6 @@ import { previewEnvironment } from "../state/preview"; |
| 155 | 156 | import { useAtomCommand } from "../state/use-atom-command"; |
| 156 | 157 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 157 | 158 | import { projectEnvironment } from "../state/projects"; |
| 158 | import { threadEnvironment } from "../state/threads"; | |
| 159 | 159 | import { |
| 160 | 160 | claimWorkspaceBasenameLookup, |
| 161 | 161 | needsWorkspaceBasenameLookup, |
| ... | ... | @@ -164,7 +164,6 @@ import { |
| 164 | 164 | } from "../workspaceBasenameLookup"; |
| 165 | 165 | import { |
| 166 | 166 | findProjectForChangeRequest, |
| 167 | matchesLinkedPullRequestUrl, | |
| 168 | 167 | parseChangeRequestUrl, |
| 169 | 168 | pullRequestCandidateUrlFromReferenceAutolink, |
| 170 | 169 | useOpenChangeRequestLink, |
| ... | ... | @@ -2185,9 +2184,7 @@ function useChatMarkdownState({ |
| 2185 | 2184 | const openPreview = useAtomCommand(previewEnvironment.open, { |
| 2186 | 2185 | reportFailure: false, |
| 2187 | 2186 | }); |
| 2188 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 2189 | reportFailure: false, | |
| 2190 | }); | |
| 2187 | const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId); | |
| 2191 | 2188 | const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; |
| 2192 | 2189 | const remoteOpen = useRemoteOpenResolution(environmentId); |
| 2193 | 2190 | const canUseShellActions = canUseMarkdownFileShellActions( |
| ... | ... | @@ -2239,9 +2236,6 @@ function useChatMarkdownState({ |
| 2239 | 2236 | [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], |
| 2240 | 2237 | ); |
| 2241 | 2238 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 2242 | const threadServerConfig = useAtomValue( | |
| 2243 | serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), | |
| 2244 | ); | |
| 2245 | 2239 | const projects = useProjects(); |
| 2246 | 2240 | const availableEditors = serverConfig?.availableEditors ?? []; |
| 2247 | 2241 | const [preferredEditor] = usePreferredEditor(availableEditors); |
| ... | ... | @@ -2331,52 +2325,33 @@ function useChatMarkdownState({ |
| 2331 | 2325 | // makes a persisted "app" apply once settings hydrate after launch. |
| 2332 | 2326 | const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); |
| 2333 | 2327 | const resolveThreadPullRequest = useCallback( |
| 2334 | (href: string): ThreadLinkedPullRequest | null => { | |
| 2328 | (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { | |
| 2335 | 2329 | if ( |
| 2336 | 2330 | threadRef === undefined || |
| 2337 | 2331 | readThreadShell(threadRef) === null || |
| 2338 | threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true | |
| 2339 | ) { | |
| 2332 | !pullRequestLinking.canLink(href) | |
| 2333 | ) | |
| 2340 | 2334 | return null; |
| 2341 | } | |
| 2342 | 2335 | 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 }; | |
| 2355 | 2337 | }, |
| 2356 | [projects, threadRef, threadServerConfig], | |
| 2338 | [pullRequestLinking, threadRef], | |
| 2339 | ); | |
| 2340 | const linkedThreadPullRequestFor = useCallback( | |
| 2341 | (href: string) => { | |
| 2342 | if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) | |
| 2343 | return null; | |
| 2344 | const parsed = parseChangeRequestUrl(href); | |
| 2345 | return parsed === null ? null : { ...parsed, url: href }; | |
| 2346 | }, | |
| 2347 | [pullRequestLinking, threadRef], | |
| 2357 | 2348 | ); |
| 2358 | 2349 | const updateThreadPullRequestLink = useCallback( |
| 2359 | 2350 | 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); | |
| 2378 | 2353 | }, |
| 2379 | [resolveThreadPullRequest, threadRef, updateThreadMetadata], | |
| 2354 | [linkedThreadPullRequestFor, pullRequestLinking, threadRef], | |
| 2380 | 2355 | ); |
| 2381 | 2356 | const openExternalLinkInPreview = useCallback( |
| 2382 | 2357 | (url: string) => { |
| ... | ... | @@ -2588,6 +2563,7 @@ function useChatMarkdownState({ |
| 2588 | 2563 | openExternalLinkInPreview, |
| 2589 | 2564 | openMarkdownMedia, |
| 2590 | 2565 | projects, |
| 2566 | linkedThreadPullRequestFor, | |
| 2591 | 2567 | resolveThreadPullRequest, |
| 2592 | 2568 | resolvedTheme, |
| 2593 | 2569 | serverConfig, |
| ... | ... | @@ -2614,6 +2590,7 @@ function useChatMarkdownState({ |
| 2614 | 2590 | openExternalLinkInPreview, |
| 2615 | 2591 | openMarkdownMedia, |
| 2616 | 2592 | projects, |
| 2593 | linkedThreadPullRequestFor, | |
| 2617 | 2594 | resolveThreadPullRequest, |
| 2618 | 2595 | resolvedTheme, |
| 2619 | 2596 | serverConfig, |
| ... | ... | @@ -2732,6 +2709,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2732 | 2709 | linkTargetPreference, |
| 2733 | 2710 | openExternalLinkInPreview, |
| 2734 | 2711 | projects, |
| 2712 | linkedThreadPullRequestFor, | |
| 2735 | 2713 | resolveThreadPullRequest, |
| 2736 | 2714 | serverConfig, |
| 2737 | 2715 | updateThreadPullRequestLink, |
| ... | ... | @@ -2866,13 +2844,10 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2866 | 2844 | event.stopPropagation(); |
| 2867 | 2845 | const api = readLocalApi(); |
| 2868 | 2846 | if (!api) return; |
| 2869 | const pullRequest = resolveThreadPullRequest(href); | |
| 2870 | const currentPullRequest = | |
| 2871 | threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; | |
| 2872 | 2847 | const threadLinkAction = |
| 2873 | currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) | |
| 2848 | linkedThreadPullRequestFor(href) !== null | |
| 2874 | 2849 | ? "unlink-from-thread" |
| 2875 | : pullRequest === null | |
| 2850 | : resolveThreadPullRequest(href) === null | |
| 2876 | 2851 | ? undefined |
| 2877 | 2852 | : "link-to-thread"; |
| 2878 | 2853 | void showExternalLinkContextMenu({ |
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+2-2| ... | ... | @@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({ |
| 32 | 32 | vi.mock("../state/entities", () => ({ |
| 33 | 33 | readThreadShell: () => null, |
| 34 | 34 | useProjects: () => [], |
| 35 | useServerConfigs: () => new Map(), | |
| 35 | 36 | })); |
| 36 | 37 | vi.mock("../remoteOpen", () => ({ |
| 37 | 38 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), |
| ... | ... | @@ -41,8 +42,7 @@ vi.mock("../editorPreferences", () => ({ |
| 41 | 42 | usePreferredEditor: () => [null, vi.fn()], |
| 42 | 43 | })); |
| 43 | 44 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 44 | findProjectForChangeRequest: () => undefined, | |
| 45 | matchesLinkedPullRequestUrl: () => false, | |
| 45 | findProjectOnChangeRequestHost: () => undefined, | |
| 46 | 46 | parseChangeRequestUrl: () => null, |
| 47 | 47 | useOpenChangeRequestLink: () => vi.fn(), |
| 48 | 48 | })); |
apps/web/src/components/ChatView.logic.test.ts+2| ... | ... | @@ -726,6 +726,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 726 | 726 | proposedPlans: [], |
| 727 | 727 | activities: [], |
| 728 | 728 | checkpoints: [], |
| 729 | pullRequests: [], | |
| 729 | 730 | createdAt: now, |
| 730 | 731 | updatedAt: now, |
| 731 | 732 | archivedAt: null, |
| ... | ... | @@ -843,6 +844,7 @@ describe("buildLoadingThreadFromShell", () => { |
| 843 | 844 | snoozedUntil: null, |
| 844 | 845 | snoozedAt: null, |
| 845 | 846 | session: null, |
| 847 | pullRequests: [], | |
| 846 | 848 | latestUserMessageAt: now, |
| 847 | 849 | hasPendingApprovals: false, |
| 848 | 850 | hasPendingUserInput: false, |
apps/web/src/components/ChatView.logic.ts+1| ... | ... | @@ -353,6 +353,7 @@ export function buildLocalDraftThread( |
| 353 | 353 | branch: draftThread.branch, |
| 354 | 354 | worktreePath: draftThread.worktreePath, |
| 355 | 355 | checkpoints: [], |
| 356 | pullRequests: [], | |
| 356 | 357 | activities: [], |
| 357 | 358 | proposedPlans: [], |
| 358 | 359 | }; |
apps/web/src/components/ChatView.tsx+30-2| ... | ... | @@ -203,6 +203,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; |
| 203 | 203 | import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; |
| 204 | 204 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 205 | 205 | import { AgentsPanel } from "./AgentsPanel"; |
| 206 | import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; | |
| 207 | import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; | |
| 206 | 208 | import { |
| 207 | 209 | deriveAgentPanelModel, |
| 208 | 210 | foldSubagentActivities, |
| ... | ... | @@ -4127,6 +4129,12 @@ export default function ChatView(props: ChatViewProps) { |
| 4127 | 4129 | if (!activeThreadRef) return; |
| 4128 | 4130 | useRightPanelStore.getState().open(activeThreadRef, "agents"); |
| 4129 | 4131 | }, [activeThreadRef]); |
| 4132 | const supportsThreadPullRequests = | |
| 4133 | serverConfig?.environment.capabilities.threadPullRequests === true; | |
| 4134 | const addPullRequestsSurface = useCallback(() => { | |
| 4135 | if (!activeThreadRef || !supportsThreadPullRequests) return; | |
| 4136 | useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); | |
| 4137 | }, [activeThreadRef, supportsThreadPullRequests]); | |
| 4130 | 4138 | const openFileSurface = useCallback( |
| 4131 | 4139 | (relativePath: string) => { |
| 4132 | 4140 | if (!activeThreadRef || !activeProject) return; |
| ... | ... | @@ -5375,9 +5383,16 @@ export default function ChatView(props: ChatViewProps) { |
| 5375 | 5383 | : resolveThreadReferenceCopyTarget({ |
| 5376 | 5384 | threadId: activeThreadId, |
| 5377 | 5385 | openPanelPullRequestUrl, |
| 5386 | pullRequests: activeThreadMetadata?.pullRequests, | |
| 5378 | 5387 | linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, |
| 5379 | 5388 | }), |
| 5380 | [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl], | |
| 5389 | [ | |
| 5390 | activeThreadId, | |
| 5391 | isServerThread, | |
| 5392 | activeThreadMetadata?.pullRequests, | |
| 5393 | linkedThreadPullRequest?.url, | |
| 5394 | openPanelPullRequestUrl, | |
| 5395 | ], | |
| 5381 | 5396 | ); |
| 5382 | 5397 | const copyActiveThreadReference = useCallback(() => { |
| 5383 | 5398 | const target = activeThreadReferenceCopyTarget; |
| ... | ... | @@ -8037,11 +8052,12 @@ export default function ChatView(props: ChatViewProps) { |
| 8037 | 8052 | // reader's feet. A link the agent wrote can open any other one here, and that one has to be |
| 8038 | 8053 | // checkable out like it is anywhere else. |
| 8039 | 8054 | <PullRequestDetailPanel |
| 8040 | key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} | |
| 8055 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} | |
| 8041 | 8056 | environmentId={activeThread.environmentId} |
| 8042 | 8057 | threadRef={activeThreadRef} |
| 8043 | 8058 | reference={{ |
| 8044 | 8059 | projectId: renderedRightPanelSurface.projectId as ProjectId, |
| 8060 | ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), | |
| 8045 | 8061 | repository: renderedRightPanelSurface.repository, |
| 8046 | 8062 | number: renderedRightPanelSurface.number, |
| 8047 | 8063 | }} |
| ... | ... | @@ -8062,7 +8078,14 @@ export default function ChatView(props: ChatViewProps) { |
| 8062 | 8078 | : "page" |
| 8063 | 8079 | } |
| 8064 | 8080 | composerDraftTarget={composerDraftTarget} |
| 8081 | onBack={ | |
| 8082 | activeThreadRef !== null && supportsThreadPullRequests | |
| 8083 | ? addPullRequestsSurface | |
| 8084 | : undefined | |
| 8085 | } | |
| 8065 | 8086 | /> |
| 8087 | ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( | |
| 8088 | <ThreadPullRequestsPanel threadRef={activeThreadRef} /> | |
| 8066 | 8089 | ) : renderedRightPanelSurface?.kind === "agents" ? ( |
| 8067 | 8090 | <AgentsPanel |
| 8068 | 8091 | model={agentPanelModel} |
| ... | ... | @@ -8620,12 +8643,14 @@ export default function ChatView(props: ChatViewProps) { |
| 8620 | 8643 | onAddDiff={addDiffSurface} |
| 8621 | 8644 | onAddFiles={addFilesSurface} |
| 8622 | 8645 | onAddPullRequest={addPullRequestSurface} |
| 8646 | onAddPullRequests={addPullRequestsSurface} | |
| 8623 | 8647 | onAddAgents={addAgentsSurface} |
| 8624 | 8648 | browserAvailable={isPreviewSupportedInRuntime()} |
| 8625 | 8649 | terminalAvailable={activeProject !== null} |
| 8626 | 8650 | diffAvailable={isServerThread && isGitRepo} |
| 8627 | 8651 | filesAvailable={activeProject !== null} |
| 8628 | 8652 | pullRequestAvailable={pullRequestSurfaceAvailable} |
| 8653 | pullRequestsAvailable={isServerThread && supportsThreadPullRequests} | |
| 8629 | 8654 | agentsAvailable |
| 8630 | 8655 | liveAgentCount={agentPanelModel.liveCount} |
| 8631 | 8656 | > |
| ... | ... | @@ -8670,12 +8695,14 @@ export default function ChatView(props: ChatViewProps) { |
| 8670 | 8695 | onAddDiff={addDiffSurface} |
| 8671 | 8696 | onAddFiles={addFilesSurface} |
| 8672 | 8697 | onAddPullRequest={addPullRequestSurface} |
| 8698 | onAddPullRequests={addPullRequestsSurface} | |
| 8673 | 8699 | onAddAgents={addAgentsSurface} |
| 8674 | 8700 | browserAvailable={isPreviewSupportedInRuntime()} |
| 8675 | 8701 | terminalAvailable={activeProject !== null} |
| 8676 | 8702 | diffAvailable={isServerThread && isGitRepo} |
| 8677 | 8703 | filesAvailable={activeProject !== null} |
| 8678 | 8704 | pullRequestAvailable={pullRequestSurfaceAvailable} |
| 8705 | pullRequestsAvailable={isServerThread && supportsThreadPullRequests} | |
| 8679 | 8706 | agentsAvailable |
| 8680 | 8707 | liveAgentCount={agentPanelModel.liveCount} |
| 8681 | 8708 | > |
| ... | ... | @@ -8684,6 +8711,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8684 | 8711 | </RightPanelSheet> |
| 8685 | 8712 | ) : null} |
| 8686 | 8713 | |
| 8714 | <LinkPullRequestDialogHost /> | |
| 8687 | 8715 | {expandedImage && ( |
| 8688 | 8716 | <ExpandedImageDialog |
| 8689 | 8717 | key={expandedImageKey(expandedImage)} |
apps/web/src/components/CommandPalette.logic.test.ts+1| ... | ... | @@ -149,6 +149,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 149 | 149 | branch: null, |
| 150 | 150 | worktreePath: null, |
| 151 | 151 | checkpoints: [], |
| 152 | pullRequests: [], | |
| 152 | 153 | activities: [], |
| 153 | 154 | ...overrides, |
| 154 | 155 | }; |
apps/web/src/components/CommandPalette.tsx+38-1| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; | |
| 4 | ||
| 3 | 5 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | 6 | import { |
| 5 | 7 | canCreateProjectInEnvironment, |
| ... | ... | @@ -43,6 +45,7 @@ import { |
| 43 | 45 | FileSearchIcon, |
| 44 | 46 | FolderIcon, |
| 45 | 47 | FolderPlusIcon, |
| 48 | GitPullRequestArrowIcon, | |
| 46 | 49 | LinkIcon, |
| 47 | 50 | MessageSquareIcon, |
| 48 | 51 | PaletteIcon, |
| ... | ... | @@ -80,7 +83,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; |
| 80 | 83 | import { useAtomCommand } from "../state/use-atom-command"; |
| 81 | 84 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 82 | 85 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 83 | import { useProjects, useThreadShells } from "../state/entities"; | |
| 86 | import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; | |
| 84 | 87 | import { useThreadSearch } from "../state/queries"; |
| 85 | 88 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 86 | 89 | import { |
| ... | ... | @@ -148,6 +151,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" |
| 148 | 151 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 149 | 152 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 150 | 153 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| 154 | import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; | |
| 151 | 155 | import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; |
| 152 | 156 | import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; |
| 153 | 157 | import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; |
| ... | ... | @@ -605,12 +609,16 @@ function OpenCommandPaletteDialog(props: { |
| 605 | 609 | ? scopeThreadRef(activeThread.environmentId, activeThread.id) |
| 606 | 610 | : null; |
| 607 | 611 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); |
| 612 | const activeThreadServerConfig = useServerConfigs().get( | |
| 613 | activeThread?.environmentId ?? ("" as EnvironmentId), | |
| 614 | ); | |
| 608 | 615 | const activeThreadReferenceCopyTarget = |
| 609 | 616 | referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) |
| 610 | 617 | ? null |
| 611 | 618 | : resolveThreadReferenceCopyTarget({ |
| 612 | 619 | threadId: referenceThreadRef.threadId, |
| 613 | 620 | openPanelPullRequestUrl, |
| 621 | pullRequests: activeThread?.pullRequests, | |
| 614 | 622 | linkedPullRequestUrl: |
| 615 | 623 | activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null, |
| 616 | 624 | }); |
| ... | ... | @@ -1591,6 +1599,35 @@ function OpenCommandPaletteDialog(props: { |
| 1591 | 1599 | }); |
| 1592 | 1600 | } |
| 1593 | 1601 | |
| 1602 | if ( | |
| 1603 | activeThread !== null && | |
| 1604 | threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported" | |
| 1605 | ) { | |
| 1606 | const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); | |
| 1607 | actionItems.push({ | |
| 1608 | kind: "action", | |
| 1609 | value: "action:link-pull-request", | |
| 1610 | searchTerms: ["link", "pull request", "pr", "attach", "stack"], | |
| 1611 | title: "Link pull request to thread", | |
| 1612 | icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />, | |
| 1613 | run: async () => { | |
| 1614 | openLinkPullRequestDialog(threadRef); | |
| 1615 | }, | |
| 1616 | }); | |
| 1617 | if (activeThreadServerConfig?.environment.capabilities.threadPullRequests === true) { | |
| 1618 | actionItems.push({ | |
| 1619 | kind: "action", | |
| 1620 | value: "action:open-thread-pull-requests", | |
| 1621 | searchTerms: ["pull requests", "linked", "stack", "prs"], | |
| 1622 | title: "Show linked pull requests", | |
| 1623 | icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />, | |
| 1624 | run: async () => { | |
| 1625 | useRightPanelStore.getState().open(threadRef, "pull-requests"); | |
| 1626 | }, | |
| 1627 | }); | |
| 1628 | } | |
| 1629 | } | |
| 1630 | ||
| 1594 | 1631 | actionItems.push({ |
| 1595 | 1632 | kind: "action", |
| 1596 | 1633 | value: "action:open-file-picker", |
apps/web/src/components/GitActionsControl.tsx+3| ... | ... | @@ -1351,6 +1351,9 @@ export default function GitActionsControl({ |
| 1351 | 1351 | ...(commitMessage ? { commitMessage } : {}), |
| 1352 | 1352 | ...(featureBranch ? { featureBranch } : {}), |
| 1353 | 1353 | ...(filePaths ? { filePaths } : {}), |
| 1354 | // A pull request the action opens is linked to the thread it ran beside. Drafts | |
| 1355 | // have no server thread yet, so there is nothing to link to. | |
| 1356 | ...(activeServerThread ? { threadId: activeServerThread.id } : {}), | |
| 1354 | 1357 | onProgress: applyProgressEvent, |
| 1355 | 1358 | }); |
| 1356 | 1359 |
apps/web/src/components/LegacySidebar.tsx+45-4| ... | ... | @@ -1,3 +1,10 @@ |
| 1 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | |
| 2 | import { GitPullRequestIcon } from "lucide-react"; | |
| 3 | import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; | |
| 4 | import { | |
| 5 | resolveThreadCurrentPullRequestLink, | |
| 6 | visibleThreadPullRequests, | |
| 7 | } from "@t3tools/shared/threadPullRequests"; | |
| 1 | 8 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | 9 | import { |
| 3 | 10 | ArchiveIcon, |
| ... | ... | @@ -465,10 +472,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 465 | 472 | }); |
| 466 | 473 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 467 | 474 | thread.environmentId, |
| 468 | thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 475 | thread.linkedPullRequest, | |
| 469 | 476 | leaseLiveStatus, |
| 477 | thread.pullRequests, | |
| 478 | thread.branchPullRequest, | |
| 470 | 479 | ); |
| 471 | 480 | const pr = linkedPullRequestStatus?.pr ?? null; |
| 481 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); | |
| 482 | const currentLinkedPr = supportsMultiplePullRequests | |
| 483 | ? resolveThreadCurrentPullRequestLink(thread.pullRequests) | |
| 484 | : null; | |
| 472 | 485 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); |
| 473 | 486 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 474 | 487 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| ... | ... | @@ -579,17 +592,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 579 | 592 | ); |
| 580 | 593 | const handlePrClick = useCallback( |
| 581 | 594 | (event: React.MouseEvent<HTMLAnchorElement>) => { |
| 582 | if (!prStatus) return; | |
| 595 | const url = prStatus?.url ?? currentLinkedPr?.url; | |
| 596 | if (!url) return; | |
| 583 | 597 | const openedInRightPanel = openPrLink( |
| 584 | 598 | event, |
| 585 | prStatus.url, | |
| 599 | url, | |
| 586 | 600 | openPullRequestsInRightPanel ? threadRef : undefined, |
| 587 | 601 | ); |
| 588 | 602 | if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { |
| 589 | 603 | navigateToThread(threadRef); |
| 590 | 604 | } |
| 591 | 605 | }, |
| 592 | [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef], | |
| 606 | [ | |
| 607 | isActive, | |
| 608 | navigateToThread, | |
| 609 | openPrLink, | |
| 610 | openPullRequestsInRightPanel, | |
| 611 | prStatus, | |
| 612 | currentLinkedPr, | |
| 613 | threadRef, | |
| 614 | ], | |
| 593 | 615 | ); |
| 594 | 616 | const handleRenameInputRef = useCallback( |
| 595 | 617 | (element: HTMLInputElement | null) => { |
| ... | ... | @@ -729,6 +751,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 729 | 751 | </TooltipPopup> |
| 730 | 752 | </Tooltip> |
| 731 | 753 | )} |
| 754 | {!pr && currentLinkedPr ? ( | |
| 755 | <a | |
| 756 | href={currentLinkedPr.url} | |
| 757 | target="_blank" | |
| 758 | rel="noopener noreferrer" | |
| 759 | onPointerDown={(event) => event.stopPropagation()} | |
| 760 | onClick={handlePrClick} | |
| 761 | className="text-muted-foreground" | |
| 762 | aria-label={`PR #${currentLinkedPr.number}, status pending`} | |
| 763 | > | |
| 764 | <GitPullRequestIcon className="size-3" /> | |
| 765 | </a> | |
| 766 | ) : null} | |
| 767 | {pr && | |
| 768 | (supportsMultiplePullRequests | |
| 769 | ? visibleThreadPullRequests(thread.pullRequests).length === 0 | |
| 770 | : thread.linkedPullRequest == null) ? ( | |
| 771 | <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} /> | |
| 772 | ) : null} | |
| 732 | 773 | {threadStatus && <ThreadStatusLabel status={threadStatus} />} |
| 733 | 774 | {renamingThreadKey === threadKey ? ( |
| 734 | 775 | <input |
apps/web/src/components/RightPanelTabs.test.tsx+48| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 1 | 2 | import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 4 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 5 | |
| 5 | 6 | import { |
| 6 | 7 | RightPanelTabs, |
| 8 | resolvePullRequestTabLink, | |
| 7 | 9 | shouldOpenDefaultBrowserProfileFromMenuClick, |
| 8 | 10 | surfaceShortcutActionForKey, |
| 9 | 11 | surfaceShortcutTargetsTypingContext, |
| ... | ... | @@ -117,6 +119,7 @@ function renderTabs( |
| 117 | 119 | onAddBrowserInProfile={() => undefined} |
| 118 | 120 | onAddTerminal={() => undefined} |
| 119 | 121 | onAddPullRequest={() => undefined} |
| 122 | onAddPullRequests={() => undefined} | |
| 120 | 123 | onAddDiff={() => undefined} |
| 121 | 124 | onAddFiles={() => undefined} |
| 122 | 125 | onAddAgents={() => undefined} |
| ... | ... | @@ -126,6 +129,7 @@ function renderTabs( |
| 126 | 129 | diffAvailable={false} |
| 127 | 130 | filesAvailable={false} |
| 128 | 131 | pullRequestAvailable={false} |
| 132 | pullRequestsAvailable={false} | |
| 129 | 133 | agentsAvailable={false} |
| 130 | 134 | > |
| 131 | 135 | <div>content</div> |
| ... | ... | @@ -278,3 +282,47 @@ describe("tabMuteMenuItem", () => { |
| 278 | 282 | }); |
| 279 | 283 | }); |
| 280 | 284 | }); |
| 285 | ||
| 286 | describe("pull request tab snapshots", () => { | |
| 287 | const environmentId = EnvironmentId.make("local"); | |
| 288 | const link: ThreadPullRequestLink = { | |
| 289 | host: "github.com", | |
| 290 | repository: "acme/api", | |
| 291 | number: 7, | |
| 292 | url: "https://github.com/acme/api/pull/7", | |
| 293 | source: "manual", | |
| 294 | linkedAt: "2026-01-01T00:00:00Z", | |
| 295 | stack: null, | |
| 296 | snapshot: null, | |
| 297 | }; | |
| 298 | it("keeps unknown linked state authoritative and scopes matches to environment and host", () => { | |
| 299 | const threads = [{ environmentId, pullRequests: [link] }]; | |
| 300 | expect(resolvePullRequestTabLink(threads, environmentId, "github.com", link)).toBe(link); | |
| 301 | expect( | |
| 302 | resolvePullRequestTabLink(threads, EnvironmentId.make("remote"), "github.com", link), | |
| 303 | ).toBeUndefined(); | |
| 304 | expect( | |
| 305 | resolvePullRequestTabLink(threads, environmentId, "github.enterprise.test", link), | |
| 306 | ).toBeUndefined(); | |
| 307 | }); | |
| 308 | it("uses the newest snapshot when several threads link the same PR", () => { | |
| 309 | const snapshot = { | |
| 310 | state: "merged" as const, | |
| 311 | title: "API", | |
| 312 | headBranch: "api", | |
| 313 | baseBranch: "main", | |
| 314 | isDraft: false, | |
| 315 | updatedAt: null, | |
| 316 | syncedAt: "2026-02-01T00:00:00Z", | |
| 317 | }; | |
| 318 | const newer = { ...link, snapshot }; | |
| 319 | expect( | |
| 320 | resolvePullRequestTabLink( | |
| 321 | [{ environmentId, pullRequests: [link, newer] }], | |
| 322 | environmentId, | |
| 323 | "github.com", | |
| 324 | link, | |
| 325 | ), | |
| 326 | ).toBe(newer); | |
| 327 | }); | |
| 328 | }); |
apps/web/src/components/RightPanelTabs.tsx+97-3| ... | ... | @@ -1,3 +1,10 @@ |
| 1 | import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; | |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 3 | import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; | |
| 4 | import { | |
| 5 | threadPullRequestKeysEqual, | |
| 6 | visibleThreadPullRequests, | |
| 7 | } from "@t3tools/shared/threadPullRequests"; | |
| 1 | 8 | import type { |
| 2 | 9 | ContextMenuItem, |
| 3 | 10 | EnvironmentId, |
| ... | ... | @@ -14,6 +21,7 @@ import { |
| 14 | 21 | FileDiff, |
| 15 | 22 | Files, |
| 16 | 23 | GitPullRequest, |
| 24 | GitPullRequestArrow, | |
| 17 | 25 | Globe2, |
| 18 | 26 | Plus, |
| 19 | 27 | TerminalSquare, |
| ... | ... | @@ -104,12 +112,14 @@ interface RightPanelTabsProps { |
| 104 | 112 | onAddDiff: () => void; |
| 105 | 113 | onAddFiles: () => void; |
| 106 | 114 | onAddPullRequest: () => void; |
| 115 | onAddPullRequests: () => void; | |
| 107 | 116 | onAddAgents: () => void; |
| 108 | 117 | browserAvailable: boolean; |
| 109 | 118 | terminalAvailable: boolean; |
| 110 | 119 | diffAvailable: boolean; |
| 111 | 120 | filesAvailable: boolean; |
| 112 | 121 | pullRequestAvailable: boolean; |
| 122 | pullRequestsAvailable: boolean; | |
| 113 | 123 | agentsAvailable: boolean; |
| 114 | 124 | pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>; |
| 115 | 125 | /** Running + waiting subagents; badges the Agents card in the empty state. */ |
| ... | ... | @@ -139,6 +149,7 @@ const SURFACE_DISABLED_REASONS = { |
| 139 | 149 | files: "Files are only available when a project is open.", |
| 140 | 150 | diff: "Diff is only available for server threads in Git repositories.", |
| 141 | 151 | pullRequest: "This thread's branch has no pull request yet.", |
| 152 | pullRequests: "Linked pull requests are only available for server threads.", | |
| 142 | 153 | agents: "Agents are only available from a thread.", |
| 143 | 154 | } as const; |
| 144 | 155 | |
| ... | ... | @@ -161,6 +172,7 @@ const SURFACE_UNAVAILABLE_HINTS = { |
| 161 | 172 | files: "Available when a project is open.", |
| 162 | 173 | diff: "Available for Git repositories.", |
| 163 | 174 | pullRequest: "No pull request on this branch yet.", |
| 175 | pullRequests: "Available for server threads.", | |
| 164 | 176 | agents: "Available from a thread.", |
| 165 | 177 | } as const; |
| 166 | 178 | |
| ... | ... | @@ -298,12 +310,14 @@ function RightPanelEmptyState(props: { |
| 298 | 310 | onAddDiff: () => void; |
| 299 | 311 | onAddFiles: () => void; |
| 300 | 312 | onAddPullRequest: () => void; |
| 313 | onAddPullRequests: () => void; | |
| 301 | 314 | onAddAgents: () => void; |
| 302 | 315 | browserAvailable: boolean; |
| 303 | 316 | terminalAvailable: boolean; |
| 304 | 317 | diffAvailable: boolean; |
| 305 | 318 | filesAvailable: boolean; |
| 306 | 319 | pullRequestAvailable: boolean; |
| 320 | pullRequestsAvailable: boolean; | |
| 307 | 321 | agentsAvailable: boolean; |
| 308 | 322 | liveAgentCount: number; |
| 309 | 323 | }) { |
| ... | ... | @@ -361,6 +375,16 @@ function RightPanelEmptyState(props: { |
| 361 | 375 | onClick: props.onAddPullRequest, |
| 362 | 376 | badgeCount: 0, |
| 363 | 377 | }, |
| 378 | { | |
| 379 | label: "Linked pull requests", | |
| 380 | description: "Every pull request this thread has linked, stacks included.", | |
| 381 | icon: GitPullRequestArrow, | |
| 382 | shortcut: "L", | |
| 383 | available: props.pullRequestsAvailable, | |
| 384 | disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, | |
| 385 | onClick: props.onAddPullRequests, | |
| 386 | badgeCount: 0, | |
| 387 | }, | |
| 364 | 388 | { |
| 365 | 389 | label: "Agents", |
| 366 | 390 | description: "Follow subagents and workflows.", |
| ... | ... | @@ -602,6 +626,8 @@ function surfaceTitle( |
| 602 | 626 | ); |
| 603 | 627 | case "pull-request": |
| 604 | 628 | return `#${surface.number}`; |
| 629 | case "pull-requests": | |
| 630 | return "Pull requests"; | |
| 605 | 631 | case "agents": |
| 606 | 632 | return "Agents"; |
| 607 | 633 | case "preview": { |
| ... | ... | @@ -683,11 +709,42 @@ function SurfaceIcon({ |
| 683 | 709 | seed={pullRequestStatusSeeds?.[surface.id]} |
| 684 | 710 | /> |
| 685 | 711 | ); |
| 712 | case "pull-requests": | |
| 713 | return <GitPullRequestArrow className="size-3 shrink-0" />; | |
| 686 | 714 | case "agents": |
| 687 | 715 | return <Bot className="size-3 shrink-0" />; |
| 688 | 716 | } |
| 689 | 717 | } |
| 690 | 718 | |
| 719 | export 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 | ||
| 691 | 748 | function PullRequestSurfaceIcon({ |
| 692 | 749 | surface, |
| 693 | 750 | environmentId, |
| ... | ... | @@ -699,13 +756,36 @@ function PullRequestSurfaceIcon({ |
| 699 | 756 | }) { |
| 700 | 757 | const resolvedEnvironmentId = |
| 701 | 758 | (surface.environmentId as EnvironmentId | undefined) ?? environmentId; |
| 702 | const detail = useEnvironmentQuery( | |
| 759 | const projects = useProjects(); | |
| 760 | const threads = useThreadShells(); | |
| 761 | const project = projects.find( | |
| 762 | (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, | |
| 763 | ); | |
| 764 | const identity = project?.repositoryIdentity; | |
| 765 | const host = | |
| 766 | surface.host ?? | |
| 767 | (identity?.provider | |
| 768 | ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) | |
| 769 | : null); | |
| 770 | const configs = useServerConfigs(); | |
| 771 | const capabilities = | |
| 703 | 772 | resolvedEnvironmentId === null |
| 773 | ? undefined | |
| 774 | : configs.get(resolvedEnvironmentId)?.environment.capabilities; | |
| 775 | const linkedSnapshot = | |
| 776 | capabilities?.threadPullRequests === true | |
| 777 | ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) | |
| 778 | : null; | |
| 779 | const detail = useEnvironmentQuery( | |
| 780 | resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null | |
| 704 | 781 | ? null |
| 705 | 782 | : pullRequestEnvironment.detail({ |
| 706 | 783 | environmentId: resolvedEnvironmentId, |
| 707 | 784 | input: { |
| 708 | 785 | projectId: surface.projectId as ProjectId, |
| 786 | ...(capabilities?.threadPullRequests === true && surface.host !== undefined | |
| 787 | ? { host: surface.host } | |
| 788 | : {}), | |
| 709 | 789 | repository: surface.repository, |
| 710 | 790 | number: surface.number, |
| 711 | 791 | }, |
| ... | ... | @@ -714,11 +794,15 @@ function PullRequestSurfaceIcon({ |
| 714 | 794 | // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the |
| 715 | 795 | // full detail would flip an open tab to the conflict glyph the moment its read lands. |
| 716 | 796 | const status = |
| 717 | detail === null ? (seed ?? null) : { state: detail.state, isDraft: detail.isDraft }; | |
| 797 | linkedSnapshot !== null | |
| 798 | ? linkedSnapshot | |
| 799 | : detail === null | |
| 800 | ? (seed ?? null) | |
| 801 | : { state: detail.state, isDraft: detail.isDraft }; | |
| 718 | 802 | if (status === null) { |
| 719 | 803 | return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />; |
| 720 | 804 | } |
| 721 | const presentation = resolvePullRequestState(status); | |
| 805 | const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft }); | |
| 722 | 806 | return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />; |
| 723 | 807 | } |
| 724 | 808 | |
| ... | ... | @@ -806,6 +890,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 806 | 890 | disabledReason: SURFACE_DISABLED_REASONS.pullRequest, |
| 807 | 891 | onClick: props.onAddPullRequest, |
| 808 | 892 | }, |
| 893 | { | |
| 894 | label: "Linked pull requests", | |
| 895 | icon: GitPullRequestArrow, | |
| 896 | shortcut: "L", | |
| 897 | available: props.pullRequestsAvailable, | |
| 898 | disabledReason: SURFACE_DISABLED_REASONS.pullRequests, | |
| 899 | onClick: props.onAddPullRequests, | |
| 900 | }, | |
| 809 | 901 | { |
| 810 | 902 | label: "Agents", |
| 811 | 903 | icon: Bot, |
| ... | ... | @@ -1250,12 +1342,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 1250 | 1342 | onAddDiff={props.onAddDiff} |
| 1251 | 1343 | onAddFiles={props.onAddFiles} |
| 1252 | 1344 | onAddPullRequest={props.onAddPullRequest} |
| 1345 | onAddPullRequests={props.onAddPullRequests} | |
| 1253 | 1346 | onAddAgents={props.onAddAgents} |
| 1254 | 1347 | browserAvailable={props.browserAvailable} |
| 1255 | 1348 | terminalAvailable={props.terminalAvailable} |
| 1256 | 1349 | diffAvailable={props.diffAvailable} |
| 1257 | 1350 | filesAvailable={props.filesAvailable} |
| 1258 | 1351 | pullRequestAvailable={props.pullRequestAvailable} |
| 1352 | pullRequestsAvailable={props.pullRequestsAvailable} | |
| 1259 | 1353 | agentsAvailable={props.agentsAvailable} |
| 1260 | 1354 | liveAgentCount={props.liveAgentCount} |
| 1261 | 1355 | /> |
apps/web/src/components/Sidebar.logic.test.ts+1| ... | ... | @@ -2133,6 +2133,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 2133 | 2133 | branch: null, |
| 2134 | 2134 | worktreePath: null, |
| 2135 | 2135 | checkpoints: [], |
| 2136 | pullRequests: [], | |
| 2136 | 2137 | activities: [], |
| 2137 | 2138 | ...overrides, |
| 2138 | 2139 | }; |
apps/web/src/components/Sidebar.tsx+114-11| ... | ... | @@ -1,3 +1,9 @@ |
| 1 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | |
| 2 | import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; | |
| 3 | import { | |
| 4 | resolveThreadCurrentPullRequestLink, | |
| 5 | visibleThreadPullRequests, | |
| 6 | } from "@t3tools/shared/threadPullRequests"; | |
| 1 | 7 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 8 | import * as Schema from "effect/Schema"; |
| 3 | 9 | import { |
| ... | ... | @@ -70,6 +76,7 @@ import { |
| 70 | 76 | } from "react"; |
| 71 | 77 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 72 | 78 | |
| 79 | import { useRightPanelStore } from "../rightPanelStore"; | |
| 73 | 80 | import { |
| 74 | 81 | isAtomCommandInterrupted, |
| 75 | 82 | settlePromise, |
| ... | ... | @@ -185,8 +192,12 @@ import { |
| 185 | 192 | import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; |
| 186 | 193 | import { createSidebarListMotion } from "./Sidebar.motion"; |
| 187 | 194 | import { |
| 195 | PR_STATE_COLOR_CLASS, | |
| 196 | ThreadPullRequestBadgeIcon, | |
| 197 | ThreadPullRequestsMiniList, | |
| 188 | 198 | ThreadWorktreeIndicator, |
| 189 | 199 | prStatusIndicator, |
| 200 | resolveThreadPullRequestBadge, | |
| 190 | 201 | settledPrHoverColorClass, |
| 191 | 202 | terminalStatusFromRunningIds, |
| 192 | 203 | type TerminalStatusIndicator, |
| ... | ... | @@ -209,7 +220,7 @@ import { |
| 209 | 220 | } from "../providerInstances"; |
| 210 | 221 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 211 | 222 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 212 | import { Button } from "./ui/button"; | |
| 223 | import { Button, InlineButton } from "./ui/button"; | |
| 213 | 224 | import { Input } from "./ui/input"; |
| 214 | 225 | import { |
| 215 | 226 | Combobox, |
| ... | ... | @@ -330,6 +341,7 @@ function SidebarThreadTooltip({ |
| 330 | 341 | terminalProcessCount: number; |
| 331 | 342 | }) { |
| 332 | 343 | const driverKind = providerEntry?.driverKind ?? null; |
| 344 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); | |
| 333 | 345 | return ( |
| 334 | 346 | <TooltipPopup |
| 335 | 347 | side="right" |
| ... | ... | @@ -411,6 +423,11 @@ function SidebarThreadTooltip({ |
| 411 | 423 | </div> |
| 412 | 424 | ) : null} |
| 413 | 425 | </div> |
| 426 | {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? ( | |
| 427 | <div className="border-t border-border/60 pt-2 pl-0.5 text-xs text-muted-foreground"> | |
| 428 | <ThreadPullRequestsMiniList pullRequests={thread.pullRequests} /> | |
| 429 | </div> | |
| 430 | ) : null} | |
| 414 | 431 | </div> |
| 415 | 432 | </TooltipPopup> |
| 416 | 433 | ); |
| ... | ... | @@ -1047,8 +1064,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1047 | 1064 | const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; |
| 1048 | 1065 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| 1049 | 1066 | thread.environmentId, |
| 1050 | thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 1067 | thread.linkedPullRequest, | |
| 1051 | 1068 | leaseLiveStatus, |
| 1069 | thread.pullRequests, | |
| 1070 | thread.branchPullRequest, | |
| 1052 | 1071 | ); |
| 1053 | 1072 | const gitStatus = useEnvironmentQuery( |
| 1054 | 1073 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| ... | ... | @@ -1063,6 +1082,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1063 | 1082 | gitStatus.data, |
| 1064 | 1083 | ); |
| 1065 | 1084 | const pr = linkedPullRequestStatus?.pr ?? null; |
| 1085 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); | |
| 1086 | const currentLinkedPr = supportsMultiplePullRequests | |
| 1087 | ? resolveThreadCurrentPullRequestLink(thread.pullRequests) | |
| 1088 | : null; | |
| 1066 | 1089 | |
| 1067 | 1090 | // Same semantics as the legacy sidebar (never-visited counts as read): |
| 1068 | 1091 | // switching sidebars must not light up every historical thread as unread. |
| ... | ... | @@ -1339,17 +1362,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1339 | 1362 | }, [showSnoozeButton]); |
| 1340 | 1363 | const handlePrClick = useCallback( |
| 1341 | 1364 | (event: ReactMouseEvent<HTMLAnchorElement>) => { |
| 1342 | if (!pr?.url) return; | |
| 1365 | const url = pr?.url ?? currentLinkedPr?.url; | |
| 1366 | if (!url) return; | |
| 1343 | 1367 | const openedInRightPanel = openPrLink( |
| 1344 | 1368 | event, |
| 1345 | pr.url, | |
| 1369 | url, | |
| 1346 | 1370 | openPullRequestsInRightPanel ? threadRef : undefined, |
| 1347 | 1371 | ); |
| 1348 | 1372 | if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) { |
| 1349 | 1373 | onThreadActivate(threadRef); |
| 1350 | 1374 | } |
| 1351 | 1375 | }, |
| 1352 | [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef], | |
| 1376 | [ | |
| 1377 | onThreadActivate, | |
| 1378 | openPrLink, | |
| 1379 | openPullRequestsInRightPanel, | |
| 1380 | pr, | |
| 1381 | currentLinkedPr, | |
| 1382 | props.isActive, | |
| 1383 | threadRef, | |
| 1384 | ], | |
| 1353 | 1385 | ); |
| 1354 | 1386 | |
| 1355 | 1387 | // All sidebar rows share one surface model. Live threads used to look |
| ... | ... | @@ -1456,10 +1488,43 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1456 | 1488 | </span> |
| 1457 | 1489 | ); |
| 1458 | 1490 | |
| 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]); | |
| 1461 | 1513 | const prBadge = |
| 1462 | prStatus && pr ? ( | |
| 1514 | prBadgeShape?.kind === "stack" ? ( | |
| 1515 | // A stack is one thing with N layers; naming one of them would misrepresent it, so the | |
| 1516 | // badge counts layers and opens the thread's pull-requests surface. | |
| 1517 | <InlineButton | |
| 1518 | underline | |
| 1519 | onPointerDown={(event) => event.stopPropagation()} | |
| 1520 | onClick={handlePrStackClick} | |
| 1521 | className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])} | |
| 1522 | aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`} | |
| 1523 | > | |
| 1524 | <ThreadPullRequestBadgeIcon icon="stack" /> | |
| 1525 | {prBadgeShape.layers} | |
| 1526 | </InlineButton> | |
| 1527 | ) : prStatus && pr ? ( | |
| 1463 | 1528 | <a |
| 1464 | 1529 | href={pr.url} |
| 1465 | 1530 | target="_blank" |
| ... | ... | @@ -1469,14 +1534,38 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1469 | 1534 | className={cn( |
| 1470 | 1535 | // Sidebar chrome follows the interface font; tabular digits keep the |
| 1471 | 1536 | // number from reflowing as PR states stream in. |
| 1472 | "shrink-0 text-xs tabular-nums hover:underline", | |
| 1537 | "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline", | |
| 1473 | 1538 | variant === "slim" && variantAction === "unsettle" |
| 1474 | 1539 | ? cn("text-secondary-label transition-colors", settledPrHoverClass) |
| 1475 | 1540 | : prStatus.colorClass, |
| 1476 | 1541 | )} |
| 1477 | aria-label={prStatus.tooltip} | |
| 1542 | aria-label={ | |
| 1543 | prBadgeShape && prBadgeShape.kind === "pull-request" && prBadgeShape.others > 0 | |
| 1544 | ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked` | |
| 1545 | : prStatus.tooltip | |
| 1546 | } | |
| 1478 | 1547 | > |
| 1479 | #{pr.number} | |
| 1548 | <ThreadPullRequestBadgeIcon icon="pull-request" /> | |
| 1549 | {pr.number} | |
| 1550 | {prBadgeShape && prBadgeShape.others > 0 ? ( | |
| 1551 | <span className="opacity-70">+{prBadgeShape.others}</span> | |
| 1552 | ) : null} | |
| 1553 | </a> | |
| 1554 | ) : currentLinkedPr ? ( | |
| 1555 | <a | |
| 1556 | href={currentLinkedPr.url} | |
| 1557 | target="_blank" | |
| 1558 | rel="noopener noreferrer" | |
| 1559 | onPointerDown={(event) => event.stopPropagation()} | |
| 1560 | onClick={handlePrClick} | |
| 1561 | className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline" | |
| 1562 | aria-label={`PR #${currentLinkedPr.number}, status pending`} | |
| 1563 | > | |
| 1564 | <ThreadPullRequestBadgeIcon icon="pull-request" /> | |
| 1565 | {currentLinkedPr.number} | |
| 1566 | {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? ( | |
| 1567 | <span>+{prBadgeShape.others}</span> | |
| 1568 | ) : null} | |
| 1480 | 1569 | </a> |
| 1481 | 1570 | ) : null; |
| 1482 | 1571 | const terminalStatusIcon = terminalStatus ? ( |
| ... | ... | @@ -1589,6 +1678,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1589 | 1678 | remain visible AND clickable while the row is hovered. Only |
| 1590 | 1679 | the time/jump label yields to the settle affordance. */} |
| 1591 | 1680 | {prBadge} |
| 1681 | {prBadge && | |
| 1682 | pr && | |
| 1683 | (supportsMultiplePullRequests | |
| 1684 | ? visibleThreadPullRequests(thread.pullRequests).length === 0 | |
| 1685 | : thread.linkedPullRequest == null) ? ( | |
| 1686 | <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} /> | |
| 1687 | ) : null} | |
| 1592 | 1688 | {sortable?.isDragging ? ( |
| 1593 | 1689 | dragDestination |
| 1594 | 1690 | ) : ( |
| ... | ... | @@ -1889,6 +1985,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1889 | 1985 | )} |
| 1890 | 1986 | {terminalStatusIcon} |
| 1891 | 1987 | {prBadge} |
| 1988 | {prBadge && | |
| 1989 | pr && | |
| 1990 | (supportsMultiplePullRequests | |
| 1991 | ? visibleThreadPullRequests(thread.pullRequests).length === 0 | |
| 1992 | : thread.linkedPullRequest == null) ? ( | |
| 1993 | <LinkBranchPullRequestButton threadRef={threadRef} url={pr.url} /> | |
| 1994 | ) : null} | |
| 1892 | 1995 | {diff ? ( |
| 1893 | 1996 | <span className="shrink-0 font-mono"> |
| 1894 | 1997 | <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "} |
apps/web/src/components/ThreadStatusIndicators.test.tsx+45-2| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 1 | import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 2 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators"; | |
| 5 | import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators"; | |
| 6 | 6 | |
| 7 | 7 | describe("ThreadWorktreeIndicator", () => { |
| 8 | 8 | it("renders the worktree folder and branch in an accessible label", () => { |
| ... | ... | @@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => { |
| 37 | 37 | expect(markup).toBe(""); |
| 38 | 38 | }); |
| 39 | 39 | }); |
| 40 | ||
| 41 | describe("linked pull request snapshots", () => { | |
| 42 | const link: ThreadPullRequestLink = { | |
| 43 | host: "gitlab.example.com", | |
| 44 | repository: "acme/web", | |
| 45 | number: 42, | |
| 46 | url: "https://gitlab.example.com/acme/web/-/merge_requests/42", | |
| 47 | source: "manual", | |
| 48 | linkedAt: "2026-01-01T00:00:00Z", | |
| 49 | stack: null, | |
| 50 | snapshot: null, | |
| 51 | }; | |
| 52 | it("keeps unsynced links unknown", () => { | |
| 53 | expect(linkedPullRequestSnapshotStatus(link)).toBeNull(); | |
| 54 | }); | |
| 55 | it("uses the snapshot state and branches with the linked identity", () => { | |
| 56 | const result = linkedPullRequestSnapshotStatus({ | |
| 57 | ...link, | |
| 58 | snapshot: { | |
| 59 | state: "merged", | |
| 60 | title: "Change", | |
| 61 | headBranch: "feature", | |
| 62 | baseBranch: "main", | |
| 63 | isDraft: false, | |
| 64 | updatedAt: "2026-01-02T00:00:00Z", | |
| 65 | syncedAt: "2026-01-03T00:00:00Z", | |
| 66 | }, | |
| 67 | }); | |
| 68 | expect(result).toEqual({ | |
| 69 | pr: { | |
| 70 | number: 42, | |
| 71 | url: link.url, | |
| 72 | title: "Change", | |
| 73 | state: "merged", | |
| 74 | isDraft: false, | |
| 75 | headRef: "feature", | |
| 76 | baseRef: "main", | |
| 77 | updatedAt: "2026-01-02T00:00:00Z", | |
| 78 | }, | |
| 79 | sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, | |
| 80 | }); | |
| 81 | }); | |
| 82 | }); |
apps/web/src/components/ThreadStatusIndicators.tsx+161-28| ... | ... | @@ -1,25 +1,35 @@ |
| 1 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | |
| 1 | 2 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | 3 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; |
| 3 | 4 | import { |
| 4 | type EnvironmentId, | |
| 5 | 5 | resolveEnvironmentMachineKind, |
| 6 | type EnvironmentId, | |
| 6 | 7 | type ThreadLinkedPullRequest, |
| 8 | type ThreadPullRequestLink, | |
| 7 | 9 | type VcsStatusResult, |
| 8 | 10 | } from "@t3tools/contracts"; |
| 9 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; | |
| 11 | import { | |
| 12 | resolveThreadCurrentPullRequestLink, | |
| 13 | resolveThreadPullRequestChains, | |
| 14 | visibleThreadPullRequests, | |
| 15 | } from "@t3tools/shared/threadPullRequests"; | |
| 16 | import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react"; | |
| 10 | 17 | import { useMemo } from "react"; |
| 18 | import { cn } from "../lib/utils"; | |
| 11 | 19 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 12 | 20 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 21 | import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; | |
| 13 | 22 | import { useEnvironmentQuery } from "../state/query"; |
| 14 | 23 | import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; |
| 15 | 24 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 16 | 25 | import { useUiStateStore } from "../uiStateStore"; |
| 17 | 26 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; |
| 18 | 27 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; |
| 19 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; | |
| 20 | 28 | import type { SidebarThreadSummary } from "../types"; |
| 21 | 29 | import { formatWorktreePathForDisplay } from "../worktreeCleanup"; |
| 22 | 30 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 31 | import { pullRequestListLines } from "./pullRequest/pullRequestListLines"; | |
| 32 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; | |
| 23 | 33 | |
| 24 | 34 | export interface PrStatusIndicator { |
| 25 | 35 | label: string; |
| ... | ... | @@ -43,42 +53,151 @@ export interface LinkedThreadPullRequestStatus { |
| 43 | 53 | readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>; |
| 44 | 54 | } |
| 45 | 55 | |
| 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. */ | |
| 47 | 57 | export function useLinkedThreadPullRequest( |
| 48 | 58 | environmentId: EnvironmentId | null, |
| 49 | 59 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 50 | 60 | enabled = true, |
| 61 | pullRequests?: ReadonlyArray<ThreadPullRequestLink>, | |
| 62 | branchPullRequest?: ThreadLinkedPullRequest | null, | |
| 51 | 63 | ): LinkedThreadPullRequestStatus | null { |
| 64 | const supportsLinks = useSupportsMultiplePullRequests(environmentId); | |
| 65 | const current = useMemo( | |
| 66 | () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null), | |
| 67 | [pullRequests, supportsLinks], | |
| 68 | ); | |
| 69 | const fallback = | |
| 70 | current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null; | |
| 71 | const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host; | |
| 72 | const reference = | |
| 73 | fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) }; | |
| 52 | 74 | const queried = useEnvironmentQuery( |
| 53 | !enabled || environmentId === null || linkedPullRequest == null | |
| 75 | !enabled || environmentId === null || reference === null | |
| 54 | 76 | ? 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 }), | |
| 63 | 78 | ).data; |
| 64 | const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); | |
| 79 | const detail = useSharedPullRequestSummary(environmentId, reference, queried); | |
| 80 | ||
| 81 | return useMemo(() => { | |
| 82 | if (current !== null) return linkedPullRequestSnapshotStatus(current); | |
| 83 | return detail === null | |
| 84 | ? null | |
| 85 | : { | |
| 86 | pr: pullRequestDetailToVcsStatus(detail), | |
| 87 | sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" }, | |
| 88 | }; | |
| 89 | }, [current, detail]); | |
| 90 | } | |
| 65 | 91 | |
| 66 | return useMemo( | |
| 92 | export 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 | ||
| 119 | export { | |
| 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. */ | |
| 125 | export 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 | */ | |
| 140 | export function ThreadPullRequestsMiniList({ | |
| 141 | pullRequests, | |
| 142 | }: { | |
| 143 | pullRequests: ReadonlyArray<ThreadPullRequestLink>; | |
| 144 | }) { | |
| 145 | const lines = useMemo( | |
| 67 | 146 | () => |
| 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> | |
| 79 | 191 | ); |
| 80 | 192 | } |
| 81 | 193 | |
| 194 | /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ | |
| 195 | export 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 | ||
| 82 | 201 | export function settledPrHoverColorClass( |
| 83 | 202 | state: NonNullable<ThreadPr>["state"], |
| 84 | 203 | isDraft = false, |
| ... | ... | @@ -282,7 +401,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 282 | 401 | ); |
| 283 | 402 | const pullRequest = useLinkedThreadPullRequest( |
| 284 | 403 | thread.environmentId, |
| 285 | thread.linkedPullRequest ?? thread.branchPullRequest, | |
| 404 | thread.linkedPullRequest, | |
| 405 | true, | |
| 406 | thread.pullRequests, | |
| 407 | thread.branchPullRequest, | |
| 286 | 408 | ); |
| 287 | 409 | const pr = pullRequest?.pr ?? null; |
| 288 | 410 | const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); |
| ... | ... | @@ -293,7 +415,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 293 | 415 | }, |
| 294 | 416 | }); |
| 295 | 417 | |
| 296 | if (!prStatus && !threadStatus) { | |
| 418 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); | |
| 419 | const pendingLink = | |
| 420 | pr === null && supportsMultiplePullRequests | |
| 421 | ? resolveThreadCurrentPullRequestLink(thread.pullRequests) | |
| 422 | : null; | |
| 423 | if (!prStatus && !threadStatus && !pendingLink) { | |
| 297 | 424 | return null; |
| 298 | 425 | } |
| 299 | 426 | |
| ... | ... | @@ -316,6 +443,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 316 | 443 | </TooltipPopup> |
| 317 | 444 | </Tooltip> |
| 318 | 445 | ) : null} |
| 446 | {pendingLink ? ( | |
| 447 | <GitPullRequestArrowIcon | |
| 448 | className="size-3 text-muted-foreground" | |
| 449 | aria-label={`PR #${pendingLink.number}, status pending`} | |
| 450 | /> | |
| 451 | ) : null} | |
| 319 | 452 | {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null} |
| 320 | 453 | </span> |
| 321 | 454 | ); |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+1| ... | ... | @@ -390,6 +390,7 @@ describe("streaming row projection", () => { |
| 390 | 390 | runtimeMode: "full-access", |
| 391 | 391 | interactionMode: "default", |
| 392 | 392 | branch: null, |
| 393 | pullRequests: [], | |
| 393 | 394 | worktreePath: null, |
| 394 | 395 | latestTurn: { |
| 395 | 396 | ...initial.input.latestTurn, |
apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import { Link2 } from "lucide-react"; | |
| 3 | import { useState } from "react"; | |
| 4 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; | |
| 5 | import { Button } from "../ui/button"; | |
| 6 | import { toastManager } from "../ui/toast"; | |
| 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 8 | ||
| 9 | /** Adopts a branch discovery as a durable link, even after the thread changes branches. */ | |
| 10 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; | |
| 4 | ||
| 5 | const project = { | |
| 6 | host: "github.com", | |
| 7 | repository: "acme/web", | |
| 8 | webUrl: (number: number) => changeRequestWebUrl("github", "github.com", "acme/web", number), | |
| 9 | }; | |
| 10 | ||
| 11 | describe("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 | ||
| 129 | describe("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 @@ |
| 1 | import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 2 | export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 3 | import { | |
| 4 | pullRequestHostOf, | |
| 5 | type ScopedThreadRef, | |
| 6 | type SourceControlProviderKind, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { useAtomValue } from "@effect/atom-react"; | |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 10 | ||
| 11 | import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; | |
| 12 | import { parsePullRequestReference } from "~/pullRequestReference"; | |
| 13 | import { useProjects, useThreadShell } from "~/state/entities"; | |
| 14 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; | |
| 15 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 16 | import { Atom } from "effect/unstable/reactivity"; | |
| 17 | import { Button } from "../ui/button"; | |
| 18 | import { | |
| 19 | Dialog, | |
| 20 | DialogDescription, | |
| 21 | DialogFooter, | |
| 22 | DialogHeader, | |
| 23 | DialogPanel, | |
| 24 | DialogPopup, | |
| 25 | DialogTitle, | |
| 26 | } from "../ui/dialog"; | |
| 27 | import { 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 | */ | |
| 34 | const linkPullRequestDialogThreadAtom = Atom.make<ScopedThreadRef | null>(null).pipe( | |
| 35 | Atom.keepAlive, | |
| 36 | Atom.withLabel("pull-requests:link-dialog-thread"), | |
| 37 | ); | |
| 38 | ||
| 39 | export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void { | |
| 40 | appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef); | |
| 41 | } | |
| 42 | ||
| 43 | interface 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. */ | |
| 52 | export 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 | ||
| 69 | interface 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 | */ | |
| 81 | export 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 | ||
| 119 | function LinkPullRequestDialog({ | |
| 120 | open, | |
| 121 | threadRef, | |
| 122 | projectId, | |
| 123 | onOpenChange, | |
| 124 | }: LinkPullRequestDialogProps) { | |
| 125 | const inputRef = useRef<HTMLInputElement>(null); | |
| 126 | const [reference, setReference] = useState(""); | |
| 127 | const [dirty, setDirty] = useState(false); | |
| 128 | const [submitError, setSubmitError] = useState<string | null>(null); | |
| 129 | const projects = useProjects(); | |
| 130 | const environmentProjects = useMemo( | |
| 131 | () => projects.filter((project) => project.environmentId === threadRef.environmentId), | |
| 132 | [projects, threadRef.environmentId], | |
| 133 | ); | |
| 134 | const ownProject = useMemo(() => { | |
| 135 | const project = environmentProjects.find((candidate) => candidate.id === projectId); | |
| 136 | const identity = project?.repositoryIdentity; | |
| 137 | if (!project || !identity) return null; | |
| 138 | const repository = | |
| 139 | identity.displayName ?? | |
| 140 | (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); | |
| 141 | if (repository === null) return null; | |
| 142 | const kind = identity.provider as SourceControlProviderKind; | |
| 143 | const host = pullRequestHostOf(identity, kind); | |
| 144 | return { | |
| 145 | host, | |
| 146 | repository, | |
| 147 | webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number), | |
| 148 | }; | |
| 149 | }, [environmentProjects, projectId]); | |
| 150 | const linking = usePullRequestLinking(threadRef.environmentId); | |
| 151 | const [pending, setPending] = useState(false); | |
| 152 | ||
| 153 | useEffect(() => { | |
| 154 | if (!open) return; | |
| 155 | setReference(""); | |
| 156 | setDirty(false); | |
| 157 | setSubmitError(null); | |
| 158 | const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); | |
| 159 | return () => window.cancelAnimationFrame(frame); | |
| 160 | }, [open]); | |
| 161 | ||
| 162 | const resolved = useMemo( | |
| 163 | () => | |
| 164 | resolveLinkPullRequestInput({ | |
| 165 | reference, | |
| 166 | project: ownProject, | |
| 167 | hasProject: (reference) => linking.canLink(reference.url), | |
| 168 | }), | |
| 169 | [linking, ownProject, reference], | |
| 170 | ); | |
| 171 | ||
| 172 | const submit = useCallback(async () => { | |
| 173 | setDirty(true); | |
| 174 | if (resolved === null || "error" in resolved) return; | |
| 175 | setSubmitError(null); | |
| 176 | setPending(true); | |
| 177 | try { | |
| 178 | await linking.changeLink(threadRef, resolved.link.url, true); | |
| 179 | } catch (error) { | |
| 180 | setSubmitError(error instanceof Error ? error.message : "Could not link the pull request."); | |
| 181 | return; | |
| 182 | } finally { | |
| 183 | setPending(false); | |
| 184 | } | |
| 185 | onOpenChange(false); | |
| 186 | }, [linking, onOpenChange, resolved, threadRef]); | |
| 187 | ||
| 188 | const validation = !dirty | |
| 189 | ? null | |
| 190 | : reference.trim().length === 0 | |
| 191 | ? "Paste a pull request URL or enter 123 / #123." | |
| 192 | : resolved === null | |
| 193 | ? "Use a pull request URL, 123, or #123." | |
| 194 | : "error" in resolved | |
| 195 | ? resolved.error | |
| 196 | : null; | |
| 197 | ||
| 198 | return ( | |
| 199 | <Dialog open={open} onOpenChange={(next) => (pending ? undefined : onOpenChange(next))}> | |
| 200 | <DialogPopup className="max-w-xl"> | |
| 201 | <DialogHeader> | |
| 202 | <DialogTitle>Link pull request</DialogTitle> | |
| 203 | <DialogDescription> | |
| 204 | Attach a pull request to this thread. A full URL can point at any repository on a host | |
| 205 | this environment has a project for. | |
| 206 | </DialogDescription> | |
| 207 | </DialogHeader> | |
| 208 | <DialogPanel className="space-y-3"> | |
| 209 | <Input | |
| 210 | ref={inputRef} | |
| 211 | placeholder="Pull request URL or #42" | |
| 212 | value={reference} | |
| 213 | onChange={(event) => { | |
| 214 | setDirty(true); | |
| 215 | setReference(event.target.value); | |
| 216 | }} | |
| 217 | onKeyDown={(event) => { | |
| 218 | if (event.key !== "Enter") return; | |
| 219 | event.preventDefault(); | |
| 220 | void submit(); | |
| 221 | }} | |
| 222 | /> | |
| 223 | {resolved !== null && "link" in resolved ? ( | |
| 224 | <p className="truncate text-muted-foreground text-xs"> | |
| 225 | {resolved.link.host}/{resolved.link.repository} #{resolved.link.number} | |
| 226 | </p> | |
| 227 | ) : null} | |
| 228 | {(validation ?? submitError) ? ( | |
| 229 | <p className="text-destructive text-xs">{validation ?? submitError}</p> | |
| 230 | ) : null} | |
| 231 | </DialogPanel> | |
| 232 | <DialogFooter> | |
| 233 | <Button | |
| 234 | type="button" | |
| 235 | variant="outline" | |
| 236 | size="sm" | |
| 237 | onClick={() => onOpenChange(false)} | |
| 238 | disabled={pending} | |
| 239 | > | |
| 240 | Cancel | |
| 241 | </Button> | |
| 242 | <Button | |
| 243 | type="button" | |
| 244 | size="sm" | |
| 245 | onClick={() => void submit()} | |
| 246 | disabled={pending || resolved === null || "error" in resolved} | |
| 247 | > | |
| 248 | {pending ? "Linking..." : "Link"} | |
| 249 | </Button> | |
| 250 | </DialogFooter> | |
| 251 | </DialogPopup> | |
| 252 | </Dialog> | |
| 253 | ); | |
| 254 | } |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+116-8| ... | ... | @@ -66,16 +66,15 @@ import { cn } from "~/lib/utils"; |
| 66 | 66 | import { readLocalApi } from "~/localApi"; |
| 67 | 67 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 68 | 68 | import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; |
| 69 | import { useProjects } from "~/state/entities"; | |
| 69 | import { useProjects, useServerConfigs } from "~/state/entities"; | |
| 70 | 70 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 71 | 71 | import { useEnvironmentQuery } from "~/state/query"; |
| 72 | 72 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 73 | import { | |
| 74 | pullRequestEnvironment, | |
| 75 | usePullRequestTurnRefresh, | |
| 76 | useSharedPullRequestSummary, | |
| 77 | } from "~/state/pullRequests"; | |
| 73 | import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; | |
| 74 | import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; | |
| 78 | 75 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 76 | import { PullRequestStackMap } from "./PullRequestStackMap"; | |
| 77 | import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; | |
| 79 | 78 | import { vcsEnvironment } from "~/state/vcs"; |
| 80 | 79 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 81 | 80 | import { useUiStateStore } from "~/uiStateStore"; |
| ... | ... | @@ -452,13 +451,14 @@ function PullRequestBaseFreshnessWarning({ |
| 452 | 451 | export function PullRequestDetailPanel({ |
| 453 | 452 | environmentId, |
| 454 | 453 | threadRef = null, |
| 455 | reference, | |
| 454 | reference: requestedReference, | |
| 456 | 455 | listEntry = null, |
| 457 | 456 | refreshToken: forcedRefreshToken = 0, |
| 458 | 457 | onActed, |
| 459 | 458 | onClose, |
| 460 | 459 | context = "page", |
| 461 | 460 | composerDraftTarget, |
| 461 | onBack, | |
| 462 | 462 | }: { |
| 463 | 463 | environmentId: EnvironmentId; |
| 464 | 464 | /** |
| ... | ... | @@ -495,14 +495,35 @@ export function PullRequestDetailPanel({ |
| 495 | 495 | * land here instead of opening a new thread — the branch is already under the reader's feet. |
| 496 | 496 | */ |
| 497 | 497 | composerDraftTarget?: ScopedThreadRef | DraftId; |
| 498 | /** | |
| 499 | * Beside a thread, the way back to that thread's list of pull requests. The tab strip can | |
| 500 | * close this surface, but closing is not going back: the reader came from the list and | |
| 501 | * expects to land on it, with this one still open behind. | |
| 502 | */ | |
| 503 | onBack?: (() => void) | undefined; | |
| 498 | 504 | }) { |
| 499 | const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; | |
| 505 | const environmentConfigs = useServerConfigs(); | |
| 506 | const supportsThreadPullRequests = | |
| 507 | environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; | |
| 508 | const reference = useMemo( | |
| 509 | () => | |
| 510 | supportsThreadPullRequests | |
| 511 | ? requestedReference | |
| 512 | : { | |
| 513 | projectId: requestedReference.projectId, | |
| 514 | repository: requestedReference.repository, | |
| 515 | number: requestedReference.number, | |
| 516 | }, | |
| 517 | [requestedReference, supportsThreadPullRequests], | |
| 518 | ); | |
| 519 | const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; | |
| 500 | 520 | const matchingListEntry = |
| 501 | 521 | listEntry?.projectId === reference.projectId && |
| 502 | 522 | listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && |
| 503 | 523 | listEntry.number === reference.number |
| 504 | 524 | ? listEntry |
| 505 | 525 | : null; |
| 526 | const [threadPickerOpen, setThreadPickerOpen] = useState(false); | |
| 506 | 527 | const [tab, setTab] = useState<DetailTab>("summary"); |
| 507 | 528 | const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); |
| 508 | 529 | const [codeCommitScope, setCodeCommitScope] = useState<{ |
| ... | ... | @@ -631,6 +652,11 @@ export function PullRequestDetailPanel({ |
| 631 | 652 | : { |
| 632 | 653 | ...resolvedCoreDetail, |
| 633 | 654 | ...sharedSummary, |
| 655 | author: sharedSummary.author ?? resolvedCoreDetail.author, | |
| 656 | additions: sharedSummary.additions ?? resolvedCoreDetail.additions, | |
| 657 | deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, | |
| 658 | changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles, | |
| 659 | mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability, | |
| 634 | 660 | closedAt: |
| 635 | 661 | sharedSummary.closedAt === undefined |
| 636 | 662 | ? resolvedCoreDetail.closedAt |
| ... | ... | @@ -702,6 +728,13 @@ export function PullRequestDetailPanel({ |
| 702 | 728 | const isStackedPullRequest = |
| 703 | 729 | detail !== null && |
| 704 | 730 | isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); |
| 731 | // The host's own stack, where it keeps one. Only asked for once the detail has landed so a | |
| 732 | // pull request nobody can read costs one request rather than two. | |
| 733 | const nativeStack = useEnvironmentQuery( | |
| 734 | detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests | |
| 735 | ? null | |
| 736 | : pullRequestStackAtom({ environmentId, input: reference }), | |
| 737 | ).data; | |
| 705 | 738 | const activityPending = activityQuery.isPending && activity === null; |
| 706 | 739 | const activityError = activity === null ? activityQuery.error : null; |
| 707 | 740 | const refreshDetail = useCallback(() => { |
| ... | ... | @@ -1379,6 +1412,17 @@ export function PullRequestDetailPanel({ |
| 1379 | 1412 | |
| 1380 | 1413 | return ( |
| 1381 | 1414 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> |
| 1415 | {threadPickerOpen && detail ? ( | |
| 1416 | <PullRequestThreadLinks | |
| 1417 | key={`${environmentId}:${detail.url}`} | |
| 1418 | display="picker" | |
| 1419 | environmentId={environmentId} | |
| 1420 | reference={reference} | |
| 1421 | url={detail.url} | |
| 1422 | threadRef={null} | |
| 1423 | onPickerOpenChange={setThreadPickerOpen} | |
| 1424 | /> | |
| 1425 | ) : null} | |
| 1382 | 1426 | <div className="@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60"> |
| 1383 | 1427 | <div className="ml-4 grid h-7 min-w-0 items-center overflow-hidden"> |
| 1384 | 1428 | <div |
| ... | ... | @@ -1393,6 +1437,24 @@ export function PullRequestDetailPanel({ |
| 1393 | 1437 | > |
| 1394 | 1438 | {detail && statePresentation ? ( |
| 1395 | 1439 | <> |
| 1440 | {onBack ? ( | |
| 1441 | <Tooltip> | |
| 1442 | <TooltipTrigger | |
| 1443 | render={ | |
| 1444 | <Button | |
| 1445 | size="icon-micro" | |
| 1446 | variant="ghost-muted" | |
| 1447 | onClick={onBack} | |
| 1448 | className="-ml-1.5" | |
| 1449 | aria-label="Back to this thread's pull requests" | |
| 1450 | > | |
| 1451 | <ArrowLeftIcon aria-hidden className="size-3.5" /> | |
| 1452 | </Button> | |
| 1453 | } | |
| 1454 | /> | |
| 1455 | <TooltipPopup side="top">Back to pull requests</TooltipPopup> | |
| 1456 | </Tooltip> | |
| 1457 | ) : null} | |
| 1396 | 1458 | <Tooltip> |
| 1397 | 1459 | <TooltipTrigger |
| 1398 | 1460 | render={ |
| ... | ... | @@ -1450,6 +1512,25 @@ export function PullRequestDetailPanel({ |
| 1450 | 1512 | > |
| 1451 | 1513 | {detail && statePresentation ? ( |
| 1452 | 1514 | <> |
| 1515 | {onBack ? ( | |
| 1516 | <Tooltip> | |
| 1517 | <TooltipTrigger | |
| 1518 | render={ | |
| 1519 | <Button | |
| 1520 | size="icon-micro" | |
| 1521 | variant="ghost-muted" | |
| 1522 | tabIndex={condensed ? 0 : -1} | |
| 1523 | onClick={onBack} | |
| 1524 | className="-ml-1.5" | |
| 1525 | aria-label="Back to this thread's pull requests" | |
| 1526 | > | |
| 1527 | <ArrowLeftIcon aria-hidden className="size-3.5" /> | |
| 1528 | </Button> | |
| 1529 | } | |
| 1530 | /> | |
| 1531 | <TooltipPopup side="top">Back to pull requests</TooltipPopup> | |
| 1532 | </Tooltip> | |
| 1533 | ) : null} | |
| 1453 | 1534 | <Tooltip> |
| 1454 | 1535 | <TooltipTrigger |
| 1455 | 1536 | render={ |
| ... | ... | @@ -1488,6 +1569,15 @@ export function PullRequestDetailPanel({ |
| 1488 | 1569 | <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1"> |
| 1489 | 1570 | {detail ? ( |
| 1490 | 1571 | <> |
| 1572 | {context === "page" ? ( | |
| 1573 | <PullRequestThreadLinks | |
| 1574 | display="count" | |
| 1575 | environmentId={environmentId} | |
| 1576 | reference={reference} | |
| 1577 | url={detail.url} | |
| 1578 | threadRef={null} | |
| 1579 | /> | |
| 1580 | ) : null} | |
| 1491 | 1581 | {/* Checking a pull request out is the reason to open one here at all, so it is a |
| 1492 | 1582 | button of its own rather than a side effect of asking an agent for something. |
| 1493 | 1583 | It asks where, because the two answers are not interchangeable: one leaves your |
| ... | ... | @@ -1714,6 +1804,17 @@ export function PullRequestDetailPanel({ |
| 1714 | 1804 | <MoreHorizontalIcon className="size-4" /> |
| 1715 | 1805 | </MenuTrigger> |
| 1716 | 1806 | <MenuPopup align="end" side="bottom" className="min-w-72"> |
| 1807 | <PullRequestThreadLinks | |
| 1808 | display="menu-item" | |
| 1809 | environmentId={environmentId} | |
| 1810 | reference={reference} | |
| 1811 | url={detail.url} | |
| 1812 | threadRef={ | |
| 1813 | threadRef ?? | |
| 1814 | (typeof composerDraftTarget === "object" ? composerDraftTarget : null) | |
| 1815 | } | |
| 1816 | onPickerOpenChange={setThreadPickerOpen} | |
| 1817 | /> | |
| 1717 | 1818 | <MenuItem |
| 1718 | 1819 | disabled={isInvalidating || detailQuery.isPending} |
| 1719 | 1820 | onClick={() => void refreshFromHost()} |
| ... | ... | @@ -2001,6 +2102,13 @@ export function PullRequestDetailPanel({ |
| 2001 | 2102 | /> |
| 2002 | 2103 | </span> |
| 2003 | 2104 | </div> |
| 2105 | {nativeStack ? ( | |
| 2106 | <PullRequestStackMap | |
| 2107 | stack={nativeStack} | |
| 2108 | currentNumber={detail.number} | |
| 2109 | className="mt-1" | |
| 2110 | /> | |
| 2111 | ) : null} | |
| 2004 | 2112 | </div> |
| 2005 | 2113 | ) : null} |
| 2006 | 2114 | </div> |
apps/web/src/components/pullRequest/PullRequestStackMap.tsx created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import type { PullRequestStack } from "@t3tools/contracts"; | |
| 2 | import { GitPullRequestArrowIcon } from "lucide-react"; | |
| 3 | ||
| 4 | import { InlineButton } from "../ui/button"; | |
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 7 | import { 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 | */ | |
| 14 | export function PullRequestStackMap({ | |
| 15 | stack, | |
| 16 | currentNumber, | |
| 17 | onSelect, | |
| 18 | className, | |
| 19 | }: { | |
| 20 | stack: PullRequestStack; | |
| 21 | currentNumber: number; | |
| 22 | /** Opens another layer in the same panel; absent where the panel cannot swap references. */ | |
| 23 | onSelect?: ((number: number) => void) | undefined; | |
| 24 | className?: string; | |
| 25 | }) { | |
| 26 | return ( | |
| 27 | <div | |
| 28 | className={cn( | |
| 29 | "flex min-w-0 items-center gap-1 overflow-x-auto text-[11px] text-muted-foreground", | |
| 30 | className, | |
| 31 | )} | |
| 32 | aria-label={`Stack ${stack.number} on ${stack.base}, ${stack.layers.length} layers`} | |
| 33 | > | |
| 34 | <Tooltip> | |
| 35 | <TooltipTrigger | |
| 36 | render={<span className="inline-flex shrink-0 items-center gap-1 font-mono" />} | |
| 37 | > | |
| 38 | <GitPullRequestArrowIcon aria-hidden className="size-3" /> | |
| 39 | <code className="truncate">{stack.base}</code> | |
| 40 | </TooltipTrigger> | |
| 41 | <TooltipPopup side="top"> | |
| 42 | Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it. | |
| 43 | </TooltipPopup> | |
| 44 | </Tooltip> | |
| 45 | {stack.layers.map((layer) => { | |
| 46 | const presentation = resolvePullRequestState({ state: layer.state, isDraft: false }); | |
| 47 | const isCurrent = layer.number === currentNumber; | |
| 48 | const chip = ( | |
| 49 | <span | |
| 50 | className={cn( | |
| 51 | "inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono tabular-nums", | |
| 52 | isCurrent | |
| 53 | ? "border-foreground/40 bg-accent text-foreground" | |
| 54 | : "border-border/70 hover:bg-accent/60", | |
| 55 | )} | |
| 56 | > | |
| 57 | <presentation.Icon aria-hidden className={cn("size-3", presentation.toneClassName)} /># | |
| 58 | {layer.number} | |
| 59 | </span> | |
| 60 | ); | |
| 61 | return ( | |
| 62 | <span key={layer.number} className="inline-flex shrink-0 items-center gap-1"> | |
| 63 | <span aria-hidden className="text-muted-foreground/50"> | |
| 64 | → | |
| 65 | </span> | |
| 66 | <Tooltip> | |
| 67 | <TooltipTrigger | |
| 68 | render={ | |
| 69 | onSelect && !isCurrent ? ( | |
| 70 | <InlineButton onClick={() => onSelect(layer.number)} /> | |
| 71 | ) : ( | |
| 72 | <span aria-current={isCurrent ? "true" : undefined} /> | |
| 73 | ) | |
| 74 | } | |
| 75 | > | |
| 76 | {chip} | |
| 77 | </TooltipTrigger> | |
| 78 | <TooltipPopup side="top"> | |
| 79 | #{layer.number} · {layer.headBranch} · {presentation.label} | |
| 80 | </TooltipPopup> | |
| 81 | </Tooltip> | |
| 82 | </span> | |
| 83 | ); | |
| 84 | })} | |
| 85 | </div> | |
| 86 | ); | |
| 87 | } |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+11-4| ... | ... | @@ -314,12 +314,14 @@ function Section({ |
| 314 | 314 | |
| 315 | 315 | function CommentComposer({ |
| 316 | 316 | environmentId, |
| 317 | reference, | |
| 317 | 318 | detail, |
| 318 | 319 | actionPending, |
| 319 | 320 | onCommentAction, |
| 320 | 321 | onCommented, |
| 321 | 322 | }: { |
| 322 | 323 | environmentId: EnvironmentId; |
| 324 | reference: PullRequestRef; | |
| 323 | 325 | detail: PullRequestDetailView; |
| 324 | 326 | actionPending: boolean; |
| 325 | 327 | onCommentAction: ( |
| ... | ... | @@ -355,9 +357,7 @@ function CommentComposer({ |
| 355 | 357 | const result = await postComment({ |
| 356 | 358 | environmentId, |
| 357 | 359 | input: { |
| 358 | projectId: detail.projectId, | |
| 359 | repository: detail.repository, | |
| 360 | number: detail.number, | |
| 360 | ...reference, | |
| 361 | 361 | body: trimmed, |
| 362 | 362 | }, |
| 363 | 363 | }); |
| ... | ... | @@ -996,7 +996,14 @@ export function PullRequestSummaryTab({ |
| 996 | 996 | {/* Posting is a core capability and remains usable even if the activity read failed. */} |
| 997 | 997 | {detail.capabilities.comment && detail.viewerPermissions.comment ? ( |
| 998 | 998 | <CommentComposer |
| 999 | key={`${environmentId}:${detail.projectId}/${detail.repository}#${detail.number}`} | |
| 999 | reference={reference} | |
| 1000 | key={JSON.stringify([ | |
| 1001 | environmentId, | |
| 1002 | reference.projectId, | |
| 1003 | reference.host, | |
| 1004 | reference.repository, | |
| 1005 | reference.number, | |
| 1006 | ])} | |
| 1000 | 1007 | environmentId={environmentId} |
| 1001 | 1008 | detail={detail} |
| 1002 | 1009 | actionPending={actionPending} |
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx created+255| ... | ... | @@ -0,0 +1,255 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { Link } from "@tanstack/react-router"; | |
| 3 | import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | |
| 4 | import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react"; | |
| 5 | import { useState } from "react"; | |
| 6 | import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; | |
| 7 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; | |
| 8 | ||
| 9 | import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; | |
| 10 | import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; | |
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 12 | import { useEnvironmentQuery } from "~/state/query"; | |
| 13 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 14 | import { buildThreadRouteParams } from "~/threadRoutes"; | |
| 15 | import { Button } from "../ui/button"; | |
| 16 | import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; | |
| 17 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; | |
| 18 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | |
| 19 | import { toastManager } from "../ui/toast"; | |
| 20 | ||
| 21 | interface 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. */ | |
| 31 | export 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 | ||
| 42 | function 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 | ||
| 186 | function 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 @@ |
| 1 | import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | resolveThreadPullRequestChains, | |
| 4 | visibleThreadPullRequests, | |
| 5 | } from "@t3tools/shared/threadPullRequests"; | |
| 6 | import { | |
| 7 | GitPullRequestArrow, | |
| 8 | LayersIcon, | |
| 9 | LinkIcon, | |
| 10 | MoreHorizontalIcon, | |
| 11 | PlusIcon, | |
| 12 | } from "lucide-react"; | |
| 13 | import { useCallback, useMemo } from "react"; | |
| 14 | ||
| 15 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | |
| 16 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; | |
| 17 | import { cn } from "~/lib/utils"; | |
| 18 | import { useServerConfigs, useThreadShell } from "~/state/entities"; | |
| 19 | import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; | |
| 20 | import { threadEnvironment } from "~/state/threads"; | |
| 21 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 22 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | |
| 23 | import { Button } from "../ui/button"; | |
| 24 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | |
| 25 | import { ScrollArea } from "../ui/scroll-area"; | |
| 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 27 | import { openLinkPullRequestDialog } from "./LinkPullRequestDialog"; | |
| 28 | import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines"; | |
| 29 | import { | |
| 30 | PullRequestActorAvatar, | |
| 31 | PullRequestDiffStat, | |
| 32 | PullRequestStateGlyph, | |
| 33 | pullRequestChecksStatePresentation, | |
| 34 | } from "./pullRequestPresentation"; | |
| 35 | ||
| 36 | const 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 | ||
| 44 | function 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 | ||
| 64 | function 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 | ||
| 207 | export 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 | ||
| 220 | function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { | |
| 221 | const thread = useThreadShell(threadRef); | |
| 222 | const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); | |
| 223 | const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); | |
| 224 | const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); | |
| 225 | const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); | |
| 226 | const handleUnlink = useCallback( | |
| 227 | (link: ThreadPullRequestLink) => { | |
| 228 | void unlink({ | |
| 229 | environmentId: threadRef.environmentId, | |
| 230 | input: { | |
| 231 | threadId: threadRef.threadId, | |
| 232 | host: link.host, | |
| 233 | repository: link.repository, | |
| 234 | number: link.number, | |
| 235 | }, | |
| 236 | }); | |
| 237 | }, | |
| 238 | [threadRef, unlink], | |
| 239 | ); | |
| 240 | const openCount = useMemo( | |
| 241 | () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length, | |
| 242 | [links], | |
| 243 | ); | |
| 244 | const lastSynced = useMemo(() => { | |
| 245 | let latest: string | null = null; | |
| 246 | for (const link of links) { | |
| 247 | const at = link.snapshot?.syncedAt; | |
| 248 | if (at !== undefined && (latest === null || at > latest)) latest = at; | |
| 249 | } | |
| 250 | return latest; | |
| 251 | }, [links]); | |
| 252 | ||
| 253 | if (links.length === 0) { | |
| 254 | return ( | |
| 255 | <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center"> | |
| 256 | <LinkIcon aria-hidden className="size-6 text-muted-foreground/60" /> | |
| 257 | <p className="text-sm font-medium">No linked pull requests</p> | |
| 258 | <p className="max-w-60 text-xs text-muted-foreground"> | |
| 259 | Pull requests the agent opens from this thread land here. Link one yourself from a URL or | |
| 260 | a number. | |
| 261 | </p> | |
| 262 | <Button size="sm" variant="outline" onClick={openLinkDialog}> | |
| 263 | <PlusIcon className="size-3.5" /> | |
| 264 | Link pull request | |
| 265 | </Button> | |
| 266 | </div> | |
| 267 | ); | |
| 268 | } | |
| 269 | ||
| 270 | return ( | |
| 271 | <div className="flex h-full min-h-0 flex-col"> | |
| 272 | <ScrollArea className="min-h-0 flex-1"> | |
| 273 | <div className="flex flex-col p-1.5"> | |
| 274 | {lines.map((line) => ( | |
| 275 | <LinkRow | |
| 276 | key={`${line.link.host}/${line.link.repository}#${line.link.number}`} | |
| 277 | line={line} | |
| 278 | threadRef={threadRef} | |
| 279 | onUnlink={handleUnlink} | |
| 280 | /> | |
| 281 | ))} | |
| 282 | </div> | |
| 283 | </ScrollArea> | |
| 284 | <footer className="flex items-center justify-between border-t border-border/60 px-2 py-1.5 text-[.7rem] text-muted-foreground"> | |
| 285 | <span> | |
| 286 | {openCount} open · {links.length} linked | |
| 287 | {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""} | |
| 288 | </span> | |
| 289 | <Button size="xs" variant="ghost" onClick={openLinkDialog}> | |
| 290 | <PlusIcon className="size-3.5" /> | |
| 291 | Link | |
| 292 | </Button> | |
| 293 | </footer> | |
| 294 | </div> | |
| 295 | ); | |
| 296 | } |
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+36| ... | ... | @@ -1420,6 +1420,42 @@ describe("cached pull request detail", () => { |
| 1420 | 1420 | expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull(); |
| 1421 | 1421 | }); |
| 1422 | 1422 | |
| 1423 | it("isolates stored and displayed details between hosts with the same repository and number", () => { | |
| 1424 | const storage = makeStorage(); | |
| 1425 | const publicRef = { ...reference, host: "github.com" }; | |
| 1426 | const enterpriseRef = { ...reference, host: "github.example.com" }; | |
| 1427 | const publicDetail = detail(); | |
| 1428 | const enterpriseDetail = detail({ | |
| 1429 | title: "Enterprise change", | |
| 1430 | url: "https://github.example.com/acme/web/pull/7", | |
| 1431 | }); | |
| 1432 | writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail); | |
| 1433 | expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); | |
| 1434 | writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, enterpriseDetail); | |
| 1435 | expect(readPullRequestDetailSnapshot(storage, "env-1", publicRef)?.title).toBe( | |
| 1436 | publicDetail.title, | |
| 1437 | ); | |
| 1438 | expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)?.title).toBe( | |
| 1439 | enterpriseDetail.title, | |
| 1440 | ); | |
| 1441 | expect( | |
| 1442 | resolveDisplayedPullRequestDetail({ | |
| 1443 | live: null, | |
| 1444 | cached: publicDetail, | |
| 1445 | reference: enterpriseRef, | |
| 1446 | }), | |
| 1447 | ).toBeNull(); | |
| 1448 | expect( | |
| 1449 | resolveDisplayedPullRequestDetail({ | |
| 1450 | live: null, | |
| 1451 | cached: enterpriseDetail, | |
| 1452 | reference: enterpriseRef, | |
| 1453 | }), | |
| 1454 | ).toBe(enterpriseDetail); | |
| 1455 | writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, publicDetail); | |
| 1456 | expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); | |
| 1457 | }); | |
| 1458 | ||
| 1423 | 1459 | it("shrugs off corrupt storage and no storage at all", () => { |
| 1424 | 1460 | const storage = makeStorage(); |
| 1425 | 1461 | storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); |
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+11-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | PullRequestDetail, |
| ... | ... | @@ -1028,6 +1029,7 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo |
| 1028 | 1029 | type SnapshotStorage = Pick<Storage, "getItem" | "setItem">; |
| 1029 | 1030 | |
| 1030 | 1031 | export interface PullRequestDetailSnapshotRef { |
| 1032 | readonly host?: string | undefined; | |
| 1031 | 1033 | readonly projectId: string; |
| 1032 | 1034 | readonly repository: string; |
| 1033 | 1035 | readonly number: number; |
| ... | ... | @@ -1037,7 +1039,9 @@ const pullRequestDetailSnapshotKey = ( |
| 1037 | 1039 | environmentId: string, |
| 1038 | 1040 | reference: PullRequestDetailSnapshotRef, |
| 1039 | 1041 | ) => |
| 1040 | `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; | |
| 1042 | reference.host | |
| 1043 | ? `t3.pullRequests.detail:${JSON.stringify([environmentId, reference.projectId, reference.host.toLowerCase(), reference.repository.toLowerCase(), reference.number])}` | |
| 1044 | : `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; | |
| 1041 | 1045 | |
| 1042 | 1046 | const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail); |
| 1043 | 1047 | |
| ... | ... | @@ -1056,7 +1060,9 @@ export function readPullRequestDetailSnapshot( |
| 1056 | 1060 | const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)); |
| 1057 | 1061 | if (!raw) return null; |
| 1058 | 1062 | const decoded = decodeDetailSnapshot(JSON.parse(raw)); |
| 1059 | return decoded._tag === "Some" ? decoded.value : null; | |
| 1063 | return decoded._tag === "Some" | |
| 1064 | ? resolveDisplayedPullRequestDetail({ live: null, cached: decoded.value, reference }) | |
| 1065 | : null; | |
| 1060 | 1066 | } catch { |
| 1061 | 1067 | return null; |
| 1062 | 1068 | } |
| ... | ... | @@ -1090,7 +1096,9 @@ export function resolveDisplayedPullRequestDetail(input: { |
| 1090 | 1096 | input.cached !== null && |
| 1091 | 1097 | input.cached.projectId === input.reference.projectId && |
| 1092 | 1098 | 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()) | |
| 1094 | 1102 | ) { |
| 1095 | 1103 | return input.cached; |
| 1096 | 1104 | } |
apps/web/src/components/pullRequest/pullRequestListLines.test.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | import type { ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 2 | import { resolveThreadPullRequestChains } from "@t3tools/shared/threadPullRequests"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { pullRequestListLines } from "./pullRequestListLines"; | |
| 6 | ||
| 7 | function 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 | ||
| 34 | describe("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 @@ |
| 1 | import type { ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 2 | import 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. */ | |
| 5 | export 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 | ||
| 15 | function activityAt(link: ThreadPullRequestLink): number { | |
| 16 | const ms = Date.parse(link.snapshot?.updatedAt ?? link.linkedAt); | |
| 17 | return Number.isNaN(ms) ? 0 : ms; | |
| 18 | } | |
| 19 | ||
| 20 | function 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 | */ | |
| 30 | export function pullRequestListLines( | |
| 31 | chains: ReadonlyArray<ThreadPullRequestChain>, | |
| 32 | ): ReadonlyArray<PullRequestListLine> { | |
| 33 | const ordered = [...chains].sort( | |
| 34 | (left, right) => | |
| 35 | Math.max(...right.layers.map(activityAt)) - Math.max(...left.layers.map(activityAt)), | |
| 36 | ); | |
| 37 | return ordered.flatMap((chain) => { | |
| 38 | const chainKey = chainKeyOf(chain); | |
| 39 | return chain.layers.map((link, depth) => ({ | |
| 40 | link, | |
| 41 | depth, | |
| 42 | chainKey, | |
| 43 | stack: | |
| 44 | depth === 0 && chain.layers.length > 1 | |
| 45 | ? { kind: chain.kind, size: chain.layers.length } | |
| 46 | : null, | |
| 47 | })); | |
| 48 | }); | |
| 49 | } |
apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts+30-1| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 2 | import { ProjectId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | import { type PendingReviewComment, usePullRequestReviewStore } from "./pullRequestReviewStore"; | |
| 4 | import { | |
| 5 | type PendingReviewComment, | |
| 6 | pullRequestReviewKey, | |
| 7 | usePullRequestReviewStore, | |
| 8 | } from "./pullRequestReviewStore"; | |
| 4 | 9 | |
| 5 | 10 | function comment(id: string, body = id): PendingReviewComment { |
| 6 | 11 | return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } }; |
| ... | ... | @@ -36,6 +41,30 @@ describe("pull request review drafts", () => { |
| 36 | 41 | }); |
| 37 | 42 | }); |
| 38 | 43 | |
| 44 | it("keeps drafts on different hosts separate when a thread reviews the same repository and number", () => { | |
| 45 | const reference = { | |
| 46 | projectId: ProjectId.make("project-a"), | |
| 47 | repository: "owner/repo", | |
| 48 | number: 7, | |
| 49 | }; | |
| 50 | const publicKey = pullRequestReviewKey({ ...reference, host: "github.com" }); | |
| 51 | const enterpriseKey = pullRequestReviewKey({ ...reference, host: "github.example.com" }); | |
| 52 | const store = usePullRequestReviewStore.getState(); | |
| 53 | store.addComment(publicKey, comment("public")); | |
| 54 | store.setSummary(publicKey, "Public review"); | |
| 55 | ||
| 56 | expect(usePullRequestReviewStore.getState().drafts[enterpriseKey]).toBeUndefined(); | |
| 57 | expect(usePullRequestReviewStore.getState().summaries[enterpriseKey]).toBeUndefined(); | |
| 58 | ||
| 59 | store.addComment(enterpriseKey, comment("enterprise")); | |
| 60 | store.setSummary(enterpriseKey, "Enterprise review"); | |
| 61 | store.clear(enterpriseKey); | |
| 62 | store.clearSummary(enterpriseKey, "Enterprise review"); | |
| 63 | ||
| 64 | expect(usePullRequestReviewStore.getState().drafts[publicKey]).toEqual([comment("public")]); | |
| 65 | expect(usePullRequestReviewStore.getState().summaries[publicKey]).toBe("Public review"); | |
| 66 | }); | |
| 67 | ||
| 39 | 68 | it("does not clear a summary revised while submission is in flight", () => { |
| 40 | 69 | const store = usePullRequestReviewStore.getState(); |
| 41 | 70 | store.setSummary("review-a", "Submitted body"); |
apps/web/src/components/pullRequest/pullRequestReviewStore.ts+11-8| ... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 | * hosts that have no pending review of their own. That also means a draft lives only as long |
| 7 | 7 | * as the tab does, which is why this is deliberately not persisted. |
| 8 | 8 | */ |
| 9 | import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; | |
| 9 | import type { PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; | |
| 10 | 10 | import { create } from "zustand"; |
| 11 | 11 | |
| 12 | 12 | export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; |
| ... | ... | @@ -24,9 +24,14 @@ export function nextPendingReviewCommentId(): string { |
| 24 | 24 | return `pending-review-comment-${pendingCommentSequence}`; |
| 25 | 25 | } |
| 26 | 26 | |
| 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. */ | |
| 28 | 28 | export function pullRequestReviewKey(reference: PullRequestRef): string { |
| 29 | return `${reference.projectId}/${reference.repository}#${reference.number}`; | |
| 29 | return JSON.stringify([ | |
| 30 | reference.projectId, | |
| 31 | reference.host?.toLowerCase() ?? null, | |
| 32 | reference.repository.toLowerCase(), | |
| 33 | reference.number, | |
| 34 | ]); | |
| 30 | 35 | } |
| 31 | 36 | |
| 32 | 37 | interface PullRequestReviewStoreState { |
| ... | ... | @@ -81,11 +86,9 @@ export const usePullRequestReviewStore = create<PullRequestReviewStoreState>()(( |
| 81 | 86 | })); |
| 82 | 87 | |
| 83 | 88 | /** The comments a pull request's draft holds, stable across renders while it is empty. */ |
| 84 | export function usePendingReviewComments(reference: { | |
| 85 | readonly projectId: ProjectId; | |
| 86 | readonly repository: string; | |
| 87 | readonly number: number; | |
| 88 | }): ReadonlyArray<PendingReviewComment> { | |
| 89 | export function usePendingReviewComments( | |
| 90 | reference: PullRequestRef, | |
| 91 | ): ReadonlyArray<PendingReviewComment> { | |
| 89 | 92 | return usePullRequestReviewStore( |
| 90 | 93 | (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY, |
| 91 | 94 | ); |
apps/web/src/components/ui/button.tsx+21| ... | ... | @@ -23,6 +23,7 @@ const buttonVariants = cva( |
| 23 | 23 | "icon-lg": "size-10 sm:size-9", |
| 24 | 24 | "icon-micro": |
| 25 | 25 | "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", |
| 26 | "icon-tiny": "size-4 p-0 [&_svg:not([class*='size-'])]:size-3", | |
| 26 | 27 | "icon-sm": "size-8 sm:size-7", |
| 27 | 28 | "icon-xl": |
| 28 | 29 | "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", |
| ... | ... | @@ -85,3 +86,23 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) { |
| 85 | 86 | } |
| 86 | 87 | |
| 87 | 88 | export { Button, buttonVariants }; |
| 89 | ||
| 90 | /** An inline action that keeps the geometry of surrounding text or a graph node. */ | |
| 91 | export function InlineButton({ | |
| 92 | className, | |
| 93 | underline = false, | |
| 94 | ...props | |
| 95 | }: React.ComponentProps<"button"> & { underline?: boolean }) { | |
| 96 | return ( | |
| 97 | <button | |
| 98 | type="button" | |
| 99 | data-slot="inline-button" | |
| 100 | className={cn( | |
| 101 | "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64", | |
| 102 | underline && "border-b border-transparent hover:border-current", | |
| 103 | className, | |
| 104 | )} | |
| 105 | {...props} | |
| 106 | /> | |
| 107 | ); | |
| 108 | } |
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+5| ... | ... | @@ -11,6 +11,7 @@ import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanel |
| 11 | 11 | import { useProject } from "../state/entities"; |
| 12 | 12 | import { pullRequestEnvironment } from "../state/pullRequests"; |
| 13 | 13 | import { useEnvironmentQuery } from "../state/query"; |
| 14 | import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; | |
| 14 | 15 | |
| 15 | 16 | export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 16 | 17 | const surface = useRightPanelStore((state) => |
| ... | ... | @@ -20,6 +21,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 20 | 21 | const environmentId = reference?.environmentId |
| 21 | 22 | ? EnvironmentId.make(reference.environmentId) |
| 22 | 23 | : threadRef?.environmentId; |
| 24 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null); | |
| 23 | 25 | const project = useProject( |
| 24 | 26 | reference && environmentId |
| 25 | 27 | ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId)) |
| ... | ... | @@ -31,6 +33,9 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 31 | 33 | environmentId, |
| 32 | 34 | input: { |
| 33 | 35 | projectId: ProjectId.make(reference.projectId), |
| 36 | ...(supportsMultiplePullRequests && reference.host !== undefined | |
| 37 | ? { host: reference.host } | |
| 38 | : {}), | |
| 34 | 39 | repository: reference.repository, |
| 35 | 40 | number: reference.number, |
| 36 | 41 | }, |
apps/web/src/hooks/usePullRequestLinking.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | |
| 3 | import type { | |
| 4 | EnvironmentId, | |
| 5 | ScopedThreadRef, | |
| 6 | ThreadLinkedPullRequest, | |
| 7 | ThreadPullRequestLink, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | isAtomCommandInterrupted, | |
| 11 | squashAtomCommandFailure, | |
| 12 | } from "@t3tools/client-runtime/state/runtime"; | |
| 13 | import { | |
| 14 | planThreadPullRequestMutation, | |
| 15 | threadPullRequestLinkMode, | |
| 16 | } from "@t3tools/client-runtime/thread-pull-request-compatibility"; | |
| 17 | import { | |
| 18 | threadPullRequestKeysEqual, | |
| 19 | visibleThreadPullRequests, | |
| 20 | } from "@t3tools/shared/threadPullRequests"; | |
| 21 | import { | |
| 22 | findProjectForChangeRequest, | |
| 23 | findProjectOnChangeRequestHost, | |
| 24 | matchesLinkedPullRequestUrl, | |
| 25 | parseChangeRequestUrl, | |
| 26 | } from "~/lib/openPullRequestLink"; | |
| 27 | import { useProjects, useServerConfigs } from "~/state/entities"; | |
| 28 | import { threadEnvironment } from "~/state/threads"; | |
| 29 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 30 | ||
| 31 | /** Routes link actions through the command advertised by this environment. */ | |
| 32 | export 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 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { useServerConfigs } from "~/state/entities"; | |
| 3 | ||
| 4 | export function useSupportsMultiplePullRequests(environmentId: EnvironmentId | null): boolean { | |
| 5 | const configs = useServerConfigs(); | |
| 6 | return ( | |
| 7 | environmentId !== null && | |
| 8 | configs.get(environmentId)?.environment.capabilities.threadPullRequests === true | |
| 9 | ); | |
| 10 | } |
apps/web/src/lib/openPullRequestLink.test.ts+76| ... | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | changeRequestRepositoryUrl, |
| 5 | 5 | findProjectForChangeRequest, |
| 6 | findProjectOnChangeRequestHost, | |
| 6 | 7 | gitHubPullRequestBrowserUrl, |
| 7 | 8 | matchesLinkedPullRequestUrl, |
| 8 | 9 | parseChangeRequestUrl, |
| ... | ... | @@ -295,6 +296,81 @@ describe("parseChangeRequestUrl", () => { |
| 295 | 296 | }); |
| 296 | 297 | }); |
| 297 | 298 | |
| 299 | describe("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 | ||
| 298 | 374 | describe("findProjectForChangeRequest", () => { |
| 299 | 375 | const project = (identity: Record<string, unknown>) => |
| 300 | 376 | ({ id: "p1", repositoryIdentity: identity }) as never; |
apps/web/src/lib/openPullRequestLink.ts+76-182| ... | ... | @@ -1,13 +1,13 @@ |
| 1 | import type { | |
| 2 | EnvironmentId, | |
| 3 | RepositoryIdentity, | |
| 4 | ScopedThreadRef, | |
| 5 | ThreadLinkedPullRequest, | |
| 6 | } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 7 | 2 | import { useNavigate } from "@tanstack/react-router"; |
| 8 | 3 | import { type MouseEvent, useCallback } from "react"; |
| 9 | 4 | |
| 10 | 5 | import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; |
| 6 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; | |
| 7 | import { | |
| 8 | canonicalRepositoryKey, | |
| 9 | sourceControlRepositorySelector, | |
| 10 | } from "@t3tools/shared/sourceControl"; | |
| 11 | 11 | |
| 12 | 12 | import { useOpenLink } from "../browser/useOpenLink"; |
| 13 | 13 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| ... | ... | @@ -17,176 +17,14 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 17 | 17 | import { useProjects, useServerConfigs } from "../state/entities"; |
| 18 | 18 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 19 | 19 | |
| 20 | /** Builds a GitHub URL that remains available when the pull request API cannot be read. */ | |
| 21 | export 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 | */ | |
| 64 | export 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. */ | |
| 71 | function 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 | */ | |
| 87 | export 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 | */ | |
| 131 | export 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. */ | |
| 151 | export 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. */ | |
| 167 | export 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 | ||
| 183 | function 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 | } | |
| 20 | export { | |
| 21 | parseChangeRequestUrl, | |
| 22 | type ChangeRequestLink, | |
| 23 | gitHubPullRequestBrowserUrl, | |
| 24 | pullRequestCandidateUrlFromReferenceAutolink, | |
| 25 | matchesLinkedPullRequestUrl, | |
| 26 | changeRequestRepositoryUrl, | |
| 27 | } from "@t3tools/shared/changeRequestUrl"; | |
| 190 | 28 | |
| 191 | 29 | /** |
| 192 | 30 | * Returns a click handler that opens a pull request URL in the system browser. |
| ... | ... | @@ -210,6 +48,12 @@ export function findProjectForChangeRequest( |
| 210 | 48 | if (!identity) return false; |
| 211 | 49 | const kind = identity.provider as SourceControlProviderKind | undefined; |
| 212 | 50 | if (kind === undefined) return false; |
| 51 | if (kind === "azure-devops") { | |
| 52 | return ( | |
| 53 | canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === | |
| 54 | canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()) | |
| 55 | ); | |
| 56 | } | |
| 213 | 57 | const repository = |
| 214 | 58 | identity.displayName ?? |
| 215 | 59 | (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); |
| ... | ... | @@ -221,6 +65,36 @@ export function findProjectForChangeRequest( |
| 221 | 65 | }); |
| 222 | 66 | } |
| 223 | 67 | |
| 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 | */ | |
| 73 | export function findProjectOnChangeRequestHost( | |
| 74 | projects: ReadonlyArray<EnvironmentProject>, | |
| 75 | link: ChangeRequestLink, | |
| 76 | ): EnvironmentProject | undefined { | |
| 77 | const own = findProjectForChangeRequest(projects, link); | |
| 78 | if (own !== undefined) return own; | |
| 79 | // Azure CLI reads use the checkout's organization and project, not host-wide credentials. | |
| 80 | if ( | |
| 81 | canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith( | |
| 82 | "dev.azure.com/", | |
| 83 | ) | |
| 84 | ) | |
| 85 | return undefined; | |
| 86 | return projects.find((project) => { | |
| 87 | const identity = project.repositoryIdentity; | |
| 88 | const kind = identity?.provider as SourceControlProviderKind | undefined; | |
| 89 | return ( | |
| 90 | identity != null && | |
| 91 | kind !== undefined && | |
| 92 | kind !== "azure-devops" && | |
| 93 | pullRequestHostOf(identity, kind) === link.host.toLowerCase() | |
| 94 | ); | |
| 95 | }); | |
| 96 | } | |
| 97 | ||
| 224 | 98 | /** |
| 225 | 99 | * Opens a change request link on the page, and says whether it did. Anything else — another |
| 226 | 100 | * organisation's repository, a host nothing here is checked out from, a link that merely looks |
| ... | ... | @@ -284,8 +158,25 @@ export function useOpenChangeRequestLink( |
| 284 | 158 | Number(right.environmentId === primaryEnvironmentId) - |
| 285 | 159 | Number(left.environmentId === primaryEnvironmentId), |
| 286 | 160 | ); |
| 287 | const project = findProjectForChangeRequest(projects, parsed); | |
| 161 | const exactProject = findProjectForChangeRequest(projects, parsed); | |
| 162 | const project = | |
| 163 | exactProject ?? | |
| 164 | (resolvedPanelRef | |
| 165 | ? findProjectOnChangeRequestHost( | |
| 166 | projects.filter( | |
| 167 | (candidate) => | |
| 168 | serverConfigs.get(candidate.environmentId)?.environment.capabilities | |
| 169 | .threadPullRequests === true, | |
| 170 | ), | |
| 171 | parsed, | |
| 172 | ) | |
| 173 | : undefined); | |
| 288 | 174 | if (project === undefined || !reads(project.environmentId)) return false; |
| 175 | const repository = | |
| 176 | serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === | |
| 177 | true | |
| 178 | ? parsed.repository | |
| 179 | : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository); | |
| 289 | 180 | event.preventDefault(); |
| 290 | 181 | event.stopPropagation(); |
| 291 | 182 | if (resolvedPanelRef) { |
| ... | ... | @@ -295,9 +186,12 @@ export function useOpenChangeRequestLink( |
| 295 | 186 | ? {} |
| 296 | 187 | : { environmentId: project.environmentId }), |
| 297 | 188 | 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, | |
| 301 | 195 | number: parsed.number, |
| 302 | 196 | }); |
| 303 | 197 | if (!resolvedThreadRef) { |
| ... | ... | @@ -307,7 +201,7 @@ export function useOpenChangeRequestLink( |
| 307 | 201 | ...previous, |
| 308 | 202 | involvement: previous.involvement ?? "all", |
| 309 | 203 | state: previous.state ?? "all", |
| 310 | repository: project.repositoryIdentity?.displayName ?? parsed.repository, | |
| 204 | repository, | |
| 311 | 205 | number: parsed.number, |
| 312 | 206 | selectedProjectId: project.id, |
| 313 | 207 | selectedEnvironmentId: project.environmentId, |
| ... | ... | @@ -324,7 +218,7 @@ export function useOpenChangeRequestLink( |
| 324 | 218 | // Every state, so the pull request being opened is also in the list behind it whether |
| 325 | 219 | // it is open, merged or closed. |
| 326 | 220 | state: "all", |
| 327 | repository: parsed.repository, | |
| 221 | repository, | |
| 328 | 222 | number: parsed.number, |
| 329 | 223 | selectedProjectId: project.id, |
| 330 | 224 | // Named so the page opens the right one of two servers holding this project. |
apps/web/src/lib/threadSort.test.ts+1| ... | ... | @@ -34,6 +34,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 34 | 34 | branch: null, |
| 35 | 35 | worktreePath: null, |
| 36 | 36 | checkpoints: [], |
| 37 | pullRequests: [], | |
| 37 | 38 | activities: [], |
| 38 | 39 | ...overrides, |
| 39 | 40 | }; |
apps/web/src/rightPanelStore.test.ts+12| ... | ... | @@ -605,6 +605,18 @@ describe("rightPanelStore", () => { |
| 605 | 605 | expect(state.surfaces[1]).not.toHaveProperty("url"); |
| 606 | 606 | }); |
| 607 | 607 | |
| 608 | it("keeps matching repository and number on different hosts as separate tabs", () => { | |
| 609 | const first = { projectId: "project-a", repository: "acme/api", number: 7, host: "github.com" }; | |
| 610 | const second = { ...first, host: "github.example.com" }; | |
| 611 | useRightPanelStore.getState().openPullRequest(refA, first); | |
| 612 | useRightPanelStore.getState().openPullRequest(refA, second); | |
| 613 | const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); | |
| 614 | expect(state.surfaces).toEqual([pullRequestSurface(first), pullRequestSurface(second)]); | |
| 615 | expect(pullRequestSurfaceId({ ...first, host: "GITHUB.COM" })).toBe( | |
| 616 | pullRequestSurfaceId(first), | |
| 617 | ); | |
| 618 | }); | |
| 619 | ||
| 608 | 620 | it("keeps one pull request read from two servers as two tabs", () => { |
| 609 | 621 | const local = { |
| 610 | 622 | environmentId: "local", |
apps/web/src/rightPanelStore.ts+12-1| ... | ... | @@ -26,6 +26,7 @@ const RIGHT_PANEL_KINDS = [ |
| 26 | 26 | "preview", |
| 27 | 27 | "terminal", |
| 28 | 28 | "pull-request", |
| 29 | "pull-requests", | |
| 29 | 30 | "agents", |
| 30 | 31 | ] as const; |
| 31 | 32 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; |
| ... | ... | @@ -68,10 +69,13 @@ export type RightPanelSurface = |
| 68 | 69 | */ |
| 69 | 70 | environmentId?: string; |
| 70 | 71 | projectId: string; |
| 72 | host?: string; | |
| 71 | 73 | repository: string; |
| 72 | 74 | number: number; |
| 73 | 75 | url?: string; |
| 74 | 76 | } |
| 77 | /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */ | |
| 78 | | { id: "pull-requests"; kind: "pull-requests" } | |
| 75 | 79 | | { id: "agents"; kind: "agents" }; |
| 76 | 80 | |
| 77 | 81 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| ... | ... | @@ -124,6 +128,7 @@ interface RightPanelStoreState { |
| 124 | 128 | target: { |
| 125 | 129 | environmentId?: string; |
| 126 | 130 | projectId: string; |
| 131 | host?: string; | |
| 127 | 132 | repository: string; |
| 128 | 133 | number: number; |
| 129 | 134 | url?: string; |
| ... | ... | @@ -169,6 +174,8 @@ const singletonSurface = ( |
| 169 | 174 | return { id: "diff", kind }; |
| 170 | 175 | case "files": |
| 171 | 176 | return { id: "files", kind }; |
| 177 | case "pull-requests": | |
| 178 | return { id: "pull-requests", kind }; | |
| 172 | 179 | case "agents": |
| 173 | 180 | return { id: "agents", kind }; |
| 174 | 181 | } |
| ... | ... | @@ -213,6 +220,7 @@ export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-reques |
| 213 | 220 | export function pullRequestSurfaceId(target: { |
| 214 | 221 | environmentId?: string; |
| 215 | 222 | projectId: string; |
| 223 | host?: string; | |
| 216 | 224 | repository: string; |
| 217 | 225 | number: number; |
| 218 | 226 | }): PullRequestSurface["id"] { |
| ... | ... | @@ -220,12 +228,14 @@ export function pullRequestSurfaceId(target: { |
| 220 | 228 | // servers is two tabs rather than one tab that changes its mind about which server it is on. |
| 221 | 229 | const scope = |
| 222 | 230 | target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`; |
| 223 | return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`; | |
| 231 | const host = target.host === undefined ? "" : `${encodeURIComponent(target.host.toLowerCase())}:`; | |
| 232 | return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${host}${encodeURIComponent(target.repository)}:${target.number}`; | |
| 224 | 233 | } |
| 225 | 234 | |
| 226 | 235 | export function pullRequestSurface(target: { |
| 227 | 236 | environmentId?: string; |
| 228 | 237 | projectId: string; |
| 238 | host?: string; | |
| 229 | 239 | repository: string; |
| 230 | 240 | number: number; |
| 231 | 241 | url?: string; |
| ... | ... | @@ -235,6 +245,7 @@ export function pullRequestSurface(target: { |
| 235 | 245 | kind: "pull-request", |
| 236 | 246 | ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }), |
| 237 | 247 | projectId: target.projectId, |
| 248 | ...(typeof target.host === "string" ? { host: target.host.toLowerCase() } : {}), | |
| 238 | 249 | repository: target.repository, |
| 239 | 250 | number: target.number, |
| 240 | 251 | ...(typeof target.url === "string" ? { url: target.url } : {}), |
apps/web/src/routes/_chat.pull-requests.tsx+2| ... | ... | @@ -1923,12 +1923,14 @@ function PullRequestsRouteView() { |
| 1923 | 1923 | onAddDiff={() => undefined} |
| 1924 | 1924 | onAddFiles={() => undefined} |
| 1925 | 1925 | onAddPullRequest={() => undefined} |
| 1926 | onAddPullRequests={() => undefined} | |
| 1926 | 1927 | onAddAgents={() => undefined} |
| 1927 | 1928 | browserAvailable={false} |
| 1928 | 1929 | terminalAvailable={false} |
| 1929 | 1930 | diffAvailable={false} |
| 1930 | 1931 | filesAvailable={false} |
| 1931 | 1932 | pullRequestAvailable={false} |
| 1933 | pullRequestsAvailable={false} | |
| 1932 | 1934 | agentsAvailable={false} |
| 1933 | 1935 | liveAgentCount={0} |
| 1934 | 1936 | pullRequestStatusSeeds={listedPullRequestTabStatuses} |
apps/web/src/state/pullRequests.ts+3| ... | ... | @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | createLinkedPullRequestSummaryAtomFamily, |
| 4 | 4 | createPullRequestEnvironmentAtoms, |
| 5 | createPullRequestStackAtomFamily, | |
| 5 | 6 | } from "@t3tools/client-runtime/state/pull-requests"; |
| 6 | 7 | import type { |
| 7 | 8 | EnvironmentId, |
| ... | ... | @@ -58,6 +59,7 @@ export function useSharedPullRequestSummary( |
| 58 | 59 | : JSON.stringify([ |
| 59 | 60 | environmentId, |
| 60 | 61 | reference.projectId, |
| 62 | reference.host?.toLowerCase() ?? null, | |
| 61 | 63 | reference.repository.toLowerCase(), |
| 62 | 64 | reference.number, |
| 63 | 65 | ]); |
| ... | ... | @@ -72,6 +74,7 @@ export function useSharedPullRequestSummary( |
| 72 | 74 | }, [atom, current, environmentId]); |
| 73 | 75 | return newestPullRequestSummary(current, observed); |
| 74 | 76 | } |
| 77 | export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime); | |
| 75 | 78 | |
| 76 | 79 | export interface EnvironmentQueryTarget<Input> { |
| 77 | 80 | readonly environmentId: EnvironmentId; |
apps/web/src/state/sourceControlActions.ts+2| ... | ... | @@ -218,6 +218,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { |
| 218 | 218 | commitMessage?: string; |
| 219 | 219 | featureBranch?: boolean; |
| 220 | 220 | filePaths?: string[]; |
| 221 | threadId?: ThreadId; | |
| 221 | 222 | onProgress?: (event: GitActionProgressEvent) => void; |
| 222 | 223 | }) => { |
| 223 | 224 | if (resolveScope(scope) === null) { |
| ... | ... | @@ -237,6 +238,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { |
| 237 | 238 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), |
| 238 | 239 | ...(input.featureBranch ? { featureBranch: true } : {}), |
| 239 | 240 | ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), |
| 241 | ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), | |
| 240 | 242 | ...(input.onProgress ? { onProgress: input.onProgress } : {}), |
| 241 | 243 | }); |
| 242 | 244 | }, |
apps/web/src/worktreeCleanup.test.ts+1| ... | ... | @@ -21,6 +21,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 21 | 21 | session: null, |
| 22 | 22 | messages: [], |
| 23 | 23 | checkpoints: [], |
| 24 | pullRequests: [], | |
| 24 | 25 | activities: [], |
| 25 | 26 | proposedPlans: [], |
| 26 | 27 | createdAt: "2026-02-13T00:00:00.000Z", |
docs/internals/glossary.md+8| ... | ... | @@ -45,3 +45,11 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain |
| 45 | 45 | | Checkpoint | A saved workspace state used for diffs and restore, stored as a hidden Git ref. | |
| 46 | 46 | | Checkpoint baseline | The workspace state captured before the work being compared. | |
| 47 | 47 | | Turn diff | The workspace changes attributed to one turn. | |
| 48 | ||
| 49 | ## Pull requests | |
| 50 | ||
| 51 | | Term | Meaning | | |
| 52 | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 53 | | Pull request link | A persisted thread association identified by host, repository, and number. Links can cross projects within an environment and carry a server-maintained snapshot. | | |
| 54 | | Pull request sync | The reactor that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. | | |
| 55 | | Current pull request | The link used by single-review controls and older clients. Open work takes precedence; a completed single chain points at its top layer. Unrelated terminal links use the latest update. | |
docs/internals/overview.md+19| ... | ... | @@ -18,6 +18,25 @@ versioned clients and servers. Subscriptions send the state a client needs, so a |
| 18 | 18 | thread does not pay for every thread's history. Authentication of a socket does not authorize every |
| 19 | 19 | method on it. See [environment auth](./environment-auth.md). |
| 20 | 20 | |
| 21 | ### Pull request linking compatibility | |
| 22 | ||
| 23 | Web, desktop, mobile, and environments upgrade independently. Negotiate linking through the | |
| 24 | environment 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 | ||
| 32 | New environments continue advertising the legacy flag, accepting legacy metadata commands, and | |
| 33 | emitting the derived `linkedPullRequest` field for older clients. That hostless field includes only | |
| 34 | links in the thread project's own repository; cross-host and cross-repository links require the | |
| 35 | multi-link protocol. New clients accept snapshots that | |
| 36 | omit `pullRequests`. Retain the legacy wire fields, projection column, and replay support; this feature | |
| 37 | does not schedule their removal. Missing new capabilities must also override cached multi-link data | |
| 38 | after an environment downgrade. | |
| 39 | ||
| 21 | 40 | Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands |
| 22 | 41 | and events, so adding a provider should not require branches throughout the domain or clients. |
| 23 | 42 | See [provider constraints](./providers.md). |
docs/user/source-control.md+24| ... | ... | @@ -91,3 +91,27 @@ reopening a declined pull request. |
| 91 | 91 | remotes can require separate setup from the hosting provider's API access. |
| 92 | 92 | - **A review cannot load:** open it on the host website while resolving connectivity, permissions, |
| 93 | 93 | or rate limits. |
| 94 | ||
| 95 | ## Linked pull requests | |
| 96 | ||
| 97 | A thread can hold several pull requests, including reviews from another repository on the same host. | |
| 98 | Use **Link pull request** in the command palette or **Linked pull requests** panel, or right-click a | |
| 99 | pull request link in the conversation. Creating a pull request from Git actions links it automatically. | |
| 100 | Agents can link their pull requests with the `link_pull_request` tool. | |
| 101 | ||
| 102 | Use **Link this PR** in a branch-detected badge's tooltip to keep it with the thread. From a review | |
| 103 | on the Pull Requests page, **Link to thread** lets you search for an active thread. The review header | |
| 104 | also lists the threads that link to it, including archived threads, so you can return to their context. | |
| 105 | ||
| 106 | Thread badges show a stack's layer count or the current review number with a count of additional | |
| 107 | links. On mobile, the Git overview lists linked reviews and their stacks; tap a review to open it. | |
| 108 | Linking and unlinking are available in the web and desktop clients. | |
| 109 | ||
| 110 | The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its | |
| 111 | row menu. An unlinked stack layer stays out of later syncs. Open linked reviews refresh on the server; | |
| 112 | closed reviews refresh periodically so reopening one on the host is detected. Merged reviews refresh | |
| 113 | when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked | |
| 114 | review is terminal. An open or unsynced link keeps it active. | |
| 115 | ||
| 116 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked | |
| 117 | out from the matching organization and repository. |
packages/client-runtime/package.json+4| ... | ... | @@ -230,6 +230,10 @@ |
| 230 | 230 | "./work-log/scroll-anchor": { |
| 231 | 231 | "types": "./src/work-log/scrollAnchor.ts", |
| 232 | 232 | "default": "./src/work-log/scrollAnchor.ts" |
| 233 | }, | |
| 234 | "./thread-pull-request-compatibility": { | |
| 235 | "types": "./src/threadPullRequestCompatibility.ts", | |
| 236 | "default": "./src/threadPullRequestCompatibility.ts" | |
| 233 | 237 | } |
| 234 | 238 | }, |
| 235 | 239 | "scripts": { |
packages/client-runtime/src/operations/commands.ts+20| ... | ... | @@ -44,6 +44,8 @@ export type UnpinThreadInput = CommandInput<"thread.unpin">; |
| 44 | 44 | export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; |
| 45 | 45 | export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; |
| 46 | 46 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 47 | export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">; | |
| 48 | export type UnlinkThreadPullRequestInput = CommandInput<"thread.pull-request.unlink">; | |
| 47 | 49 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; |
| 48 | 50 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; |
| 49 | 51 | export type StartThreadTurnInput = CommandInput<"thread.turn.start">; |
| ... | ... | @@ -252,6 +254,24 @@ export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => Command |
| 252 | 254 | }); |
| 253 | 255 | }); |
| 254 | 256 | |
| 257 | export 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 | ||
| 266 | export 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 | ||
| 255 | 275 | export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( |
| 256 | 276 | "EnvironmentCommands.setThreadRuntimeMode", |
| 257 | 277 | )(function* (input) { |
packages/client-runtime/src/state/entities.test.ts+1| ... | ... | @@ -99,6 +99,7 @@ const THREAD_SHELL = { |
| 99 | 99 | archivedAt: null, |
| 100 | 100 | settledOverride: null, |
| 101 | 101 | settledAt: null, |
| 102 | pullRequests: [], | |
| 102 | 103 | session: null, |
| 103 | 104 | latestUserMessageAt: null, |
| 104 | 105 | hasPendingApprovals: false, |
packages/client-runtime/src/state/environmentHttpAuth.test.ts+1| ... | ... | @@ -84,6 +84,7 @@ const THREAD = { |
| 84 | 84 | runtimeMode: "full-access", |
| 85 | 85 | interactionMode: "default", |
| 86 | 86 | branch: null, |
| 87 | pullRequests: [], | |
| 87 | 88 | worktreePath: null, |
| 88 | 89 | latestTurn: null, |
| 89 | 90 | createdAt: "2026-09-04T00:00:00.000Z", |
packages/client-runtime/src/state/pullRequests.test.ts+89-38| ... | ... | @@ -41,6 +41,93 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 41 | 41 | }; |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | const 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 | ||
| 86 | it.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 | ||
| 44 | 131 | it.effect("refreshes pull request activity after a comment is updated", () => |
| 45 | 132 | Effect.scoped( |
| 46 | 133 | Effect.gen(function* () { |
| ... | ... | @@ -76,46 +163,10 @@ it.effect("refreshes pull request activity after a comment is updated", () => |
| 76 | 163 | commentBody = input.body; |
| 77 | 164 | }), |
| 78 | 165 | } 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); | |
| 117 | 167 | const reference = { |
| 118 | 168 | projectId: ProjectId.make("project-1"), |
| 169 | host: "github.example.com", | |
| 119 | 170 | repository: "acme/web", |
| 120 | 171 | number: 1, |
| 121 | 172 | } as const; |
packages/client-runtime/src/state/pullRequests.ts+27-2| ... | ... | @@ -60,6 +60,18 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>( |
| 60 | 60 | }); |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | /** The host-native stack a pull request belongs to; null where it is not stacked. */ | |
| 64 | export 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 | ||
| 63 | 75 | export function pullRequestDetailToVcsStatus( |
| 64 | 76 | detail: PullRequestDetail | PullRequestSummary, |
| 65 | 77 | ): NonNullable<VcsStatusResult["pr"]> { |
| ... | ... | @@ -97,6 +109,13 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 97 | 109 | }); |
| 98 | 110 | return { |
| 99 | 111 | refreshes, |
| 112 | linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, { | |
| 113 | label: "environment-data:pull-requests:linked-threads", | |
| 114 | tag: WS_METHODS.pullRequestsLinkedThreads, | |
| 115 | staleTimeMs: 0, | |
| 116 | refreshIntervalMs: 10_000, | |
| 117 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), | |
| 118 | }), | |
| 100 | 119 | list: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 101 | 120 | label: "environment-data:pull-requests:list", |
| 102 | 121 | tag: WS_METHODS.pullRequestsList, |
| ... | ... | @@ -159,6 +178,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 159 | 178 | JSON.stringify([ |
| 160 | 179 | environmentId, |
| 161 | 180 | input.projectId, |
| 181 | input.host?.toLowerCase() ?? null, | |
| 162 | 182 | input.repository, |
| 163 | 183 | input.number, |
| 164 | 184 | input.commit ?? null, |
| ... | ... | @@ -191,9 +211,14 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 191 | 211 | tag: WS_METHODS.pullRequestsUpdateComment, |
| 192 | 212 | scheduler: commandScheduler, |
| 193 | 213 | concurrency: serialPerEnvironment, |
| 194 | onSuccess: ({ environmentId, input: { projectId, repository, number } }, registry) => | |
| 214 | onSuccess: ({ environmentId, input: { projectId, host, repository, number } }, registry) => | |
| 195 | 215 | Effect.sync(() => |
| 196 | registry.refresh(activity({ environmentId, input: { projectId, repository, number } })), | |
| 216 | registry.refresh( | |
| 217 | activity({ | |
| 218 | environmentId, | |
| 219 | input: { projectId, ...(host === undefined ? {} : { host }), repository, number }, | |
| 220 | }), | |
| 221 | ), | |
| 197 | 222 | ), |
| 198 | 223 | }), |
| 199 | 224 | submitReview: createEnvironmentRpcCommand(runtime, { |
packages/client-runtime/src/state/shellReducer.test.ts+1| ... | ... | @@ -38,6 +38,7 @@ const stubThread = { |
| 38 | 38 | archivedAt: null, |
| 39 | 39 | settledOverride: null, |
| 40 | 40 | settledAt: null, |
| 41 | pullRequests: [], | |
| 41 | 42 | latestUserMessageAt: null, |
| 42 | 43 | hasPendingApprovals: false, |
| 43 | 44 | hasPendingUserInput: false, |
packages/client-runtime/src/state/threadCommands.ts+18| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | type CreateThreadInput, |
| 13 | 13 | type DeleteThreadInput, |
| 14 | 14 | type InterruptThreadTurnInput, |
| 15 | type LinkThreadPullRequestInput, | |
| 15 | 16 | type RespondToThreadApprovalInput, |
| 16 | 17 | type RespondToThreadUserInputInput, |
| 17 | 18 | type DismissThreadUserInputInput, |
| ... | ... | @@ -26,6 +27,7 @@ import { |
| 26 | 27 | type StartThreadTurnInput, |
| 27 | 28 | type StopThreadSessionInput, |
| 28 | 29 | type UnarchiveThreadInput, |
| 30 | type UnlinkThreadPullRequestInput, | |
| 29 | 31 | type UnpinThreadInput, |
| 30 | 32 | type UnsettleThreadInput, |
| 31 | 33 | type UnsnoozeThreadInput, |
| ... | ... | @@ -34,6 +36,7 @@ import { |
| 34 | 36 | createThread, |
| 35 | 37 | deleteThread, |
| 36 | 38 | interruptThreadTurn, |
| 39 | linkThreadPullRequest, | |
| 37 | 40 | respondToThreadApproval, |
| 38 | 41 | respondToThreadUserInput, |
| 39 | 42 | dismissThreadUserInput, |
| ... | ... | @@ -48,6 +51,7 @@ import { |
| 48 | 51 | startThreadTurn, |
| 49 | 52 | stopThreadSession, |
| 50 | 53 | unarchiveThread, |
| 54 | unlinkThreadPullRequest, | |
| 51 | 55 | unpinThread, |
| 52 | 56 | unsettleThread, |
| 53 | 57 | unsnoozeThread, |
| ... | ... | @@ -60,6 +64,7 @@ export type { |
| 60 | 64 | CreateThreadInput, |
| 61 | 65 | DeleteThreadInput, |
| 62 | 66 | InterruptThreadTurnInput, |
| 67 | LinkThreadPullRequestInput, | |
| 63 | 68 | RespondToThreadApprovalInput, |
| 64 | 69 | RespondToThreadUserInputInput, |
| 65 | 70 | DismissThreadUserInputInput, |
| ... | ... | @@ -74,6 +79,7 @@ export type { |
| 74 | 79 | StartThreadTurnInput, |
| 75 | 80 | StopThreadSessionInput, |
| 76 | 81 | UnarchiveThreadInput, |
| 82 | UnlinkThreadPullRequestInput, | |
| 77 | 83 | UnpinThreadInput, |
| 78 | 84 | UnsettleThreadInput, |
| 79 | 85 | UnsnoozeThreadInput, |
| ... | ... | @@ -168,6 +174,18 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 168 | 174 | scheduler, |
| 169 | 175 | concurrency, |
| 170 | 176 | }), |
| 177 | linkPullRequest: createEnvironmentCommand(runtime, { | |
| 178 | label: "environment-data:commands:thread:link-pull-request", | |
| 179 | execute: (input: LinkThreadPullRequestInput) => linkThreadPullRequest(input), | |
| 180 | scheduler, | |
| 181 | concurrency, | |
| 182 | }), | |
| 183 | unlinkPullRequest: createEnvironmentCommand(runtime, { | |
| 184 | label: "environment-data:commands:thread:unlink-pull-request", | |
| 185 | execute: (input: UnlinkThreadPullRequestInput) => unlinkThreadPullRequest(input), | |
| 186 | scheduler, | |
| 187 | concurrency, | |
| 188 | }), | |
| 171 | 189 | setRuntimeMode: createEnvironmentCommand(runtime, { |
| 172 | 190 | label: "environment-data:commands:thread:set-runtime-mode", |
| 173 | 191 | execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), |
packages/client-runtime/src/state/threadReducer.test.ts+119| ... | ... | @@ -37,6 +37,7 @@ const baseThread: OrchestrationThread = { |
| 37 | 37 | archivedAt: null, |
| 38 | 38 | settledOverride: null, |
| 39 | 39 | settledAt: null, |
| 40 | pullRequests: [], | |
| 40 | 41 | deletedAt: null, |
| 41 | 42 | messages: [], |
| 42 | 43 | proposedPlans: [], |
| ... | ... | @@ -406,6 +407,124 @@ describe("applyThreadDetailEvent", () => { |
| 406 | 407 | ); |
| 407 | 408 | }); |
| 408 | 409 | |
| 410 | describe("thread pull request links", () => { | |
| 411 | const link = { | |
| 412 | host: "github.com", | |
| 413 | repository: "pingdotgg/t3code", | |
| 414 | number: 42, | |
| 415 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 416 | source: "manual" as const, | |
| 417 | linkedAt: "2026-04-01T05:00:00.000Z", | |
| 418 | snapshot: null, | |
| 419 | stack: null, | |
| 420 | }; | |
| 421 | const key = { host: "github.com", repository: "pingdotgg/t3code", number: 42 }; | |
| 422 | const linkEvent = (sequence: number) => | |
| 423 | ({ | |
| 424 | ...baseEventFields, | |
| 425 | sequence, | |
| 426 | occurredAt: "2026-04-01T05:00:00.000Z", | |
| 427 | aggregateKind: "thread", | |
| 428 | aggregateId: ThreadId.make("thread-1"), | |
| 429 | type: "thread.pull-request-linked", | |
| 430 | payload: { | |
| 431 | threadId: ThreadId.make("thread-1"), | |
| 432 | link, | |
| 433 | updatedAt: "2026-04-01T05:00:00.000Z", | |
| 434 | }, | |
| 435 | }) as const; | |
| 436 | ||
| 437 | it("links without inventing a legacy route and replaces by key", () => { | |
| 438 | const linked = applyThreadDetailEvent(baseThread, linkEvent(5)); | |
| 439 | expect(linked.kind).toBe("updated"); | |
| 440 | if (linked.kind !== "updated") return; | |
| 441 | expect(linked.thread.pullRequests).toEqual([link]); | |
| 442 | expect(linked.thread.linkedPullRequest).toBeNull(); | |
| 443 | ||
| 444 | const relinked = applyThreadDetailEvent(linked.thread, { | |
| 445 | ...linkEvent(6), | |
| 446 | payload: { | |
| 447 | threadId: ThreadId.make("thread-1"), | |
| 448 | link: { ...link, host: "GitHub.com", source: "agent" }, | |
| 449 | updatedAt: "2026-04-01T06:00:00.000Z", | |
| 450 | }, | |
| 451 | }); | |
| 452 | if (relinked.kind !== "updated") throw new Error("expected update"); | |
| 453 | expect(relinked.thread.pullRequests).toHaveLength(1); | |
| 454 | expect(relinked.thread.pullRequests[0]?.source).toBe("agent"); | |
| 455 | }); | |
| 456 | ||
| 457 | it("syncs snapshot and stack onto the matching link only", () => { | |
| 458 | const linked = applyThreadDetailEvent(baseThread, linkEvent(5)); | |
| 459 | if (linked.kind !== "updated") throw new Error("expected update"); | |
| 460 | const snapshot = { | |
| 461 | state: "merged" as const, | |
| 462 | title: "Ship it", | |
| 463 | headBranch: "feature", | |
| 464 | baseBranch: "main", | |
| 465 | isDraft: false, | |
| 466 | updatedAt: "2026-04-02T00:00:00.000Z", | |
| 467 | syncedAt: "2026-04-02T00:01:00.000Z", | |
| 468 | }; | |
| 469 | const synced = applyThreadDetailEvent(linked.thread, { | |
| 470 | ...baseEventFields, | |
| 471 | sequence: 6, | |
| 472 | occurredAt: "2026-04-02T00:01:00.000Z", | |
| 473 | aggregateKind: "thread", | |
| 474 | aggregateId: ThreadId.make("thread-1"), | |
| 475 | type: "thread.pull-request-synced", | |
| 476 | payload: { | |
| 477 | threadId: ThreadId.make("thread-1"), | |
| 478 | ...key, | |
| 479 | snapshot, | |
| 480 | stack: null, | |
| 481 | updatedAt: "2026-04-02T00:01:00.000Z", | |
| 482 | }, | |
| 483 | }); | |
| 484 | if (synced.kind !== "updated") throw new Error("expected update"); | |
| 485 | expect(synced.thread.pullRequests[0]?.snapshot).toEqual(snapshot); | |
| 486 | ||
| 487 | const unknown = applyThreadDetailEvent(synced.thread, { | |
| 488 | ...baseEventFields, | |
| 489 | sequence: 7, | |
| 490 | occurredAt: "2026-04-02T00:02:00.000Z", | |
| 491 | aggregateKind: "thread", | |
| 492 | aggregateId: ThreadId.make("thread-1"), | |
| 493 | type: "thread.pull-request-synced", | |
| 494 | payload: { | |
| 495 | threadId: ThreadId.make("thread-1"), | |
| 496 | ...key, | |
| 497 | number: 99, | |
| 498 | snapshot, | |
| 499 | stack: null, | |
| 500 | updatedAt: "2026-04-02T00:02:00.000Z", | |
| 501 | }, | |
| 502 | }); | |
| 503 | expect(unknown.kind).toBe("unchanged"); | |
| 504 | }); | |
| 505 | ||
| 506 | it("unlinks and clears the compat field", () => { | |
| 507 | const linked = applyThreadDetailEvent(baseThread, linkEvent(5)); | |
| 508 | if (linked.kind !== "updated") throw new Error("expected update"); | |
| 509 | const unlinked = applyThreadDetailEvent(linked.thread, { | |
| 510 | ...baseEventFields, | |
| 511 | sequence: 6, | |
| 512 | occurredAt: "2026-04-01T06:00:00.000Z", | |
| 513 | aggregateKind: "thread", | |
| 514 | aggregateId: ThreadId.make("thread-1"), | |
| 515 | type: "thread.pull-request-unlinked", | |
| 516 | payload: { | |
| 517 | threadId: ThreadId.make("thread-1"), | |
| 518 | ...key, | |
| 519 | updatedAt: "2026-04-01T06:00:00.000Z", | |
| 520 | }, | |
| 521 | }); | |
| 522 | if (unlinked.kind !== "updated") throw new Error("expected update"); | |
| 523 | expect(unlinked.thread.pullRequests).toEqual([]); | |
| 524 | expect(unlinked.thread.linkedPullRequest).toBeNull(); | |
| 525 | }); | |
| 526 | }); | |
| 527 | ||
| 409 | 528 | describe("thread.message-sent", () => { |
| 410 | 529 | it("appends a new message", () => { |
| 411 | 530 | const result = applyThreadDetailEvent(baseThread, { |
packages/client-runtime/src/state/threadReducer.ts+60| ... | ... | @@ -10,8 +10,10 @@ import type { |
| 10 | 10 | OrchestrationSession, |
| 11 | 11 | OrchestrationThread, |
| 12 | 12 | OrchestrationThreadActivity, |
| 13 | ThreadPullRequestLink, | |
| 13 | 14 | TurnId, |
| 14 | 15 | } from "@t3tools/contracts"; |
| 16 | import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequests"; | |
| 15 | 17 | import { isImportedAgentSessionMessageId } from "@t3tools/contracts"; |
| 16 | 18 | import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; |
| 17 | 19 | |
| ... | ... | @@ -20,6 +22,29 @@ export type ThreadDetailReducerResult = |
| 20 | 22 | | { readonly kind: "deleted" } |
| 21 | 23 | | { readonly kind: "unchanged" }; |
| 22 | 24 | |
| 25 | /** Keep only a legacy route supplied by the server; detail events cannot resolve project hosts. */ | |
| 26 | function 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 | ||
| 23 | 48 | const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>( |
| 24 | 49 | O.mapInput(O.String, (p) => p.createdAt), |
| 25 | 50 | O.mapInput(O.String, (p) => p.id), |
| ... | ... | @@ -109,6 +134,7 @@ export function applyThreadDetailEvent( |
| 109 | 134 | snoozedUntil: null, |
| 110 | 135 | snoozedAt: null, |
| 111 | 136 | deletedAt: null, |
| 137 | pullRequests: [], | |
| 112 | 138 | messages: [], |
| 113 | 139 | proposedPlans: [], |
| 114 | 140 | activities: [], |
| ... | ... | @@ -253,6 +279,40 @@ export function applyThreadDetailEvent( |
| 253 | 279 | }, |
| 254 | 280 | }; |
| 255 | 281 | |
| 282 | case "thread.pull-request-linked": { | |
| 283 | const link = event.payload.link; | |
| 284 | const others = thread.pullRequests.filter( | |
| 285 | (existing) => !threadPullRequestKeysEqual(existing, link), | |
| 286 | ); | |
| 287 | return withPullRequests(thread, [...others, link], event.payload.updatedAt); | |
| 288 | } | |
| 289 | ||
| 290 | case "thread.pull-request-unlinked": | |
| 291 | return withPullRequests( | |
| 292 | thread, | |
| 293 | thread.pullRequests.filter( | |
| 294 | (existing) => !threadPullRequestKeysEqual(existing, event.payload), | |
| 295 | ), | |
| 296 | event.payload.updatedAt, | |
| 297 | ); | |
| 298 | ||
| 299 | case "thread.pull-request-synced": { | |
| 300 | if ( | |
| 301 | !thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, event.payload)) | |
| 302 | ) { | |
| 303 | return { kind: "unchanged" }; | |
| 304 | } | |
| 305 | return withPullRequests( | |
| 306 | thread, | |
| 307 | thread.pullRequests.map((existing) => | |
| 308 | threadPullRequestKeysEqual(existing, event.payload) | |
| 309 | ? { ...existing, snapshot: event.payload.snapshot, stack: event.payload.stack } | |
| 310 | : existing, | |
| 311 | ), | |
| 312 | event.payload.updatedAt, | |
| 313 | ); | |
| 314 | } | |
| 315 | ||
| 256 | 316 | case "thread.runtime-mode-set": |
| 257 | 317 | return { |
| 258 | 318 | kind: "updated", |
packages/client-runtime/src/state/threads-atoms.test.ts+1| ... | ... | @@ -65,6 +65,7 @@ const THREAD: OrchestrationThread = { |
| 65 | 65 | runtimeMode: "full-access", |
| 66 | 66 | interactionMode: "default", |
| 67 | 67 | branch: "main", |
| 68 | pullRequests: [], | |
| 68 | 69 | worktreePath: null, |
| 69 | 70 | latestTurn: null, |
| 70 | 71 | createdAt: "2026-04-01T00:00:00.000Z", |
packages/client-runtime/src/state/threads-pagination.test.ts+1| ... | ... | @@ -104,6 +104,7 @@ const BASE_THREAD: OrchestrationThread = { |
| 104 | 104 | archivedAt: null, |
| 105 | 105 | settledOverride: null, |
| 106 | 106 | settledAt: null, |
| 107 | pullRequests: [], | |
| 107 | 108 | deletedAt: null, |
| 108 | 109 | messages: [RECENT_MESSAGE], |
| 109 | 110 | proposedPlans: [], |
packages/client-runtime/src/state/threads-sync.test.ts+1| ... | ... | @@ -75,6 +75,7 @@ const BASE_THREAD: OrchestrationThread = { |
| 75 | 75 | archivedAt: null, |
| 76 | 76 | settledOverride: null, |
| 77 | 77 | settledAt: null, |
| 78 | pullRequests: [], | |
| 78 | 79 | deletedAt: null, |
| 79 | 80 | messages: [], |
| 80 | 81 | proposedPlans: [], |
packages/client-runtime/src/state/vcsAction.test.ts+11-2| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | 1 | import { |
| 2 | 2 | EnvironmentId, |
| 3 | ThreadId, | |
| 3 | 4 | WS_METHODS, |
| 4 | 5 | type GitActionProgressEvent, |
| 6 | type GitRunStackedActionInput, | |
| 5 | 7 | type GitRunStackedActionResult, |
| 6 | 8 | } from "@t3tools/contracts"; |
| 7 | 9 | import { describe, expect, it } from "@effect/vitest"; |
| ... | ... | @@ -589,9 +591,10 @@ describe("vcsActionState", () => { |
| 589 | 591 | successfulActionId, |
| 590 | 592 | ); |
| 591 | 593 | const failedTransportActionId = createVcsActionTransportId(targetKey, failedActionId); |
| 594 | const rpcInputs = new Array<GitRunStackedActionInput>(); | |
| 592 | 595 | 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) | |
| 595 | 598 | ? Stream.make( |
| 596 | 599 | progress({ |
| 597 | 600 | kind: "action_finished", |
| ... | ... | @@ -652,16 +655,22 @@ describe("vcsActionState", () => { |
| 652 | 655 | const state = vcsRefsCacheStateAtom({ environmentId }); |
| 653 | 656 | |
| 654 | 657 | expect(registry.get(state).revision).toBe(0); |
| 658 | const threadId = ThreadId.make("thread-stacked-action"); | |
| 655 | 659 | const successfulResult = yield* Effect.promise(() => |
| 656 | 660 | manager.runStackedAction(targetKey).run(registry, { |
| 657 | 661 | actionId: successfulActionId, |
| 658 | 662 | action, |
| 663 | threadId, | |
| 659 | 664 | }), |
| 660 | 665 | ); |
| 661 | 666 | |
| 662 | 667 | expect(AsyncResult.isSuccess(successfulResult)).toBe(true); |
| 663 | 668 | expect(registry.get(state).revision).toBe(1); |
| 664 | 669 | expect(removed).toEqual([`${environmentId}:*`]); |
| 670 | // The server links a created pull request to this thread, so the id must ride along. | |
| 671 | expect(rpcInputs).toEqual([ | |
| 672 | { actionId: successfulTransportActionId, cwd, action, threadId }, | |
| 673 | ]); | |
| 665 | 674 | |
| 666 | 675 | const failedResult = yield* Effect.promise(() => |
| 667 | 676 | manager.runStackedAction(targetKey).run(registry, { |
packages/client-runtime/src/state/vcsAction.ts+4| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | type GitRunStackedActionInput, |
| 7 | 7 | type GitRunStackedActionResult, |
| 8 | 8 | GitStackedAction, |
| 9 | type ThreadId, | |
| 9 | 10 | WS_METHODS, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import * as Cause from "effect/Cause"; |
| ... | ... | @@ -76,6 +77,8 @@ export interface RunVcsStackedActionInput { |
| 76 | 77 | readonly commitMessage?: string; |
| 77 | 78 | readonly featureBranch?: boolean; |
| 78 | 79 | readonly filePaths?: ReadonlyArray<string>; |
| 80 | /** The thread the action runs beside; the server links a pull request it creates to it. */ | |
| 81 | readonly threadId?: ThreadId; | |
| 79 | 82 | readonly onProgress?: (event: GitActionProgressEvent) => void; |
| 80 | 83 | } |
| 81 | 84 | |
| ... | ... | @@ -463,6 +466,7 @@ export function createVcsActionManager<R, E>( |
| 463 | 466 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), |
| 464 | 467 | ...(input.featureBranch ? { featureBranch: true } : {}), |
| 465 | 468 | ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), |
| 469 | ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), | |
| 466 | 470 | }; |
| 467 | 471 | return consumeVcsActionProgress( |
| 468 | 472 | runStreamInEnvironment( |
packages/client-runtime/src/threadPullRequestCompatibility.test.ts created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | planThreadPullRequestMutation, | |
| 5 | threadPullRequestLinkMode, | |
| 6 | } from "./threadPullRequestCompatibility.ts"; | |
| 7 | ||
| 8 | const reference = { | |
| 9 | host: "github.example", | |
| 10 | repository: "team/repo", | |
| 11 | number: 7, | |
| 12 | url: "https://github.example/team/repo/pull/7", | |
| 13 | }; | |
| 14 | const input = { | |
| 15 | threadId: ThreadId.make("thread"), | |
| 16 | reference, | |
| 17 | legacyProjectId: ProjectId.make("exact-checkout"), | |
| 18 | linked: true, | |
| 19 | }; | |
| 20 | ||
| 21 | describe("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 @@ |
| 1 | import type { | |
| 2 | ExecutionEnvironmentCapabilities, | |
| 3 | ProjectId, | |
| 4 | ThreadId, | |
| 5 | ThreadPullRequestKey, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | type LinkingCapabilities = Pick< | |
| 9 | ExecutionEnvironmentCapabilities, | |
| 10 | "threadPullRequests" | "threadPullRequestLinking" | |
| 11 | >; | |
| 12 | ||
| 13 | /** Capability negotiation keeps both single-link and multi-link environments usable. */ | |
| 14 | export function threadPullRequestLinkMode(capabilities: LinkingCapabilities | null | undefined) { | |
| 15 | return capabilities?.threadPullRequests === true | |
| 16 | ? "multiple" | |
| 17 | : capabilities?.threadPullRequestLinking === true | |
| 18 | ? "single" | |
| 19 | : "unsupported"; | |
| 20 | } | |
| 21 | ||
| 22 | export function planThreadPullRequestMutation({ | |
| 23 | capabilities, | |
| 24 | threadId, | |
| 25 | reference, | |
| 26 | legacyProjectId, | |
| 27 | legacyRepository, | |
| 28 | linked, | |
| 29 | }: { | |
| 30 | capabilities: LinkingCapabilities | null | undefined; | |
| 31 | threadId: ThreadId; | |
| 32 | reference: ThreadPullRequestKey & { readonly url: string }; | |
| 33 | /** Older servers need the exact repository checkout; same-host routing is not available. */ | |
| 34 | legacyProjectId: ProjectId | null; | |
| 35 | /** Provider selector for legacy APIs, which omit Azure organization and project paths. */ | |
| 36 | legacyRepository?: string | undefined; | |
| 37 | linked: boolean; | |
| 38 | }) { | |
| 39 | switch (threadPullRequestLinkMode(capabilities)) { | |
| 40 | case "multiple": | |
| 41 | return linked | |
| 42 | ? { | |
| 43 | type: "thread.pull-request.link" as const, | |
| 44 | input: { threadId, ...reference, source: "manual" as const }, | |
| 45 | } | |
| 46 | : { | |
| 47 | type: "thread.pull-request.unlink" as const, | |
| 48 | input: { | |
| 49 | threadId, | |
| 50 | host: reference.host, | |
| 51 | repository: reference.repository, | |
| 52 | number: reference.number, | |
| 53 | }, | |
| 54 | }; | |
| 55 | case "single": | |
| 56 | if (linked && legacyProjectId === null) return null; | |
| 57 | return { | |
| 58 | type: "thread.meta.update" as const, | |
| 59 | input: { | |
| 60 | threadId, | |
| 61 | linkedPullRequest: | |
| 62 | linked && legacyProjectId !== null | |
| 63 | ? { | |
| 64 | projectId: legacyProjectId, | |
| 65 | repository: legacyRepository ?? reference.repository, | |
| 66 | number: reference.number, | |
| 67 | url: reference.url, | |
| 68 | } | |
| 69 | : null, | |
| 70 | }, | |
| 71 | }; | |
| 72 | case "unsupported": | |
| 73 | return null; | |
| 74 | } | |
| 75 | } |
packages/contracts/src/environment.ts+6-1| ... | ... | @@ -123,8 +123,13 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 123 | 123 | /** Server understands regenerateTitle on thread.meta.update. Absent on |
| 124 | 124 | older servers, so clients hide the action instead of sending it. */ |
| 125 | 125 | threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), |
| 126 | /** Server persists a pull request reference on thread.meta.update. */ | |
| 126 | /** Server supports legacy linkedPullRequest updates through thread.meta.update. | |
| 127 | Independent of threadPullRequests; servers supporting both advertise both. */ | |
| 127 | 128 | threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), |
| 129 | /** Server understands thread.pull-request.link / .unlink, exposes `pullRequests` on | |
| 130 | threads, and routes PullRequestRef.host across projects on the same host. Same | |
| 131 | version-skew contract as threadSettlement. */ | |
| 132 | threadPullRequests: Schema.optionalKey(Schema.Boolean), | |
| 128 | 133 | /** The update path clients should offer for this server. Absent on |
| 129 | 134 | servers that must be relaunched manually (dev checkouts, Windows |
| 130 | 135 | foreground runs, pre-update servers). */ |
packages/contracts/src/git.ts+2| ... | ... | @@ -119,6 +119,8 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 119 | 119 | filePaths: Schema.optional( |
| 120 | 120 | Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), |
| 121 | 121 | ), |
| 122 | /** The thread the action runs beside; a pull request it creates is linked to it. */ | |
| 123 | threadId: Schema.optional(ThreadId), | |
| 122 | 124 | }); |
| 123 | 125 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 124 | 126 |
packages/contracts/src/orchestration.test.ts+161-11| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | OrchestrationMessage, |
| 27 | 27 | ThreadMessageSentPayload, |
| 28 | 28 | ThreadMetaUpdatedPayload, |
| 29 | ThreadLinkedPullRequest, | |
| 29 | 30 | ThreadTurnStartCommand, |
| 30 | 31 | ThreadCreatedPayload, |
| 31 | 32 | ThreadTurnDiff, |
| ... | ... | @@ -682,6 +683,116 @@ it.effect("defaults settled fields when decoding historical thread data", () => |
| 682 | 683 | assert.strictEqual(thread.settledAt, null); |
| 683 | 684 | assert.strictEqual(shell.settledOverride, null); |
| 684 | 685 | assert.strictEqual(shell.settledAt, null); |
| 686 | // Pre-link servers omit the array entirely. | |
| 687 | assert.deepStrictEqual(thread.pullRequests, []); | |
| 688 | assert.deepStrictEqual(shell.pullRequests, []); | |
| 689 | ||
| 690 | const legacyLink = { | |
| 691 | projectId: ProjectId.make("project-1"), | |
| 692 | repository: "acme/web", | |
| 693 | number: 42, | |
| 694 | url: "https://github.com/acme/web/pull/42", | |
| 695 | }; | |
| 696 | const oldServerShell = yield* decodeOrchestrationThreadShell({ | |
| 697 | ...common, | |
| 698 | latestUserMessageAt: null, | |
| 699 | hasPendingApprovals: false, | |
| 700 | hasPendingUserInput: false, | |
| 701 | hasActionableProposedPlan: false, | |
| 702 | linkedPullRequest: legacyLink, | |
| 703 | }); | |
| 704 | assert.deepStrictEqual(oldServerShell.pullRequests, []); | |
| 705 | assert.deepStrictEqual(oldServerShell.linkedPullRequest, legacyLink); | |
| 706 | ||
| 707 | // A decoder from before the array must still read its single-link field | |
| 708 | // after a new server encodes the expanded snapshot. | |
| 709 | const oldLinkFields = Schema.Struct({ | |
| 710 | linkedPullRequest: Schema.optional(ThreadLinkedPullRequest), | |
| 711 | }); | |
| 712 | const newServerWire = yield* Schema.encodeEffect(OrchestrationThreadShell)({ | |
| 713 | ...oldServerShell, | |
| 714 | pullRequests: [ | |
| 715 | { | |
| 716 | host: "github.com", | |
| 717 | repository: legacyLink.repository, | |
| 718 | number: legacyLink.number, | |
| 719 | url: legacyLink.url, | |
| 720 | source: "agent", | |
| 721 | linkedAt: common.createdAt, | |
| 722 | snapshot: null, | |
| 723 | stack: null, | |
| 724 | }, | |
| 725 | ], | |
| 726 | }); | |
| 727 | const oldClientFields = yield* Schema.decodeUnknownEffect(oldLinkFields)(newServerWire); | |
| 728 | assert.deepStrictEqual(oldClientFields.linkedPullRequest, legacyLink); | |
| 729 | }), | |
| 730 | ); | |
| 731 | ||
| 732 | it.effect("decodes thread pull request links with snapshot and stack", () => | |
| 733 | Effect.gen(function* () { | |
| 734 | const shell = yield* decodeOrchestrationThreadShell({ | |
| 735 | id: "thread-1", | |
| 736 | projectId: "project-1", | |
| 737 | title: "Thread", | |
| 738 | modelSelection: { provider: "codex", model: "gpt-5-codex" }, | |
| 739 | runtimeMode: "full-access", | |
| 740 | branch: "feature/stack-2", | |
| 741 | worktreePath: null, | |
| 742 | latestTurn: null, | |
| 743 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 744 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 745 | archivedAt: null, | |
| 746 | session: null, | |
| 747 | latestUserMessageAt: null, | |
| 748 | hasPendingApprovals: false, | |
| 749 | hasPendingUserInput: false, | |
| 750 | hasActionableProposedPlan: false, | |
| 751 | pullRequests: [ | |
| 752 | { | |
| 753 | host: "github.com", | |
| 754 | repository: "pingdotgg/t3code", | |
| 755 | number: 42, | |
| 756 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 757 | source: "agent", | |
| 758 | linkedAt: "2026-01-01T00:00:00.000Z", | |
| 759 | snapshot: null, | |
| 760 | stack: null, | |
| 761 | }, | |
| 762 | { | |
| 763 | host: "github.com", | |
| 764 | repository: "pingdotgg/t3code", | |
| 765 | number: 43, | |
| 766 | url: "https://github.com/pingdotgg/t3code/pull/43", | |
| 767 | source: "stack", | |
| 768 | linkedAt: "2026-01-01T00:01:00.000Z", | |
| 769 | snapshot: { | |
| 770 | state: "open", | |
| 771 | title: "Layer two", | |
| 772 | headBranch: "feature/stack-2", | |
| 773 | baseBranch: "feature/stack-1", | |
| 774 | isDraft: false, | |
| 775 | updatedAt: "2026-01-01T00:02:00.000Z", | |
| 776 | syncedAt: "2026-01-01T00:03:00.000Z", | |
| 777 | }, | |
| 778 | stack: { | |
| 779 | kind: "native", | |
| 780 | id: "7", | |
| 781 | number: 3, | |
| 782 | url: "https://github.com/pingdotgg/t3code/stacks/3", | |
| 783 | base: "main", | |
| 784 | layers: [ | |
| 785 | { number: 42, headBranch: "feature/stack-1", state: "open" }, | |
| 786 | { number: 43, headBranch: "feature/stack-2", state: "open" }, | |
| 787 | ], | |
| 788 | }, | |
| 789 | }, | |
| 790 | ], | |
| 791 | }); | |
| 792 | ||
| 793 | assert.strictEqual(shell.pullRequests.length, 2); | |
| 794 | assert.strictEqual(shell.pullRequests[1]?.stack?.layers.length, 2); | |
| 795 | assert.strictEqual(shell.pullRequests[1]?.snapshot?.state, "open"); | |
| 685 | 796 | }), |
| 686 | 797 | ); |
| 687 | 798 | |
| ... | ... | @@ -966,25 +1077,64 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () => |
| 966 | 1077 | }), |
| 967 | 1078 | ); |
| 968 | 1079 | |
| 969 | it.effect("accepts a linked pull request in thread.meta.update", () => | |
| 1080 | it.effect("accepts thread.pull-request.link and .unlink commands", () => | |
| 970 | 1081 | 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", | |
| 973 | 1087 | repository: "pingdotgg/t3code", |
| 974 | 1088 | number: 42, |
| 975 | 1089 | 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", | |
| 980 | 1101 | threadId: "thread-1", |
| 981 | linkedPullRequest, | |
| 1102 | host: "github.com", | |
| 1103 | repository: "pingdotgg/t3code", | |
| 1104 | number: 42, | |
| 982 | 1105 | }); |
| 1106 | assert.strictEqual(unlink.type, "thread.pull-request.unlink"); | |
| 1107 | }), | |
| 1108 | ); | |
| 983 | 1109 | |
| 984 | assert.strictEqual(parsed.type, "thread.meta.update"); | |
| 985 | if (parsed.type === "thread.meta.update") { | |
| 986 | assert.deepStrictEqual(parsed.linkedPullRequest, linkedPullRequest); | |
| 1110 | it.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}.`); | |
| 987 | 1136 | } |
| 1137 | assert.strictEqual(event.payload.linkedPullRequest?.number, 42); | |
| 988 | 1138 | }), |
| 989 | 1139 | ); |
| 990 | 1140 |
packages/contracts/src/orchestration.ts+168| ... | ... | @@ -23,6 +23,13 @@ import { |
| 23 | 23 | TurnId, |
| 24 | 24 | } from "./baseSchemas.ts"; |
| 25 | 25 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 26 | import { | |
| 27 | PullRequestActor, | |
| 28 | PullRequestChecksState, | |
| 29 | PullRequestMergeability, | |
| 30 | PullRequestReviewDecision, | |
| 31 | PullRequestState, | |
| 32 | } from "./pullRequest.ts"; | |
| 26 | 33 | |
| 27 | 34 | export const ORCHESTRATION_WS_METHODS = { |
| 28 | 35 | dispatchCommand: "orchestration.dispatchCommand", |
| ... | ... | @@ -599,6 +606,11 @@ export const ThreadTitleRegeneration = Schema.Struct({ |
| 599 | 606 | }); |
| 600 | 607 | export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; |
| 601 | 608 | |
| 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 | */ | |
| 602 | 614 | export const ThreadLinkedPullRequest = Schema.Struct({ |
| 603 | 615 | projectId: ProjectId, |
| 604 | 616 | repository: TrimmedNonEmptyString, |
| ... | ... | @@ -607,6 +619,80 @@ export const ThreadLinkedPullRequest = Schema.Struct({ |
| 607 | 619 | }); |
| 608 | 620 | export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type; |
| 609 | 621 | |
| 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. */ | |
| 625 | export const ThreadPullRequestLinkSource = Schema.Literals([ | |
| 626 | "manual", | |
| 627 | "created", | |
| 628 | "agent", | |
| 629 | "stack", | |
| 630 | "stack-dismissed", | |
| 631 | ]); | |
| 632 | export 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 | */ | |
| 639 | export 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 | }); | |
| 657 | export type ThreadPullRequestSnapshot = typeof ThreadPullRequestSnapshot.Type; | |
| 658 | ||
| 659 | export const ThreadPullRequestStackLayer = Schema.Struct({ | |
| 660 | number: PositiveInt, | |
| 661 | headBranch: TrimmedNonEmptyString, | |
| 662 | state: PullRequestState, | |
| 663 | }); | |
| 664 | export type ThreadPullRequestStackLayer = typeof ThreadPullRequestStackLayer.Type; | |
| 665 | ||
| 666 | /** A host-native stack the pull request belongs to. Layers run bottom to top. */ | |
| 667 | export 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 | }); | |
| 675 | export 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. */ | |
| 679 | export const ThreadPullRequestKey = Schema.Struct({ | |
| 680 | host: TrimmedNonEmptyString, | |
| 681 | repository: TrimmedNonEmptyString, | |
| 682 | number: PositiveInt, | |
| 683 | }); | |
| 684 | export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; | |
| 685 | ||
| 686 | export 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 | }); | |
| 694 | export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; | |
| 695 | ||
| 610 | 696 | export const OrchestrationThread = Schema.Struct({ |
| 611 | 697 | id: ThreadId, |
| 612 | 698 | projectId: ProjectId, |
| ... | ... | @@ -619,6 +705,10 @@ export const OrchestrationThread = Schema.Struct({ |
| 619 | 705 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 620 | 706 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 621 | 707 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 708 | // Optional so payloads from pre-link servers still decode. | |
| 709 | pullRequests: Schema.Array(ThreadPullRequestLink).pipe( | |
| 710 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 711 | ), | |
| 622 | 712 | branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 623 | 713 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 624 | 714 | createdAt: IsoDateTime, |
| ... | ... | @@ -701,6 +791,9 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 701 | 791 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 702 | 792 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 703 | 793 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 794 | pullRequests: Schema.Array(ThreadPullRequestLink).pipe( | |
| 795 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 796 | ), | |
| 704 | 797 | branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 705 | 798 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 706 | 799 | createdAt: IsoDateTime, |
| ... | ... | @@ -1050,6 +1143,22 @@ const ThreadMetaUpdateCommand = Schema.Struct({ |
| 1050 | 1143 | ), |
| 1051 | 1144 | ); |
| 1052 | 1145 | |
| 1146 | const 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 | ||
| 1155 | const ThreadPullRequestUnlinkCommand = Schema.Struct({ | |
| 1156 | type: Schema.Literal("thread.pull-request.unlink"), | |
| 1157 | commandId: CommandId, | |
| 1158 | threadId: ThreadId, | |
| 1159 | ...ThreadPullRequestKey.fields, | |
| 1160 | }); | |
| 1161 | ||
| 1053 | 1162 | const ThreadRuntimeModeSetCommand = Schema.Struct({ |
| 1054 | 1163 | type: Schema.Literal("thread.runtime-mode.set"), |
| 1055 | 1164 | commandId: CommandId, |
| ... | ... | @@ -1208,6 +1317,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 1208 | 1317 | ThreadPinReorderCommand, |
| 1209 | 1318 | ThreadActiveReorderCommand, |
| 1210 | 1319 | ThreadMetaUpdateCommand, |
| 1320 | ThreadPullRequestLinkCommand, | |
| 1321 | ThreadPullRequestUnlinkCommand, | |
| 1211 | 1322 | ThreadRuntimeModeSetCommand, |
| 1212 | 1323 | ThreadInteractionModeSetCommand, |
| 1213 | 1324 | ThreadTurnStartCommand, |
| ... | ... | @@ -1238,6 +1349,8 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 1238 | 1349 | ThreadPinReorderCommand, |
| 1239 | 1350 | ThreadActiveReorderCommand, |
| 1240 | 1351 | ThreadMetaUpdateCommand, |
| 1352 | ThreadPullRequestLinkCommand, | |
| 1353 | ThreadPullRequestUnlinkCommand, | |
| 1241 | 1354 | ThreadRuntimeModeSetCommand, |
| 1242 | 1355 | ThreadInteractionModeSetCommand, |
| 1243 | 1356 | ClientThreadTurnStartCommand, |
| ... | ... | @@ -1354,8 +1467,19 @@ const ThreadPullRequestSyncCommand = Schema.Struct({ |
| 1354 | 1467 | linkedPullRequest: Schema.optional(ThreadLinkedPullRequest), |
| 1355 | 1468 | }); |
| 1356 | 1469 | |
| 1470 | const 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 | ||
| 1357 | 1479 | const InternalOrchestrationCommand = Schema.Union([ |
| 1358 | 1480 | ThreadAutoSettleCommand, |
| 1481 | ThreadPullRequestSyncCommand, | |
| 1482 | ThreadPullRequestLinkSyncCommand, | |
| 1359 | 1483 | ThreadSessionSetCommand, |
| 1360 | 1484 | ThreadMessageAssistantDeltaCommand, |
| 1361 | 1485 | ThreadMessageAssistantCompleteCommand, |
| ... | ... | @@ -1366,6 +1490,7 @@ const InternalOrchestrationCommand = Schema.Union([ |
| 1366 | 1490 | ThreadRevertCompleteCommand, |
| 1367 | 1491 | ThreadTitleRegenerationCompleteCommand, |
| 1368 | 1492 | ThreadPullRequestSyncCommand, |
| 1493 | ThreadPullRequestLinkSyncCommand, | |
| 1369 | 1494 | ]); |
| 1370 | 1495 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; |
| 1371 | 1496 | |
| ... | ... | @@ -1391,6 +1516,9 @@ export const OrchestrationEventType = Schema.Literals([ |
| 1391 | 1516 | "thread.unpinned", |
| 1392 | 1517 | "thread.pin-reordered", |
| 1393 | 1518 | "thread.meta-updated", |
| 1519 | "thread.pull-request-linked", | |
| 1520 | "thread.pull-request-unlinked", | |
| 1521 | "thread.pull-request-synced", | |
| 1394 | 1522 | "thread.runtime-mode-set", |
| 1395 | 1523 | "thread.interaction-mode-set", |
| 1396 | 1524 | "thread.message-sent", |
| ... | ... | @@ -1541,11 +1669,36 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 1541 | 1669 | modelSelection: Schema.optional(ModelSelection), |
| 1542 | 1670 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1543 | 1671 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1672 | // No longer produced; kept so persisted events from before | |
| 1673 | // thread.pull-request-linked still decode and replay into the link table. | |
| 1544 | 1674 | linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 1545 | 1675 | branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), |
| 1546 | 1676 | updatedAt: IsoDateTime, |
| 1547 | 1677 | }); |
| 1548 | 1678 | |
| 1679 | export const ThreadPullRequestLinkedPayload = Schema.Struct({ | |
| 1680 | threadId: ThreadId, | |
| 1681 | link: ThreadPullRequestLink, | |
| 1682 | updatedAt: IsoDateTime, | |
| 1683 | }); | |
| 1684 | export type ThreadPullRequestLinkedPayload = typeof ThreadPullRequestLinkedPayload.Type; | |
| 1685 | ||
| 1686 | export const ThreadPullRequestUnlinkedPayload = Schema.Struct({ | |
| 1687 | threadId: ThreadId, | |
| 1688 | ...ThreadPullRequestKey.fields, | |
| 1689 | updatedAt: IsoDateTime, | |
| 1690 | }); | |
| 1691 | export type ThreadPullRequestUnlinkedPayload = typeof ThreadPullRequestUnlinkedPayload.Type; | |
| 1692 | ||
| 1693 | export const ThreadPullRequestSyncedPayload = Schema.Struct({ | |
| 1694 | threadId: ThreadId, | |
| 1695 | ...ThreadPullRequestKey.fields, | |
| 1696 | snapshot: ThreadPullRequestSnapshot, | |
| 1697 | stack: Schema.NullOr(ThreadPullRequestStack), | |
| 1698 | updatedAt: IsoDateTime, | |
| 1699 | }); | |
| 1700 | export type ThreadPullRequestSyncedPayload = typeof ThreadPullRequestSyncedPayload.Type; | |
| 1701 | ||
| 1549 | 1702 | export const ThreadRuntimeModeSetPayload = Schema.Struct({ |
| 1550 | 1703 | threadId: ThreadId, |
| 1551 | 1704 | runtimeMode: RuntimeMode, |
| ... | ... | @@ -1759,6 +1912,21 @@ export const OrchestrationEvent = Schema.Union([ |
| 1759 | 1912 | type: Schema.Literal("thread.meta-updated"), |
| 1760 | 1913 | payload: ThreadMetaUpdatedPayload, |
| 1761 | 1914 | }), |
| 1915 | Schema.Struct({ | |
| 1916 | ...EventBaseFields, | |
| 1917 | type: Schema.Literal("thread.pull-request-linked"), | |
| 1918 | payload: ThreadPullRequestLinkedPayload, | |
| 1919 | }), | |
| 1920 | Schema.Struct({ | |
| 1921 | ...EventBaseFields, | |
| 1922 | type: Schema.Literal("thread.pull-request-unlinked"), | |
| 1923 | payload: ThreadPullRequestUnlinkedPayload, | |
| 1924 | }), | |
| 1925 | Schema.Struct({ | |
| 1926 | ...EventBaseFields, | |
| 1927 | type: Schema.Literal("thread.pull-request-synced"), | |
| 1928 | payload: ThreadPullRequestSyncedPayload, | |
| 1929 | }), | |
| 1762 | 1930 | Schema.Struct({ |
| 1763 | 1931 | ...EventBaseFields, |
| 1764 | 1932 | type: Schema.Literal("thread.runtime-mode-set"), |
packages/contracts/src/previewAutomation.ts+21-4| ... | ... | @@ -633,14 +633,31 @@ export const PreviewAutomationResponse = Schema.Struct({ |
| 633 | 633 | }); |
| 634 | 634 | export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; |
| 635 | 635 | |
| 636 | const McpCapabilityErrorFields = { | |
| 637 | environmentId: EnvironmentId, | |
| 638 | threadId: ThreadId, | |
| 639 | providerSessionId: TrimmedNonEmptyString, | |
| 640 | providerInstanceId: ProviderInstanceId, | |
| 641 | }; | |
| 642 | ||
| 636 | 643 | export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()( |
| 637 | 644 | "PreviewAutomationUnavailableError", |
| 638 | 645 | { |
| 639 | 646 | 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. */ | |
| 656 | export class McpCapabilityUnavailableError extends Schema.TaggedError<McpCapabilityUnavailableError>()( | |
| 657 | "McpCapabilityUnavailableError", | |
| 658 | { | |
| 659 | capability: TrimmedNonEmptyString, | |
| 660 | ...McpCapabilityErrorFields, | |
| 644 | 661 | }, |
| 645 | 662 | ) { |
| 646 | 663 | override get message(): string { |
packages/contracts/src/pullRequest.ts+49| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | NonNegativeInt, |
| 8 | 8 | PositiveInt, |
| 9 | 9 | ProjectId, |
| 10 | ThreadId, | |
| 10 | 11 | TrimmedNonEmptyString, |
| 11 | 12 | } from "./baseSchemas.ts"; |
| 12 | 13 | import { SourceControlProviderKind } from "./sourceControl.ts"; |
| ... | ... | @@ -412,6 +413,11 @@ export const PullRequestCapabilities = Schema.Struct({ |
| 412 | 413 | * what every server before this one was. |
| 413 | 414 | */ |
| 414 | 415 | edit: Schema.optional(PullRequestEditCapabilities), |
| 416 | /** | |
| 417 | * The host keeps stacks of change requests as objects of its own, so a linked thread can show | |
| 418 | * the stack the host shows. Absent means chains are only ever inferred from base branches. | |
| 419 | */ | |
| 420 | stacks: Schema.optional(Schema.Boolean), | |
| 415 | 421 | /** |
| 416 | 422 | * The repository's labels can be listed, and one put on a change request or taken off it. |
| 417 | 423 | * Optional for the same reason `edit` is: a server that says nothing about labels has no way |
| ... | ... | @@ -615,13 +621,33 @@ export const PullRequestListResult = Schema.Struct({ |
| 615 | 621 | }); |
| 616 | 622 | export type PullRequestListResult = typeof PullRequestListResult.Type; |
| 617 | 623 | |
| 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 | */ | |
| 618 | 631 | export const PullRequestRef = Schema.Struct({ |
| 619 | 632 | projectId: ProjectId, |
| 633 | host: Schema.optional(TrimmedNonEmptyString), | |
| 620 | 634 | repository: TrimmedNonEmptyString, |
| 621 | 635 | number: PositiveInt, |
| 622 | 636 | }); |
| 623 | 637 | export type PullRequestRef = typeof PullRequestRef.Type; |
| 624 | 638 | |
| 639 | export 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 | }); | |
| 649 | export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type; | |
| 650 | ||
| 625 | 651 | /** |
| 626 | 652 | * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar |
| 627 | 653 | * status check never loads permissions, repository settings, checks, or base comparison data. |
| ... | ... | @@ -641,9 +667,32 @@ export const PullRequestSummary = Schema.Struct({ |
| 641 | 667 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 642 | 668 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 643 | 669 | updatedAt: IsoDateTime, |
| 670 | author: Schema.optional(Schema.NullOr(PullRequestActor)), | |
| 671 | additions: Schema.optional(NonNegativeInt), | |
| 672 | deletions: Schema.optional(NonNegativeInt), | |
| 673 | changedFiles: Schema.optional(NonNegativeInt), | |
| 674 | reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)), | |
| 675 | checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)), | |
| 676 | mergeability: Schema.optional(PullRequestMergeability), | |
| 644 | 677 | }); |
| 645 | 678 | export type PullRequestSummary = typeof PullRequestSummary.Type; |
| 646 | 679 | |
| 680 | /** The host-native stack a pull request belongs to, in the thread link's shape. */ | |
| 681 | export 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 | }); | |
| 694 | export type PullRequestStack = typeof PullRequestStack.Type; | |
| 695 | ||
| 647 | 696 | /** |
| 648 | 697 | * One row's line counts, read after the listing rather than inside it. On GitHub the pair is |
| 649 | 698 | * 40-60% of the wall clock of the search that answers the whole page — measured over twelve |
packages/contracts/src/rpc.ts+18| ... | ... | @@ -115,6 +115,8 @@ import { |
| 115 | 115 | PullRequestOperationError, |
| 116 | 116 | PullRequestReactionInput, |
| 117 | 117 | PullRequestRef, |
| 118 | PullRequestStack, | |
| 119 | PullRequestLinkedThreadsResult, | |
| 118 | 120 | PullRequestSummary, |
| 119 | 121 | PullRequestReviewerCandidateList, |
| 120 | 122 | PullRequestReviewerRequestInput, |
| ... | ... | @@ -344,6 +346,8 @@ export const WS_METHODS = { |
| 344 | 346 | pullRequestsList: "pullRequests.list", |
| 345 | 347 | pullRequestsListStats: "pullRequests.listStats", |
| 346 | 348 | pullRequestsSummary: "pullRequests.summary", |
| 349 | pullRequestsStack: "pullRequests.stack", | |
| 350 | pullRequestsLinkedThreads: "pullRequests.linkedThreads", | |
| 347 | 351 | pullRequestsDetail: "pullRequests.detail", |
| 348 | 352 | pullRequestsActivity: "pullRequests.activity", |
| 349 | 353 | pullRequestsThreadComments: "pullRequests.threadComments", |
| ... | ... | @@ -648,6 +652,18 @@ const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { |
| 648 | 652 | error: PullRequestRpcError, |
| 649 | 653 | }); |
| 650 | 654 | |
| 655 | const WsPullRequestsStackRpc = Rpc.make(WS_METHODS.pullRequestsStack, { | |
| 656 | payload: PullRequestRef, | |
| 657 | success: Schema.NullOr(PullRequestStack), | |
| 658 | error: PullRequestRpcError, | |
| 659 | }); | |
| 660 | ||
| 661 | const WsPullRequestsLinkedThreadsRpc = Rpc.make(WS_METHODS.pullRequestsLinkedThreads, { | |
| 662 | payload: PullRequestRef, | |
| 663 | success: PullRequestLinkedThreadsResult, | |
| 664 | error: PullRequestRpcError, | |
| 665 | }); | |
| 666 | ||
| 651 | 667 | const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { |
| 652 | 668 | payload: PullRequestRef, |
| 653 | 669 | success: PullRequestDetail, |
| ... | ... | @@ -1221,6 +1237,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 1221 | 1237 | WsPullRequestsListRpc, |
| 1222 | 1238 | WsPullRequestsListStatsRpc, |
| 1223 | 1239 | WsPullRequestsSummaryRpc, |
| 1240 | WsPullRequestsStackRpc, | |
| 1241 | WsPullRequestsLinkedThreadsRpc, | |
| 1224 | 1242 | WsPullRequestsDetailRpc, |
| 1225 | 1243 | WsPullRequestsActivityRpc, |
| 1226 | 1244 | WsPullRequestsThreadCommentsRpc, |
packages/shared/package.json+8| ... | ... | @@ -175,6 +175,14 @@ |
| 175 | 175 | "types": "./src/threadReference.ts", |
| 176 | 176 | "import": "./src/threadReference.ts" |
| 177 | 177 | }, |
| 178 | "./threadPullRequests": { | |
| 179 | "types": "./src/threadPullRequests.ts", | |
| 180 | "import": "./src/threadPullRequests.ts" | |
| 181 | }, | |
| 182 | "./changeRequestUrl": { | |
| 183 | "types": "./src/changeRequestUrl.ts", | |
| 184 | "import": "./src/changeRequestUrl.ts" | |
| 185 | }, | |
| 178 | 186 | "./composerTrigger": { |
| 179 | 187 | "types": "./src/composerTrigger.ts", |
| 180 | 188 | "import": "./src/composerTrigger.ts" |
packages/shared/src/changeRequestUrl.test.ts created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | changeRequestUrlFor, | |
| 5 | parseChangeRequestUrl, | |
| 6 | pullRequestCandidateUrlFromReferenceAutolink, | |
| 7 | siblingPullRequestUrl, | |
| 8 | } from "./changeRequestUrl.ts"; | |
| 9 | ||
| 10 | describe("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 | ||
| 92 | describe("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 | ||
| 127 | describe("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 @@ |
| 1 | import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; | |
| 2 | import { 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 | */ | |
| 12 | export 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. */ | |
| 19 | function 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 | */ | |
| 38 | export 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 | ||
| 72 | function 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. */ | |
| 81 | export 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. */ | |
| 102 | export 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 | */ | |
| 142 | export 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. */ | |
| 166 | export 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. */ | |
| 182 | export 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 | ||
| 198 | export function siblingPullRequestUrl(url: string, number: number): string | null { | |
| 199 | const reference = parseChangeRequestUrl(url); | |
| 200 | if (reference === null || !Number.isSafeInteger(number) || number < 1) return null; | |
| 201 | const sibling = new URL(url); | |
| 202 | const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( | |
| 203 | sibling.pathname.slice(reference.repository.length + 1), | |
| 204 | )?.[1]; | |
| 205 | if (route === undefined) return null; | |
| 206 | sibling.pathname = `/${reference.repository}/${route}/${number}`; | |
| 207 | sibling.search = ""; | |
| 208 | sibling.hash = ""; | |
| 209 | return sibling.toString(); | |
| 210 | } |
packages/shared/src/sourceControl.test.ts+32| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | sourceControlRepositorySelector, | |
| 4 | 5 | detectSourceControlProviderFromRemoteUrl, |
| 5 | 6 | getChangeRequestTerminologyForKind, |
| 6 | 7 | isSshRemoteUrl, |
| ... | ... | @@ -160,3 +161,34 @@ describe("isSshRemoteUrl", () => { |
| 160 | 161 | expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false); |
| 161 | 162 | }); |
| 162 | 163 | }); |
| 164 | ||
| 165 | it("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 | ||
| 178 | it("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 | ||
| 186 | it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { | |
| 187 | const selector = sourceControlRepositorySelector({ | |
| 188 | provider: "gitlab", | |
| 189 | displayName: "group/subgroup/service", | |
| 190 | owner: "group", | |
| 191 | name: "service", | |
| 192 | }); | |
| 193 | expect(selector).toBe("group/subgroup/service"); | |
| 194 | }); |
packages/shared/src/sourceControl.ts+45-1| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | RepositoryIdentity, | |
| 3 | SourceControlProviderInfo, | |
| 4 | SourceControlProviderKind, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | export interface ChangeRequestPresentation { |
| 4 | 8 | readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; |
| ... | ... | @@ -234,3 +238,43 @@ export function detectSourceControlProviderFromRemoteUrl( |
| 234 | 238 | baseUrl: toBaseUrl(host), |
| 235 | 239 | }; |
| 236 | 240 | } |
| 241 | ||
| 242 | /** | |
| 243 | * The provider-native repository selector. `displayName` is the full path below the host, which | |
| 244 | * is what nested GitLab groups need; owner/name is the two-segment fallback for identities | |
| 245 | * recorded before that field existed. | |
| 246 | * | |
| 247 | * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and | |
| 248 | * takes the organisation and project from the checkout it detects — so the recorded | |
| 249 | * `org/project/_git/repo` path is refused outright and the whole repository reads as | |
| 250 | * unavailable. Its name is the last segment, which is what this hands over. | |
| 251 | * | |
| 252 | * One function because everything downstream is keyed by what it answers: the rows' own | |
| 253 | * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. | |
| 254 | */ | |
| 255 | export 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 | ||
| 270 | export 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 @@ |
| 1 | import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | legacyLinkedPullRequestOf, | |
| 6 | legacyThreadPullRequestKey, | |
| 7 | resolveThreadCurrentPullRequest, | |
| 8 | resolveThreadPullRequestChains, | |
| 9 | resolveThreadPullRequestBadge, | |
| 10 | threadPullRequestKeysEqual, | |
| 11 | } from "./threadPullRequests.ts"; | |
| 12 | ||
| 13 | function 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 | ||
| 26 | function 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 | ||
| 43 | describe("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 | ||
| 60 | describe("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 | ||
| 118 | describe("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 | ||
| 204 | describe("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 | ||
| 245 | describe("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 @@ |
| 1 | import type { | |
| 2 | RepositoryIdentity, | |
| 3 | SourceControlProviderKind, | |
| 4 | ThreadLinkedPullRequest, | |
| 5 | ThreadPullRequestKey, | |
| 6 | ThreadPullRequestLink, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | import { pullRequestHostOf } from "@t3tools/contracts"; | |
| 10 | import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; | |
| 11 | import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; | |
| 12 | ||
| 13 | /** Normalize stored link identity, including Azure's SSH and browser host aliases. */ | |
| 14 | export 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. */ | |
| 27 | export 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. */ | |
| 54 | export function threadPullRequestKeysEqual( | |
| 55 | left: ThreadPullRequestKey, | |
| 56 | right: ThreadPullRequestKey, | |
| 57 | ): boolean { | |
| 58 | return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); | |
| 59 | } | |
| 60 | ||
| 61 | export 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. */ | |
| 68 | export function visibleThreadPullRequests( | |
| 69 | links: ReadonlyArray<ThreadPullRequestLink>, | |
| 70 | ): ReadonlyArray<ThreadPullRequestLink> { | |
| 71 | return links.filter((link) => link.source !== "stack-dismissed"); | |
| 72 | } | |
| 73 | ||
| 74 | function 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 | ||
| 80 | function 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. */ | |
| 87 | export 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 | */ | |
| 100 | export 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. */ | |
| 130 | export 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. */ | |
| 139 | export 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 | ||
| 173 | export 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 | */ | |
| 184 | export 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 | ||
| 246 | export 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. */ | |
| 255 | export function resolveThreadPullRequestBadge( | |
| 256 | pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined, | |
| 257 | ): ThreadPullRequestBadge | null { | |
| 258 | const visible = visibleThreadPullRequests(pullRequests ?? []); | |
| 259 | if (visible.length === 0) return null; | |
| 260 | const chains = resolveThreadPullRequestChains(visible); | |
| 261 | if (visible.length > 1 && chains.length === 1) { | |
| 262 | const states = visible.map((link) => link.snapshot?.state ?? "open"); | |
| 263 | const state = states.includes("open") | |
| 264 | ? "open" | |
| 265 | : states.every((entry) => entry === "merged") | |
| 266 | ? "merged" | |
| 267 | : "closed"; | |
| 268 | return { kind: "stack", layers: visible.length, state }; | |
| 269 | } | |
| 270 | return { kind: "pull-request", others: visible.length - 1 }; | |
| 271 | } |
packages/shared/src/threadReference.test.ts+66| ... | ... | @@ -1,13 +1,26 @@ |
| 1 | import type { ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | 4 | import { resolveThreadReferenceCopyTarget } from "./threadReference.ts"; |
| 4 | 5 | |
| 6 | const 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 | ||
| 5 | 17 | describe("resolveThreadReferenceCopyTarget", () => { |
| 6 | 18 | it("does not copy another reference while the open panel URL is unavailable", () => { |
| 7 | 19 | expect( |
| 8 | 20 | resolveThreadReferenceCopyTarget({ |
| 9 | 21 | threadId: "thread-1", |
| 10 | 22 | openPanelPullRequestUrl: null, |
| 23 | pullRequests: [crossRepositoryPullRequest], | |
| 11 | 24 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 12 | 25 | }), |
| 13 | 26 | ).toBeNull(); |
| ... | ... | @@ -18,6 +31,7 @@ describe("resolveThreadReferenceCopyTarget", () => { |
| 18 | 31 | resolveThreadReferenceCopyTarget({ |
| 19 | 32 | threadId: "thread-1", |
| 20 | 33 | openPanelPullRequestUrl: "https://github.com/t3/pr/14", |
| 34 | pullRequests: [crossRepositoryPullRequest], | |
| 21 | 35 | linkedPullRequestUrl: "https://github.com/t3/pr/12", |
| 22 | 36 | }), |
| 23 | 37 | ).toMatchObject({ |
| ... | ... | @@ -27,6 +41,58 @@ describe("resolveThreadReferenceCopyTarget", () => { |
| 27 | 41 | }); |
| 28 | 42 | }); |
| 29 | 43 | |
| 44 | it.each([null, "https://github.com/t3/pr/12"])( | |
| 45 | "copies a native cross-repository link before the fallback URL %s", | |
| 46 | (linkedPullRequestUrl) => { | |
| 47 | expect( | |
| 48 | resolveThreadReferenceCopyTarget({ | |
| 49 | threadId: "thread-1", | |
| 50 | pullRequests: [crossRepositoryPullRequest], | |
| 51 | linkedPullRequestUrl, | |
| 52 | }), | |
| 53 | ).toMatchObject({ | |
| 54 | kind: "pull-request", | |
| 55 | value: crossRepositoryPullRequest.url, | |
| 56 | }); | |
| 57 | }, | |
| 58 | ); | |
| 59 | ||
| 60 | it("copies the highest open stack layer instead of the first link", () => { | |
| 61 | const stack: NonNullable<ThreadPullRequestLink["stack"]> = { | |
| 62 | kind: "native", | |
| 63 | id: "stack-1", | |
| 64 | number: 1, | |
| 65 | url: "https://github.com/other/repo/stacks/1", | |
| 66 | base: "main", | |
| 67 | layers: [ | |
| 68 | { number: 42, headBranch: "first", state: "open" }, | |
| 69 | { number: 43, headBranch: "second", state: "open" }, | |
| 70 | ], | |
| 71 | }; | |
| 72 | const top = { | |
| 73 | ...crossRepositoryPullRequest, | |
| 74 | number: 43, | |
| 75 | url: "https://github.com/other/repo/pull/43", | |
| 76 | stack, | |
| 77 | }; | |
| 78 | expect( | |
| 79 | resolveThreadReferenceCopyTarget({ | |
| 80 | threadId: "thread-1", | |
| 81 | pullRequests: [{ ...crossRepositoryPullRequest, stack }, top], | |
| 82 | }), | |
| 83 | ).toMatchObject({ kind: "pull-request", value: top.url }); | |
| 84 | }); | |
| 85 | ||
| 86 | it("uses the fallback URL when native links are dismissed", () => { | |
| 87 | expect( | |
| 88 | resolveThreadReferenceCopyTarget({ | |
| 89 | threadId: "thread-1", | |
| 90 | pullRequests: [{ ...crossRepositoryPullRequest, source: "stack-dismissed" }], | |
| 91 | linkedPullRequestUrl: "https://github.com/t3/pr/12", | |
| 92 | }), | |
| 93 | ).toMatchObject({ kind: "pull-request", value: "https://github.com/t3/pr/12" }); | |
| 94 | }); | |
| 95 | ||
| 30 | 96 | it("uses the thread pull request when no panel is open", () => { |
| 31 | 97 | expect( |
| 32 | 98 | resolveThreadReferenceCopyTarget({ |
packages/shared/src/threadReference.ts+9-1| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import type { ThreadPullRequestLink } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts"; | |
| 4 | ||
| 1 | 5 | export interface ThreadReferenceCopyTarget { |
| 2 | 6 | readonly kind: "pull-request" | "thread"; |
| 3 | 7 | readonly value: string; |
| ... | ... | @@ -10,10 +14,14 @@ export function resolveThreadReferenceCopyTarget(input: { |
| 10 | 14 | readonly threadId: string; |
| 11 | 15 | /** Undefined means no PR panel; null means its URL is not available yet. */ |
| 12 | 16 | readonly openPanelPullRequestUrl?: string | null | undefined; |
| 17 | readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined; | |
| 13 | 18 | readonly linkedPullRequestUrl?: string | null; |
| 14 | 19 | }): ThreadReferenceCopyTarget | null { |
| 15 | 20 | if (input.openPanelPullRequestUrl === null) return null; |
| 16 | const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl; | |
| 21 | const pullRequestUrl = | |
| 22 | input.openPanelPullRequestUrl ?? | |
| 23 | resolveThreadCurrentPullRequestLink(input.pullRequests ?? [])?.url ?? | |
| 24 | input.linkedPullRequestUrl; | |
| 17 | 25 | return pullRequestUrl |
| 18 | 26 | ? { |
| 19 | 27 | kind: "pull-request", |