From 40df5e1cf2b5ef3d040c95669da60aca3b86c90e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 14:50:12 -0700 Subject: [PATCH 01/13] feat(pull-requests): link any number of pull requests to a thread A thread held one linkedPullRequest, set only by right-clicking a PR URL in chat, with live state polled per client. That breaks for the way threads are used: a PR merges and the thread continues into a new one, a thread produces a stack, several threads fix one PR, a frontend thread opens a backend PR. Links are now a host-level relation, (host, repository, number), stored in their own projection table with a source (manual, created, agent, stack) and a synced snapshot. A server reactor reads each distinct PR once per minute while open and active, every 15 minutes once settled, and never once terminal; unchanged reads dispatch nothing. GitHub-native stacks are read through the Stacks preview API and their other layers auto-linked; other hosts get chains derived from base branches. Settlement now needs every linked PR merged. Agents get link/unlink/list tools on the T3 MCP server, create_pr links its result, and the web app gains a thread-scoped Pull requests surface, a link dialog, and a sidebar badge that reads as a stack or as the current PR plus how many others. Co-Authored-By: Claude Code --- .../archive/archivedThreadList.test.ts | 1 + .../src/features/home/homeListItems.test.ts | 1 + .../src/features/home/homeThreadList.test.ts | 1 + .../src/features/threads/threadListV2.test.ts | 1 + apps/mobile/src/lib/threadActivity.test.ts | 1 + .../state/use-selected-thread-git-actions.ts | 2 + apps/mobile/src/state/use-thread-pr.ts | 11 + apps/mobile/src/state/use-thread-selection.ts | 1 + .../OrchestrationEngineHarness.integration.ts | 8 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/environment/ServerEnvironment.test.ts | 3 +- .../src/environment/ServerEnvironment.ts | 2 +- .../src/git/linkCreatedPullRequest.test.ts | 230 +++++++ apps/server/src/git/linkCreatedPullRequest.ts | 99 +++ apps/server/src/mcp/McpHttpServer.test.ts | 47 ++ apps/server/src/mcp/McpHttpServer.ts | 11 +- .../src/mcp/McpInvocationContext.test.ts | 27 + apps/server/src/mcp/McpInvocationContext.ts | 45 +- apps/server/src/mcp/McpProviderSession.ts | 2 + .../server/src/mcp/McpSessionRegistry.test.ts | 28 + apps/server/src/mcp/McpSessionRegistry.ts | 12 +- .../toolkits/pullRequests/handlers.test.ts | 408 +++++++++++++ .../src/mcp/toolkits/pullRequests/handlers.ts | 270 ++++++++ .../src/mcp/toolkits/pullRequests/tools.ts | 190 ++++++ .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/OrchestrationReactor.test.ts | 12 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProjectionPipeline.test.ts | 237 +++++++ .../Layers/ProjectionPipeline.ts | 118 ++++ .../Layers/ProjectionSnapshotQuery.test.ts | 99 ++- .../Layers/ProjectionSnapshotQuery.ts | 577 ++++++++++++------ .../PullRequestSyncReactor.test.ts | 522 ++++++++++++++++ .../orchestration/PullRequestSyncReactor.ts | 313 ++++++++++ apps/server/src/orchestration/Schemas.ts | 6 + .../ThreadSettlementPolicy.test.ts | 81 ++- .../orchestration/ThreadSettlementPolicy.ts | 24 +- .../ThreadSettlementReactor.test.ts | 237 ++++--- .../orchestration/ThreadSettlementReactor.ts | 52 +- .../orchestration/commandInvariants.test.ts | 2 + .../src/orchestration/decider.pinned.test.ts | 1 + .../decider.pullRequests.test.ts | 297 +++++++++ .../src/orchestration/decider.settled.test.ts | 1 + .../src/orchestration/decider.snoozed.test.ts | 1 + .../decider.titleRegeneration.test.ts | 1 + apps/server/src/orchestration/decider.ts | 145 ++++- .../projector.pullRequests.test.ts | 360 +++++++++++ .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 215 ++++++- .../Layers/ProjectionRepositories.test.ts | 119 ++++ .../Layers/ProjectionThreadPullRequests.ts | 194 ++++++ apps/server/src/persistence/Migrations.ts | 2 + .../046_ProjectionThreadPullRequests.test.ts | 154 +++++ .../046_ProjectionThreadPullRequests.ts | 103 ++++ .../Services/ProjectionThreadPullRequests.ts | 84 +++ .../src/provider/Layers/CodexAdapter.ts | 1 + .../provider/Layers/CodexSessionRuntime.ts | 13 +- .../provider/Layers/ProviderService.test.ts | 37 +- .../src/provider/Layers/ProviderService.ts | 36 +- .../Layers/ProviderSessionReaper.test.ts | 1 + .../pullRequest/GitHubPullRequestCli.test.ts | 229 ++++++- .../src/pullRequest/GitHubPullRequestCli.ts | 128 ++-- .../GitHubPullRequestProvider.test.ts | 61 ++ .../pullRequest/GitHubPullRequestProvider.ts | 16 +- .../src/pullRequest/PullRequestProvider.ts | 38 ++ .../pullRequest/PullRequestService.test.ts | 86 +++ .../src/pullRequest/PullRequestService.ts | 154 ++++- .../pullRequest/gitHubPullRequestJson.test.ts | 79 +++ .../src/pullRequest/gitHubPullRequestJson.ts | 66 ++ .../src/relay/AgentAwarenessRelay.test.ts | 3 + apps/server/src/server.test.ts | 9 + apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 71 ++- apps/web/src/components/ChatMarkdown.test.tsx | 3 +- apps/web/src/components/ChatMarkdown.tsx | 90 ++- .../ChatMarkdown.workspace-images.test.tsx | 3 +- .../web/src/components/ChatView.logic.test.ts | 2 + apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/ChatView.tsx | 16 + .../components/CommandPalette.logic.test.ts | 1 + apps/web/src/components/CommandPalette.tsx | 34 +- apps/web/src/components/GitActionsControl.tsx | 3 + apps/web/src/components/LegacySidebar.tsx | 1 + .../src/components/RightPanelTabs.test.tsx | 2 + apps/web/src/components/RightPanelTabs.tsx | 31 + apps/web/src/components/Sidebar.logic.test.ts | 1 + apps/web/src/components/Sidebar.tsx | 72 ++- .../src/components/ThreadStatusIndicators.tsx | 154 ++++- .../LinkPullRequestDialog.logic.test.ts | 81 +++ .../pullRequest/LinkPullRequestDialog.tsx | 281 +++++++++ .../pullRequest/PullRequestDetailPanel.tsx | 68 ++- .../pullRequest/PullRequestStackMap.tsx | 86 +++ .../pullRequest/ThreadPullRequestsPanel.tsx | 282 +++++++++ .../pullRequest/pullRequestListLines.test.ts | 76 +++ .../pullRequest/pullRequestListLines.ts | 49 ++ apps/web/src/lib/openPullRequestLink.test.ts | 48 ++ apps/web/src/lib/openPullRequestLink.ts | 103 +--- apps/web/src/lib/threadSort.test.ts | 1 + apps/web/src/rightPanelStore.ts | 5 + apps/web/src/routes/_chat.pull-requests.tsx | 2 + apps/web/src/state/pullRequests.ts | 2 + apps/web/src/state/sourceControlActions.ts | 2 + apps/web/src/worktreeCleanup.test.ts | 1 + docs/internals/glossary.md | 12 + docs/user/source-control.md | 22 + docs/user/thread-sidebar.md | 6 +- .../client-runtime/src/operations/commands.ts | 20 + .../client-runtime/src/state/entities.test.ts | 1 + .../client-runtime/src/state/pullRequests.ts | 12 + .../src/state/shellReducer.test.ts | 1 + .../src/state/threadCommands.ts | 18 + .../src/state/threadReducer.test.ts | 124 ++++ .../client-runtime/src/state/threadReducer.ts | 57 ++ .../src/state/threads-pagination.test.ts | 1 + .../src/state/threads-sync.test.ts | 1 + .../src/state/vcsAction.test.ts | 13 +- .../client-runtime/src/state/vcsAction.ts | 4 + packages/contracts/src/environment.ts | 7 +- packages/contracts/src/git.ts | 2 + packages/contracts/src/orchestration.test.ts | 131 +++- packages/contracts/src/orchestration.ts | 166 ++++- packages/contracts/src/previewAutomation.ts | 25 +- packages/contracts/src/pullRequest.ts | 38 ++ packages/contracts/src/rpc.ts | 9 + packages/shared/package.json | 8 + packages/shared/src/changeRequestUrl.test.ts | 72 +++ packages/shared/src/changeRequestUrl.ts | 75 +++ .../shared/src/threadPullRequests.test.ts | 167 +++++ packages/shared/src/threadPullRequests.ts | 173 ++++++ 128 files changed, 8389 insertions(+), 671 deletions(-) create mode 100644 apps/server/src/git/linkCreatedPullRequest.test.ts create mode 100644 apps/server/src/git/linkCreatedPullRequest.ts create mode 100644 apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/pullRequests/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/pullRequests/tools.ts create mode 100644 apps/server/src/orchestration/PullRequestSyncReactor.test.ts create mode 100644 apps/server/src/orchestration/PullRequestSyncReactor.ts create mode 100644 apps/server/src/orchestration/decider.pullRequests.test.ts create mode 100644 apps/server/src/orchestration/projector.pullRequests.test.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts create mode 100644 apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.test.ts create mode 100644 apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.ts create mode 100644 apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts create mode 100644 apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestStackMap.tsx create mode 100644 apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestListLines.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestListLines.ts create mode 100644 packages/shared/src/changeRequestUrl.test.ts create mode 100644 packages/shared/src/changeRequestUrl.ts create mode 100644 packages/shared/src/threadPullRequests.test.ts create mode 100644 packages/shared/src/threadPullRequests.ts diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 697d13e7c47..474bd4481ca 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -31,6 +31,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index c5a9f2c6bbc..eb1722c73fd 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 60d3ab2c867..5bb419a901a 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -36,6 +36,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 48edf390600..6deef9e4b16 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -37,6 +37,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 47bfe6755db..5f14c434032 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -217,6 +217,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710..e66f690428e 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() { ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + // A pull request the action opens is linked to the thread it ran beside. + threadId: thread.id, }); if (AsyncResult.isFailure(result)) { return result; diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 8e4eb27963a..cca6562737e 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -67,6 +67,16 @@ export function useThreadPr( }) : null, ); + // The compat field carries no host; the link it was derived from does, and a link from + // another repository needs it to be routed through a project on that host. + const linkedHost = + thread.linkedPullRequest == null + ? undefined + : thread.pullRequests?.find( + (link) => + link.number === thread.linkedPullRequest?.number && + link.repository.toLowerCase() === thread.linkedPullRequest?.repository.toLowerCase(), + )?.host; const linkedPullRequest = useEnvironmentQuery( thread.linkedPullRequest == null ? null @@ -74,6 +84,7 @@ export function useThreadPr( environmentId: thread.environmentId, input: { projectId: thread.linkedPullRequest.projectId, + ...(linkedHost === undefined ? {} : { host: linkedHost }), repository: thread.linkedPullRequest.repository, number: thread.linkedPullRequest.number, }, diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index e0e87d609d5..3313d8a2fe4 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -55,6 +55,7 @@ function threadDetailToShell( branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, + pullRequests: thread.pullRequests, latestTurn: thread.latestTurn, createdAt: thread.createdAt, updatedAt: thread.updatedAt, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index be3e21dcbfd..8aebe5f31d7 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -66,6 +66,7 @@ import { } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -386,6 +387,13 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { + start: () => Effect.void, + drain: Effect.void, + requestSync: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index c1eb8b15764..2ba9210a51d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -56,6 +56,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 91895fd5dcf..41334c48782 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -167,7 +167,8 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); - expect(second.capabilities.threadPullRequestLinking).toBe(true); + expect(second.capabilities.threadPullRequests).toBe(true); + expect(second.capabilities.threadPullRequestLinking).toBeUndefined(); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index f21a410a96c..ccacff69859 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -221,7 +221,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, - threadPullRequestLinking: true, + threadPullRequests: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts new file mode 100644 index 00000000000..2c33d82792b --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -0,0 +1,230 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type GitRunStackedActionResult, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test")); + +const project: OrchestrationProjectShell = { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + repositoryIdentity: { + canonicalKey: "github.acme.test/platform/api", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.acme.test:Platform/API.git", + }, + provider: "github", + displayName: "Platform/API", + owner: "Platform", + name: "API", + }, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +const thread: OrchestrationThreadShell = { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}; + +function prResult(pr: GitRunStackedActionResult["pr"]): Pick { + return { pr }; +} + +const makeDependencies = ( + dispatch: OrchestrationEngineShape["dispatch"], + threadShell: OrchestrationThreadShell | null = thread, +) => + Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + ); + +const recordingDispatch = Effect.fn("recordingDispatch")(function* () { + const commands = yield* Ref.make>([]); + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Ref.update(commands, (recorded) => [...recorded, command]).pipe(Effect.as({ sequence: 1 })); + return { commands, dispatch }; +}); + +describe("createdPullRequestKey", () => { + it("reads host and repository from the URL when it is recognisable", () => { + expect( + createdPullRequestKey( + prResult({ + status: "created", + number: 12, + url: "https://github.com/Other/Fork/pull/12", + }), + project, + ), + ).toEqual({ + host: "github.com", + repository: "other/fork", + number: 12, + url: "https://github.com/Other/Fork/pull/12", + }); + }); + + it("falls back to the project's host and repository for an unreadable URL", () => { + expect( + createdPullRequestKey( + prResult({ status: "opened_existing", number: 3, url: "https://ghe.internal/x/3" }), + project, + ), + ).toEqual({ + host: "github.acme.test", + repository: "platform/api", + number: 3, + url: "https://ghe.internal/x/3", + }); + expect( + createdPullRequestKey( + prResult({ status: "created", number: 3, url: "https://ghe.internal/x/3" }), + undefined, + ), + ).toBeNull(); + }); + + it("yields nothing when no pull request came out of the action", () => { + expect( + createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project), + ).toBeNull(); + expect( + createdPullRequestKey( + prResult({ status: "created", url: "https://github.com/a/b/pull/1" }), + project, + ), + ).toBeNull(); + expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull(); + }); +}); + +describe("linkCreatedPullRequest", () => { + it.effect("links a created pull request to the thread with source created", () => + Effect.gen(function* () { + const { commands, dispatch } = yield* recordingDispatch(); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ + status: "created", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }), + commandId, + }).pipe(Effect.provide(makeDependencies(dispatch))); + + expect(yield* Ref.get(commands)).toEqual([ + { + type: "thread.pull-request.link", + commandId: "server:pr-created-link:test", + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "created", + }, + ]); + }), + ); + + it.effect("dispatches nothing when the action produced no pull request", () => + Effect.gen(function* () { + const { commands, dispatch } = yield* recordingDispatch(); + const dependencies = makeDependencies(dispatch); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ status: "skipped_not_requested" }), + commandId, + }).pipe(Effect.provide(dependencies)); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }), + commandId, + }).pipe(Effect.provide(dependencies)); + + expect(yield* Ref.get(commands)).toEqual([]); + }), + ); + + it.effect("swallows an already-linked rejection and other dispatch failures", () => + Effect.gen(function* () { + const rejecting: OrchestrationEngineShape["dispatch"] = (command) => + Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "already linked", + }), + ); + const result = prResult({ + status: "opened_existing", + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + }); + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(rejecting)), + ); + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(() => Effect.die(new Error("engine down")))), + ); + // A thread that vanished between the action and the link is not an error either. + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(() => Effect.die(new Error("unreachable")), null)), + ); + }), + ); +}); diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts new file mode 100644 index 00000000000..b6eec85e31c --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -0,0 +1,99 @@ +import { + type CommandId, + pullRequestHostOf, + type GitRunStackedActionResult, + type OrchestrationProjectShell, + type SourceControlProviderKind, + type ThreadId, +} from "@t3tools/contracts"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { repositoryIdentityOf } from "../pullRequest/PullRequestService.ts"; + +export interface CreatedPullRequestKey { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** + * The identity a stacked action's pull request should be linked under, or + * null when the action did not leave one behind. Reading the host and + * repository from the URL keeps the link host-level even when the checkout's + * remote differs from where the PR was opened (a fork, say); the project is + * only consulted when the URL is one this cannot read. + */ +export function createdPullRequestKey( + result: Pick, + project: OrchestrationProjectShell | undefined, +): CreatedPullRequestKey | null { + const { status, number, url } = result.pr; + if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) { + return null; + } + const parsed = parseChangeRequestUrl(url); + if (parsed !== null) return { ...parsed, url }; + const identity = project?.repositoryIdentity; + const kind = identity?.provider as SourceControlProviderKind | undefined; + const repository = project === undefined ? null : repositoryIdentityOf(project); + if (!identity || kind === undefined || repository === null) return null; + return { + host: pullRequestHostOf(identity, kind), + repository: repository.toLowerCase(), + number, + url, + }; +} + +/** + * Links the pull request a `create_pr`-shaped action produced to the thread it + * ran beside. Never fails: the git action already succeeded and its result is + * on its way to the client, so a link that cannot be made is logged and + * dropped. A duplicate link is the decider saying the thread already knew. + */ +export const linkCreatedPullRequest = (input: { + readonly threadId: ThreadId; + readonly result: Pick; + readonly commandId: Effect.Effect; +}): Effect.Effect< + void, + never, + OrchestrationEngine.OrchestrationEngineService | ProjectionSnapshotQuery.ProjectionSnapshotQuery +> => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const thread = yield* snapshots.getThreadShellById(input.threadId); + if (Option.isNone(thread)) return; + const project = Option.getOrUndefined( + yield* snapshots.getProjectShellById(thread.value.projectId), + ); + const key = createdPullRequestKey(input.result, project); + if (key === null) return; + const commandId = yield* input.commandId; + yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId, + threadId: input.threadId, + ...key, + source: "created", + }) + .pipe(Effect.catchTag("OrchestrationCommandInvariantError", () => Effect.void)); + }).pipe( + Effect.withSpan("linkCreatedPullRequest"), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.logWarning("failed to link created pull request to thread", { + threadId: input.threadId, + cause: Cause.pretty(cause), + }), + ), + ); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index fa2880f9c36..7b49bab0ffe 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -4,10 +4,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -38,6 +41,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), ); +const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.none()), + }), + Layer.mock(OrchestrationEngineService)({}), + NodeServices.layer, + ), + ), +); it("normalizes empty successful notification responses to accepted", () => { const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( @@ -97,6 +112,38 @@ it.effect("returns bounded structural preview snapshot failures", () => ).pipe(Effect.provide(TestLayer)), ); +it.effect( + "registers the pull request toolkit and surfaces a missing capability as a tool error", + () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const names = server.tools.map(({ tool }) => tool.name); + expect(names).toEqual( + expect.arrayContaining([ + "link_pull_request", + "unlink_pull_request", + "list_thread_pull_requests", + ]), + ); + const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request"); + expect(linkTool?.tool.annotations?.idempotentHint).toBe(true); + expect(linkTool?.tool.annotations?.openWorldHint).toBe(false); + expect(linkTool?.tool.description).toContain("Register every pull request you open"); + + const denied = yield* server + .callTool({ name: "list_thread_pull_requests", arguments: {} }) + .pipe( + // A preview-only credential: the token predates the toolkit or was minted elsewhere. + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(denied.isError).toBe(true); + expect(denied.content).toEqual([ + { type: "text", text: "MCP credential does not grant the pull-requests capability." }, + ]); + }).pipe(Effect.provide(PullRequestsTestLayer)), +); + it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 87975a49de2..93ed17bbfe9 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -22,6 +22,8 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; +import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -216,6 +218,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe( + Layer.provide(PullRequestsToolkitHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -223,4 +229,7 @@ const McpTransportLive = McpServer.layerHttp({ protocols: [McpProtocol.v2025_06_18], }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); +export const layer = Layer.mergeAll( + PreviewToolkitRegistrationLive, + PullRequestsToolkitRegistrationLive, +).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 569917325be..123944206e3 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + McpCapabilityUnavailableError, PreviewAutomationUnavailableError, ProviderInstanceId, ThreadId, @@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav expect(error.message).toBe("MCP credential does not grant the preview capability."); }); }); + +it.effect("reports other missing capabilities with the neutral error", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), + issuedAt: 1, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + + expect(error).toBeInstanceOf(McpCapabilityUnavailableError); + expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId }); + + const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + ); + expect(scope).toBe(invocation); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 49273485a44..0c0a0ab68ae 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + McpCapabilityUnavailableError, PreviewAutomationUnavailableError, type ProviderInstanceId, type ThreadId, @@ -7,7 +8,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview"; +export type McpCapability = "preview" | "pull-requests"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; @@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} -export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( +/** The error a missing capability surfaces as; preview keeps its own so the broker can route it. */ +export type McpCapabilityError = C extends "preview" + ? PreviewAutomationUnavailableError + : McpCapabilityUnavailableError; + +const missingCapability = ( + invocation: McpInvocationScope, capability: McpCapability, -) { - const invocation = yield* McpInvocationContext; - if (!invocation.capabilities.has(capability)) { - return yield* new PreviewAutomationUnavailableError({ - capability, - environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, - }); - } - return invocation; -}); +): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => { + const fields = { + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }; + return capability === "preview" + ? new PreviewAutomationUnavailableError({ capability, ...fields }) + : new McpCapabilityUnavailableError({ capability, ...fields }); +}; + +export const requireMcpCapability = ( + capability: C, +): Effect.Effect, McpInvocationContext> => + Effect.flatMap(McpInvocationContext, (invocation) => + invocation.capabilities.has(capability) + ? Effect.succeed(invocation) + : // The conditional type narrows what the literal argument decided at runtime. + Effect.fail(missingCapability(invocation, capability) as McpCapabilityError), + ).pipe(Effect.withSpan("mcp.requireCapability")); diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts index d5dc582046c..61c3ac1e0b2 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -7,6 +7,8 @@ export interface McpProviderSessionConfig { readonly providerInstanceId: ProviderInstanceId; readonly endpoint: string; readonly authorizationHeader: string; + /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */ + readonly preview: boolean; } const sessionsByThread = new Map(); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 1d8aead99d0..2e9749a0406 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -39,6 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, }); expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -54,6 +55,29 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); +it.effect("always grants pull-requests and gates preview on the request", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const withPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, + }); + const withoutPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-no-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + preview: false, + }); + const capabilitiesOf = (issued: typeof withPreview) => + registry + .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, "")) + .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort())); + + expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]); + expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]); + }), +); + it.effect("builds MCP endpoints from the bound server host", () => Effect.gen(function* () { const cases = [ @@ -68,6 +92,7 @@ it.effect("builds MCP endpoints from the bound server host", () => const issued = yield* registry.issue({ threadId: ThreadId.make(`thread-${hostname}`), providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, }); expect(issued.config.endpoint).toBe(expectedEndpoint); } @@ -81,6 +106,7 @@ it.effect("expires credentials once their session stops showing signs of life", const issued = yield* registry.issue({ threadId: ThreadId.make("thread-2"), providerInstanceId: ProviderInstanceId.make("claude"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); timestamp += 101; @@ -96,6 +122,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("claude"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -117,6 +144,7 @@ it.effect("does not keep credentials of other threads alive", () => const issued = yield* registry.issue({ threadId: ThreadId.make("thread-4"), providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index f19a4f4e8c4..130f6dce582 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -14,6 +14,11 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + /** + * Whether the credential may drive the user's browser. The pull request + * toolkit is always granted: it only touches the thread's own links. + */ + readonly preview: boolean; } export interface McpIssuedCredential { @@ -68,7 +73,7 @@ export interface McpSessionRegistryOptions { * * The bound matters because `/mcp` is mounted outside the environment auth * stack and is reachable on whatever host the server binds to, so this token is - * the only thing guarding the preview toolkit on a remote-reachable server. + * the only thing guarding the `t3-code` toolkits on a remote-reachable server. */ const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000; @@ -128,7 +133,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), + capabilities: new Set( + request.preview ? ["pull-requests", "preview"] : ["pull-requests"], + ), issuedAt, }; yield* SynchronizedRef.update(state, ({ records }) => { @@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( providerInstanceId: scope.providerInstanceId, endpoint, authorizationHeader: `Bearer ${rawToken}`, + preview: request.preview, }, }; }, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts new file mode 100644 index 00000000000..fb30e04d524 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -0,0 +1,408 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import type { Tool } from "effect/unstable/ai"; + +import { OrchestrationCommandInvariantError } from "../../../orchestration/Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts"; +import { PullRequestsToolkit } from "./tools.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(7), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +const invocation = ( + capabilities: ReadonlyArray, +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment-1"), + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(capabilities), + issuedAt: 1, +}); + +function makeProject( + repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/T3Code.git", + }, + provider: "github", + displayName: "T3Tools/T3Code", + owner: "T3Tools", + name: "T3Code", + }, +): OrchestrationProjectShell { + return { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + repositoryIdentity, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; +} + +function makeThread(pullRequests: ReadonlyArray): OrchestrationThreadShell { + return { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function makeLink( + number: number, + overrides: Partial & { + readonly headBranch?: string; + readonly baseBranch?: string; + } = {}, +): ThreadPullRequestLink { + const { headBranch, baseBranch, ...rest } = overrides; + return { + host: "github.com", + repository: "t3tools/t3code", + number, + url: `https://github.com/t3tools/t3code/pull/${number}`, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + snapshot: + headBranch === undefined + ? null + : { + state: "open", + title: `PR ${number}`, + headBranch, + baseBranch: baseBranch ?? "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-08-27T00:00:00.000Z", + }, + stack: null, + ...rest, + }; +} + +interface HarnessOptions { + readonly thread?: OrchestrationThreadShell | null; + readonly project?: OrchestrationProjectShell | null; + readonly reject?: (command: OrchestrationCommand) => OrchestrationCommandInvariantError | null; +} + +const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( + options: HarnessOptions = {}, +) { + const commands = yield* Ref.make>([]); + const thread = options.thread === undefined ? makeThread([]) : options.thread; + const project = options.project === undefined ? makeProject() : options.project; + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Effect.gen(function* () { + const rejection = options.reject?.(command) ?? null; + if (rejection !== null) return yield* rejection; + yield* Ref.update(commands, (recorded) => [...recorded, command]); + return { sequence: 1 }; + }); + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: (threadId) => + Effect.succeed(threadId === THREAD_ID ? Option.fromNullishOr(thread) : Option.none()), + getProjectShellById: () => Effect.succeed(Option.fromNullishOr(project)), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + const toolkit = yield* PullRequestsToolkit.pipe( + Effect.provide(PullRequestsToolkitHandlersLive.pipe(Layer.provide(dependencies))), + ); + const call = ( + name: Name, + params: Parameters>[1], + capabilities: ReadonlyArray = ["pull-requests"], + ) => + toolkit.handle(name, params).pipe( + Stream.unwrap, + Stream.runCollect, + // Failure mode is "error", so a delivered result is always the success shape. + Effect.map( + (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>, + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)), + Effect.provide(dependencies), + ); + return { commands, call }; +}); + +describe("pull request toolkit handlers", () => { + it.effect("refuses a credential without the pull-requests capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call("list_thread_pull_requests", {}, ["preview"]) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "McpCapabilityUnavailableError", + capability: "pull-requests", + threadId: THREAD_ID, + }); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("links by URL with source agent on the token's thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_pull_request", { + url: "https://github.com/T3Tools/T3Code/pull/123/files", + }); + expect(result).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + url: "https://github.com/T3Tools/T3Code/pull/123/files", + alreadyLinked: false, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.pull-request.link", + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 123, + source: "agent", + }, + ]); + }), + ); + + it.effect("links by repository and number, defaulting the host to the project's", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_pull_request", { + repository: "T3Tools/Other", + number: 7, + }); + expect(result).toEqual({ + host: "github.com", + repository: "t3tools/other", + number: 7, + url: "https://github.com/t3tools/other/pull/7", + alreadyLinked: false, + }); + }), + ); + + it.effect("builds the URL in the project host's own shape", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "gitlab.com/group/sub/project", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@gitlab.com:group/sub/project.git", + }, + provider: "gitlab", + displayName: "group/sub/project", + }), + }); + const result = yield* harness.call("link_pull_request", { + repository: "group/sub/project", + number: 42, + }); + expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); + expect(result.host).toBe("gitlab.com"); + }), + ); + + it.effect("rejects a target that names neither a URL nor repository and number", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call("link_pull_request", { repository: "x/y" }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestTargetError" }); + const unknown = yield* harness + .call("link_pull_request", { url: "https://github.com/t3tools/t3code/issues/1" }) + .pipe(Effect.flip); + expect(unknown).toMatchObject({ _tag: "PullRequestTargetError" }); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("treats a duplicate link as alreadyLinked rather than an error", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([makeLink(123)]), + reject: (command) => + command.type === "thread.pull-request.link" + ? new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "already linked", + }) + : null, + }); + const result = yield* harness.call("link_pull_request", { + url: "https://github.com/t3tools/t3code/pull/123", + }); + expect(result.alreadyLinked).toBe(true); + }), + ); + + it.effect("unlinks a linked pull request and reports a missing one as wasLinked=false", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([makeLink(5)]), + reject: (command) => + command.type === "thread.pull-request.unlink" && command.number !== 5 + ? new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "not linked", + }) + : null, + }); + const linked = yield* harness.call("unlink_pull_request", { + repository: "t3tools/t3code", + number: 5, + }); + expect(linked).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 5, + wasLinked: true, + }); + const missing = yield* harness.call("unlink_pull_request", { + url: "https://github.com/t3tools/t3code/pull/9", + }); + expect(missing.wasLinked).toBe(false); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.pull-request.unlink", number: 5 }, + ]); + }), + ); + + it.effect("fails cleanly when the token's thread no longer exists", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ thread: null }); + const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID }); + }), + ); + + it.effect("lists visible links with host state and derived chain order", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([ + makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }), + makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }), + makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }), + makeLink(9, { source: "stack-dismissed" }), + makeLink(10), + ]), + }); + const result = yield* harness.call("list_thread_pull_requests", {}); + expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]); + expect(result.pullRequests[0]).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 3, + url: "https://github.com/t3tools/t3code/pull/3", + source: "agent", + state: "open", + title: "PR 3", + headBranch: "feat-c", + baseBranch: "feat-b", + isDraft: false, + stack: { kind: "derived", position: 3, size: 3 }, + }); + expect(result.pullRequests[3]).toMatchObject({ + number: 10, + state: null, + title: null, + headBranch: null, + stack: null, + }); + expect(result.chains).toEqual([ + { kind: "derived", numbers: [1, 2, 3] }, + { kind: "derived", numbers: [10] }, + ]); + }), + ); +}); + +describe("listThreadPullRequests", () => { + it("reports a native stack position for each member", () => { + const stack = { + kind: "native" as const, + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [ + { number: 1, headBranch: "a", state: "open" as const }, + { number: 2, headBranch: "b", state: "open" as const }, + ], + }; + const result = listThreadPullRequests({ + pullRequests: [ + makeLink(2, { stack, source: "stack" }), + makeLink(1, { stack, source: "created" }), + ], + }); + expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([ + [2, { kind: "native", position: 2, size: 2 }], + [1, { kind: "native", position: 1, size: 2 }], + ]); + expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]); + }); +}); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts new file mode 100644 index 00000000000..92f09d70084 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -0,0 +1,270 @@ +import { + CommandId, + pullRequestHostOf, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type SourceControlProviderKind, + type ThreadId, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { repositoryIdentityOf } from "../../../pullRequest/PullRequestService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { + type ListThreadPullRequestsResult, + PullRequestLinkFailedError, + PullRequestTargetError, + type PullRequestTargetInput, + PullRequestThreadNotFoundError, + PullRequestsToolkit, + type ThreadPullRequestEntry, +} from "./tools.ts"; + +interface ResolvedTarget { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** + * The host and repository a thread's project is checked out from, so a bare + * repository+number can be completed and a URL rebuilt for it. + */ +function projectHostAndRepository(project: OrchestrationProjectShell | undefined): { + readonly host: string | null; + readonly repository: string | null; + readonly kind: SourceControlProviderKind | null; +} { + const identity = project?.repositoryIdentity; + const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; + if (!identity || kind === null) return { host: null, repository: null, kind: null }; + return { + host: pullRequestHostOf(identity, kind), + repository: project ? repositoryIdentityOf(project) : null, + kind, + }; +} + +/** The web URL a host writes for a change request; null when the host shape is unknown. */ +function changeRequestUrlFor( + kind: SourceControlProviderKind | null, + host: string, + repository: string, + number: number, +): string | null { + switch (kind) { + case "github": + return `https://${host}/${repository}/pull/${number}`; + case "gitlab": + return `https://${host}/${repository}/-/merge_requests/${number}`; + case "bitbucket": + return `https://${host}/${repository}/pull-requests/${number}`; + case "azure-devops": + return `https://${host}/${repository}/pullrequest/${number}`; + default: + return null; + } +} + +/** + * Turns whichever shape the agent passed into one host-level identity. A URL + * wins outright; otherwise the repository and number are completed with the + * thread's project host, which is where an agent working in that checkout + * almost always opened the pull request. + */ +const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( + input: PullRequestTargetInput, + project: OrchestrationProjectShell | undefined, +) { + if (input.url !== undefined) { + const parsed = parseChangeRequestUrl(input.url); + if (parsed === null) { + return yield* new PullRequestTargetError({ + detail: `"${input.url}" is not a pull request URL on a host T3 Code recognises. Pass repository and number instead.`, + }); + } + return { ...parsed, url: input.url } satisfies ResolvedTarget; + } + if (input.repository === undefined || input.number === undefined) { + return yield* new PullRequestTargetError({ + detail: "Pass either url, or both repository and number.", + }); + } + const projectHost = projectHostAndRepository(project); + const host = (input.host ?? projectHost.host)?.toLowerCase(); + if (host === undefined) { + return yield* new PullRequestTargetError({ + detail: + "This thread's project has no recognised remote, so host cannot be defaulted. Pass host or url.", + }); + } + const repository = input.repository.toLowerCase(); + const url = + changeRequestUrlFor( + // The project's kind only describes its own host; another host gets no URL guess. + host === projectHost.host ? projectHost.kind : null, + host, + repository, + input.number, + ) ?? `https://${host}/${repository}/pull/${input.number}`; + return { host, repository, number: input.number, url } satisfies ResolvedTarget; +}); + +function entryOf( + link: ThreadPullRequestLink, + chains: ReturnType, +): ThreadPullRequestEntry { + const key = threadPullRequestKeyOf(link); + let stack: ThreadPullRequestEntry["stack"] = null; + for (const chain of chains) { + if (chain.layers.length < 2) continue; + const index = chain.layers.findIndex((layer) => threadPullRequestKeyOf(layer) === key); + if (index !== -1) { + stack = { kind: chain.kind, position: index + 1, size: chain.layers.length }; + break; + } + } + return { + host: link.host, + repository: link.repository, + number: link.number, + url: link.url, + source: link.source, + state: link.snapshot?.state ?? null, + title: link.snapshot?.title ?? null, + headBranch: link.snapshot?.headBranch ?? null, + baseBranch: link.snapshot?.baseBranch ?? null, + isDraft: link.snapshot?.isDraft ?? null, + stack, + }; +} + +/** What the tools report from a thread shell; exported so the shape is testable without a layer. */ +export function listThreadPullRequests( + thread: Pick, +): ListThreadPullRequestsResult { + const chains = resolveThreadPullRequestChains(thread.pullRequests); + return { + pullRequests: visibleThreadPullRequests(thread.pullRequests).map((link) => + entryOf(link, chains), + ), + chains: chains.map((chain) => ({ + kind: chain.kind, + numbers: chain.layers.map((layer) => layer.number), + })), + }; +} + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const crypto = yield* Crypto.Crypto; + + const commandId = (tag: string, threadId: ThreadId) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)), + ); + + const requireThread = Effect.fn("PullRequestsToolkit.requireThread")(function* ( + operation: "link" | "unlink" | "list", + ) { + const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); + const thread = yield* snapshots + .getThreadShellById(scope.threadId) + .pipe( + Effect.mapError( + (cause) => new PullRequestLinkFailedError({ operation, detail: cause.message }), + ), + ); + if (Option.isNone(thread)) { + return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); + } + return thread.value; + }); + + const projectOf = (thread: OrchestrationThreadShell, operation: "link" | "unlink") => + snapshots.getProjectShellById(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => new PullRequestLinkFailedError({ operation, detail: cause.message }), + ), + ); + + const dispatchFailure = + (operation: "link" | "unlink") => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail(new PullRequestLinkFailedError({ operation, detail: Cause.pretty(cause) })); + + return PullRequestsToolkit.of({ + link_pull_request: (input) => + Effect.gen(function* () { + const thread = yield* requireThread("link"); + const project = yield* projectOf(thread, "link"); + const target = yield* resolveTarget(input, project); + const alreadyLinked = yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId: yield* commandId("mcp-pr-link", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + url: target.url, + source: "agent", + }) + .pipe( + Effect.as(false), + // The decider rejects a second link of the same PR; for the agent that is + // the outcome it asked for, not an error. + Effect.catchTag("OrchestrationCommandInvariantError", () => Effect.succeed(true)), + Effect.catchCause(dispatchFailure("link")), + ); + return { ...target, alreadyLinked }; + }), + unlink_pull_request: (input) => + Effect.gen(function* () { + const thread = yield* requireThread("unlink"); + const project = yield* projectOf(thread, "unlink"); + const target = yield* resolveTarget(input, project); + const wasLinked = yield* engine + .dispatch({ + type: "thread.pull-request.unlink", + commandId: yield* commandId("mcp-pr-unlink", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + }) + .pipe( + Effect.as(true), + Effect.catchTag("OrchestrationCommandInvariantError", () => Effect.succeed(false)), + Effect.catchCause(dispatchFailure("unlink")), + ); + return { + host: target.host, + repository: target.repository, + number: target.number, + wasLinked, + }; + }), + list_thread_pull_requests: () => requireThread("list").pipe(Effect.map(listThreadPullRequests)), + }); +}); + +export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts new file mode 100644 index 00000000000..aacf74c0857 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -0,0 +1,190 @@ +import { + McpCapabilityUnavailableError, + PositiveInt, + PullRequestState, + ThreadPullRequestLinkSource, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + OrchestrationEngine.OrchestrationEngineService, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, +]; + +const REGISTER_EVERY_PR = + "Register every pull request you open for this thread, including each layer of a stack, right after creating it."; + +/** + * Either the pull request's URL or its repository and number. Both forms + * resolve to the same host-level identity, so the agent can pass whichever + * the host CLI handed back. + */ +export const PullRequestTargetInput = Schema.Struct({ + url: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.", + }), + ), + repository: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Repository path below the host, for example owner/repo. Required with number when url is omitted.", + }), + ), + number: Schema.optional( + PositiveInt.annotate({ + description: "Pull request number. Required with repository when url is omitted.", + }), + ), + host: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.", + }), + ), +}); +export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; + +export class PullRequestTargetError extends Schema.TaggedErrorClass()( + "PullRequestTargetError", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + +export class PullRequestThreadNotFoundError extends Schema.TaggedErrorClass()( + "PullRequestThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +export class PullRequestLinkFailedError extends Schema.TaggedErrorClass()( + "PullRequestLinkFailedError", + { operation: Schema.Literals(["link", "unlink", "list"]), detail: Schema.String }, +) { + override get message(): string { + return `Could not ${this.operation} the pull request: ${this.detail}`; + } +} + +export const PullRequestToolError = Schema.Union([ + McpCapabilityUnavailableError, + PullRequestTargetError, + PullRequestThreadNotFoundError, + PullRequestLinkFailedError, +]); +export type PullRequestToolError = typeof PullRequestToolError.Type; + +const PullRequestIdentity = { + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + url: Schema.String, +}; + +export const LinkPullRequestResult = Schema.Struct({ + ...PullRequestIdentity, + alreadyLinked: Schema.Boolean.annotate({ + description: "True when the pull request was linked to this thread before the call.", + }), +}); +export type LinkPullRequestResult = typeof LinkPullRequestResult.Type; + +export const UnlinkPullRequestResult = Schema.Struct({ + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + wasLinked: Schema.Boolean.annotate({ + description: "False when the pull request was not linked to this thread to begin with.", + }), +}); +export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; + +export const ThreadPullRequestEntry = Schema.Struct({ + ...PullRequestIdentity, + source: ThreadPullRequestLinkSource, + state: Schema.NullOr(PullRequestState), + title: Schema.NullOr(Schema.String), + headBranch: Schema.NullOr(Schema.String), + baseBranch: Schema.NullOr(Schema.String), + isDraft: Schema.NullOr(Schema.Boolean), + stack: Schema.NullOr( + Schema.Struct({ + kind: Schema.Literals(["native", "derived"]), + /** 1-based, bottom of the stack first. */ + position: Schema.Int, + size: Schema.Int, + }), + ), +}); +export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type; + +export const ListThreadPullRequestsResult = Schema.Struct({ + pullRequests: Schema.Array(ThreadPullRequestEntry), + chains: Schema.Array( + Schema.Struct({ + kind: Schema.Literals(["native", "derived"]), + /** Bottom to top. */ + numbers: Schema.Array(Schema.Int), + }), + ), +}); +export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; + +export const LinkPullRequestTool = Tool.make("link_pull_request", { + description: `${REGISTER_EVERY_PR} Links a pull request to this thread so T3 Code tracks it, shows its status beside the thread, and settles the thread when it merges. Pass the URL, or repository plus number. Linking an already-linked pull request succeeds with alreadyLinked=true.`, + parameters: PullRequestTargetInput, + success: LinkPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Link pull request to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { + description: + "Remove a pull request link from this thread, for example after closing a pull request you opened by mistake. Pass the URL, or repository plus number. Unlinking a pull request that is not linked succeeds with wasLinked=false.", + parameters: PullRequestTargetInput, + success: UnlinkPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Unlink pull request from thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { + description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + success: ListThreadPullRequestsResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "List thread pull requests") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const PullRequestsToolkit = Toolkit.make( + LinkPullRequestTool, + UnlinkPullRequestTool, + ListThreadPullRequestsTool, +); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 3810ee378c4..81b0b83d200 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -153,6 +153,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 1340480bce5..b34ccad5507 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -74,6 +75,16 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { + start: () => { + started.push("pull-request-sync-reactor"); + return Effect.void; + }, + drain: Effect.void, + requestSync: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -96,6 +107,7 @@ describe("OrchestrationReactor", () => { "checkpoint-reactor", "thread-deletion-reactor", "thread-settlement-reactor", + "pull-request-sync-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 649e803809d..6f9014ffb38 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -26,6 +28,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); yield* threadSettlementReactor.start(); + yield* pullRequestSyncReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 504fa7c5254..9a66821f99c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -7,6 +7,7 @@ import { MessageId, ProjectId, ThreadId, + type ThreadPullRequestSnapshot, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -395,6 +396,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull-requests-")))( + "OrchestrationProjectionPipeline pull request links", + (it) => { + it.effect("projects link, sync, unlink, legacy replay and delete into the link table", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-pr"); + const projectId = ProjectId.make("project-pr"); + const t0 = "2026-01-01T00:00:00.000Z"; + let counter = 0; + const base = (occurredAt: string) => { + counter += 1; + return { + eventId: EventId.make(`evt-pr-${counter}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make(`cmd-pr-${counter}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-pr-${counter}`), + metadata: {}, + } as const; + }; + const readLinks = () => + sql<{ + readonly host: string; + readonly repository: string; + readonly number: number; + readonly source: string; + readonly linkedAt: string; + readonly snapshotJson: string | null; + readonly stackJson: string | null; + }>` + SELECT + host, + repository, + number, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshotJson", + stack_json AS "stackJson" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY number ASC + `; + const readThreadUpdatedAt = () => + sql<{ readonly updatedAt: string }>` + SELECT updated_at AS "updatedAt" FROM projection_threads WHERE thread_id = ${threadId} + `; + + yield* eventStore.append({ + ...base(t0), + type: "thread.created", + payload: { + threadId, + projectId, + title: "Thread PR", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: t0, + updatedAt: t0, + }, + }); + + // Legacy single-link event replays into a manual row with the URL host. + yield* eventStore.append({ + ...base("2026-01-01T00:00:01.000Z"), + type: "thread.meta-updated", + payload: { + threadId, + linkedPullRequest: { + projectId, + repository: "PingDotGG/T3Code", + number: 41, + url: "https://GitHub.com/pingdotgg/t3code/pull/41", + }, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:02.000Z"), + type: "thread.pull-request-linked", + payload: { + threadId, + link: { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "created", + linkedAt: "2026-01-01T00:00:02.000Z", + snapshot: null, + stack: null, + }, + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + assert.deepEqual(yield* readLinks(), [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 41, + source: "manual", + linkedAt: "2026-01-01T00:00:01.000Z", + snapshotJson: null, + stackJson: null, + }, + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + source: "created", + linkedAt: "2026-01-01T00:00:02.000Z", + snapshotJson: null, + stackJson: null, + }, + ]); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:02.000Z" }]); + + // Sync fills snapshot/stack on the matching row; a sync for an unknown + // link is ignored. + const snapshot: ThreadPullRequestSnapshot = { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-01T00:00:02.500Z", + syncedAt: "2026-01-01T00:00:03.000Z", + }; + yield* eventStore.append({ + ...base("2026-01-01T00:00:03.000Z"), + type: "thread.pull-request-synced", + payload: { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:03.500Z"), + type: "thread.pull-request-synced", + payload: { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 99, + snapshot, + stack: null, + updatedAt: "2026-01-01T00:00:03.500Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const synced = yield* readLinks(); + assert.equal(synced.length, 2); + assert.equal(synced[0]?.snapshotJson, null); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(synced[1]?.snapshotJson ?? "null"), snapshot); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:03.000Z" }]); + + // A legacy null clears only the manual row; created/agent/stack rows stay. + yield* eventStore.append({ + ...base("2026-01-01T00:00:04.000Z"), + type: "thread.meta-updated", + payload: { + threadId, + linkedPullRequest: null, + updatedAt: "2026-01-01T00:00:04.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual( + (yield* readLinks()).map((row) => row.number), + [42], + ); + + yield* eventStore.append({ + ...base("2026-01-01T00:00:05.000Z"), + type: "thread.pull-request-unlinked", + payload: { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + updatedAt: "2026-01-01T00:00:05.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readLinks(), []); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]); + + // Deleting the thread clears whatever links it still had. + yield* eventStore.append({ + ...base("2026-01-01T00:00:06.000Z"), + type: "thread.pull-request-linked", + payload: { + threadId, + link: { + host: "github.com", + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + source: "agent", + linkedAt: "2026-01-01T00:00:06.000Z", + snapshot: null, + stack: null, + }, + updatedAt: "2026-01-01T00:00:06.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:07.000Z"), + type: "thread.deleted", + payload: { + threadId, + deletedAt: "2026-01-01T00:00:07.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readLinks(), []); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 01c646ee15b..06fc6d5ec5d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -4,6 +4,7 @@ import { type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, + type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,6 +13,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequests"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; @@ -28,6 +30,7 @@ import { type ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadPullRequestRepository } from "../../persistence/Services/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { type ProjectionTurn, @@ -40,6 +43,7 @@ import { ProjectionStateRepositoryLive } from "../../persistence/Layers/Projecti import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadPullRequestRepositoryLive } from "../../persistence/Layers/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; @@ -93,6 +97,20 @@ function settledTurnStateForSessionStatus( } } +/** + * Legacy single-link events carried no host. Projects never persisted their + * repository identity, so the pull request URL is the only host source the + * pipeline has, matching the 046 migration backfill. + */ +function legacyPullRequestHost(linked: ThreadLinkedPullRequest): string { + try { + const hostname = new URL(linked.url).hostname.trim().toLowerCase(); + return hostname.length > 0 ? hostname : "unknown"; + } catch { + return "unknown"; + } +} + interface ProjectorDefinition { readonly name: ProjectorName; readonly apply: ( @@ -495,6 +513,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; + const projectionThreadPullRequestRepository = yield* ProjectionThreadPullRequestRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -623,6 +642,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.created": + // A draft retry can re-create this id; links belong to the old incarnation. + yield* projectionThreadPullRequestRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); yield* projectionThreadRepository.upsert({ threadId: event.payload.threadId, projectId: event.payload.projectId, @@ -835,6 +858,96 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti : {}), updatedAt: event.payload.updatedAt, }); + // Legacy single-link events replay into the link table. The old + // field held one user-chosen link, so it only ever owns the manual + // rows; created/agent/stack links are left alone. + if (event.payload.linkedPullRequest !== undefined) { + yield* projectionThreadPullRequestRepository.deleteByThreadIdAndSource({ + threadId: event.payload.threadId, + source: "manual", + }); + if (event.payload.linkedPullRequest !== null) { + const linked = event.payload.linkedPullRequest; + yield* projectionThreadPullRequestRepository.upsert({ + threadId: event.payload.threadId, + host: legacyPullRequestHost(linked), + repository: linked.repository.toLowerCase(), + number: linked.number, + url: linked.url, + source: "manual", + linkedAt: event.payload.updatedAt, + snapshot: null, + stack: null, + }); + } + } + return; + } + + case "thread.pull-request-linked": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadPullRequestRepository.upsert({ + threadId: event.payload.threadId, + ...event.payload.link, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pull-request-unlinked": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadPullRequestRepository.delete({ + threadId: event.payload.threadId, + host: event.payload.host, + repository: event.payload.repository, + number: event.payload.number, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pull-request-synced": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + // A sync for a link the user removed in the meantime is stale; drop it. + const links = yield* projectionThreadPullRequestRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const link = links.find((candidate) => + threadPullRequestKeysEqual(candidate, event.payload), + ); + if (link === undefined) { + return; + } + yield* projectionThreadPullRequestRepository.upsert({ + ...link, + snapshot: event.payload.snapshot, + stack: event.payload.stack, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); return; } @@ -881,6 +994,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (!recreatedLater) { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); } + // A tombstoned thread must not show up as linked to a pull request. + yield* projectionThreadPullRequestRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -1944,6 +2061,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), + Layer.provideMerge(ProjectionThreadPullRequestRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 3c9501fded8..350c2a09980 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -4,6 +4,7 @@ import { MessageId, ProjectId, ThreadId, + type ThreadPullRequestLink, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -48,6 +49,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; yield* sql`DELETE FROM projection_thread_proposed_plans`; + yield* sql`DELETE FROM projection_thread_pull_requests`; yield* sql`DELETE FROM projection_turns`; yield* sql` @@ -73,6 +75,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + // A merged link plus a newer open one: the legacy single-link field must + // resolve to the open pull request, not the stale JSON column below. + yield* sql` + INSERT INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES + ( + 'thread-1', + 'github.com', + 'pingdotgg/t3code', + 41, + 'https://github.com/pingdotgg/t3code/pull/41', + 'created', + '2026-02-24T00:00:02.500Z', + '{"state":"merged","title":"Groundwork","headBranch":"feat/groundwork","baseBranch":"main","isDraft":false,"updatedAt":"2026-02-24T00:00:02.600Z","syncedAt":"2026-02-24T00:00:02.700Z"}', + NULL + ), + ( + 'thread-1', + 'github.com', + 'pingdotgg/t3code', + 42, + 'https://github.com/pingdotgg/t3code/pull/42', + 'manual', + '2026-02-24T00:00:03.000Z', + NULL, + NULL + ) + `; + yield* sql` INSERT INTO projection_threads ( thread_id, @@ -104,7 +145,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'default', NULL, NULL, - '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', + '{"projectId":"project-1","repository":"pingdotgg/t3code","number":41,"url":"https://github.com/pingdotgg/t3code/pull/41"}', 'turn-1', '2026-02-24T00:00:04.000Z', 1, @@ -264,6 +305,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { sequence += 1; } + const expectedPullRequests: ReadonlyArray = [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 41, + url: "https://github.com/pingdotgg/t3code/pull/41", + source: "created", + linkedAt: "2026-02-24T00:00:02.500Z", + snapshot: { + state: "merged", + title: "Groundwork", + headBranch: "feat/groundwork", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-02-24T00:00:02.600Z", + syncedAt: "2026-02-24T00:00:02.700Z", + }, + stack: null, + }, + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-02-24T00:00:03.000Z", + snapshot: null, + stack: null, + }, + ]; + const snapshot = yield* snapshotQuery.getSnapshot(); assert.equal(snapshot.snapshotSequence, 5); @@ -314,6 +386,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }, + pullRequests: expectedPullRequests, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -441,6 +514,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }, + pullRequests: expectedPullRequests, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -488,6 +562,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(threadShell._tag, "Some"); + if (threadShell._tag === "Some") { + assert.deepEqual(threadShell.value, shellSnapshot.threads[0]); + } + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.deepEqual(commandReadModel.threads[0]?.pullRequests, expectedPullRequests); + assert.deepEqual( + commandReadModel.threads[0]?.linkedPullRequest, + snapshot.threads[0]?.linkedPullRequest, + ); + + // Without link rows the legacy field is omitted, whatever the old JSON + // column still holds. + yield* sql`DELETE FROM projection_thread_pull_requests`; + const unlinkedShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(unlinkedShell._tag, "Some"); + if (unlinkedShell._tag === "Some") { + assert.deepEqual(unlinkedShell.value.pullRequests, []); + assert.equal("linkedPullRequest" in unlinkedShell.value, false); + } + yield* sql` INSERT INTO projection_thread_activities ( activity_id, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c9d356a973d..807ba106928 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -26,7 +26,11 @@ import { ProjectId, ThreadLinkedPullRequest, ThreadId, + ThreadPullRequestSnapshot, + ThreadPullRequestStack, + type ThreadPullRequestLink, } from "@t3tools/contracts"; +import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -51,6 +55,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadPullRequest } from "../../persistence/Services/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { @@ -94,6 +99,12 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; +const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFields( + Struct.assign({ + snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), + stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), + }), +); const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), @@ -370,6 +381,48 @@ function mapProposedPlanRow( }; } +function mapPullRequestRow( + row: Schema.Schema.Type, +): ThreadPullRequestLink { + return { + host: row.host, + repository: row.repository, + number: row.number, + url: row.url, + source: row.source, + linkedAt: row.linkedAt, + snapshot: row.snapshot, + stack: row.stack, + }; +} + +function groupPullRequestRowsByThread( + rows: ReadonlyArray>, +): Map> { + const byThread = new Map>(); + for (const row of rows) { + const links = byThread.get(row.threadId) ?? []; + links.push(mapPullRequestRow(row)); + byThread.set(row.threadId, links); + } + return byThread; +} + +/** + * The link array plus the legacy single-link field derived from it, so clients + * from before `pullRequests` keep seeing the thread's current pull request. + */ +function mapThreadPullRequests( + pullRequests: ReadonlyArray, + projectId: ProjectId, +): Pick { + const linkedPullRequest = legacyLinkedPullRequestOf(pullRequests, projectId); + return { + pullRequests, + ...(linkedPullRequest === null ? {} : { linkedPullRequest }), + }; +} + function mapThreadActivityRow( row: Schema.Schema.Type, ): OrchestrationThreadActivity { @@ -609,6 +662,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + ORDER BY thread_id ASC, linked_at ASC, number ASC + `, + }); + + const listActiveThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC + `, + }); + + const listArchivedThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC + `, + }); + const listThreadActivityRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadActivityDbRowSchema, @@ -1072,6 +1193,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadPullRequestRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY linked_at ASC, number ASC + `, + }); + const listThreadActivityRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1682,6 +1824,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listThreadActivityRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1731,6 +1881,7 @@ pending_approval_requests AS ( threadRows, messageRows, proposedPlanRows, + pullRequestRows, activityRows, sessionRows, checkpointRows, @@ -1740,6 +1891,7 @@ pending_approval_requests AS ( Effect.gen(function* () { const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const activitiesByThread = new Map>(); const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); @@ -1900,9 +2052,10 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + ), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1973,6 +2126,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:decodeRows", + ), + ), + ), listThreadSessionRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2001,7 +2162,15 @@ pending_approval_requests AS ( ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + proposedPlanRows, + pullRequestRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; @@ -2078,6 +2247,7 @@ pending_approval_requests AS ( latestTurnByThread.set(row.threadId, mapLatestTurn(row)); } const proposedPlansByThread = new Map>(); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const sessionByThread = new Map(); for (let index = 0; index < sessionRows.length; index += 1) { @@ -2112,9 +2282,10 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + ), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2180,6 +2351,14 @@ pending_approval_requests AS ( ), ), ), + listActiveThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listActiveLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2199,97 +2378,101 @@ pending_approval_requests AS ( ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); + const repositoryIdentities = + yield* resolveRepositoryIdentitiesForProjects(projectRows); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: Arr.filterMap(threadRows, (row) => - row.deletedAt === null - ? Result.succeed({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - } satisfies OrchestrationThreadShell) - : Result.failVoid, - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, + ), + threads: Arr.filterMap(threadRows, (row) => + row.deletedAt === null + ? Result.succeed({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + ), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + } satisfies OrchestrationThreadShell) + : Result.failVoid, + ), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2327,6 +2510,14 @@ pending_approval_requests AS ( ), ), ), + listArchivedThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listArchivedLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2346,98 +2537,101 @@ pending_approval_requests AS ( ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( - projectRows.filter((row) => activeProjectIds.has(row.projectId)), - ); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null && activeProjectIds.has(row.projectId) - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: threadRows.map( - (row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - }), - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null && activeProjectIds.has(row.projectId) + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, + ), + threads: threadRows.map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + ), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + }), + ), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2661,7 +2855,7 @@ pending_approval_requests AS ( const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { - const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ + const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2686,6 +2880,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query", + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows", + ), + ), + ), ]); if (Option.isNone(threadRow)) { @@ -2701,9 +2903,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, - ...(threadRow.value.linkedPullRequest === null - ? {} - : { linkedPullRequest: threadRow.value.linkedPullRequest }), + ...mapThreadPullRequests(pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -2867,6 +3067,7 @@ pending_approval_requests AS ( threadRow, messageRows, proposedPlanRows, + pullRequestRows, activities, checkpointRows, latestTurnRow, @@ -2899,6 +3100,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:decodeRows", + ), + ), + ), activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( @@ -2939,9 +3148,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, - ...(threadRow.value.linkedPullRequest === null - ? {} - : { linkedPullRequest: threadRow.value.linkedPullRequest }), + ...mapThreadPullRequests(pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts new file mode 100644 index 00000000000..abfacd3de1f --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -0,0 +1,522 @@ +import { + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestRef, + type PullRequestStack, + type PullRequestSummary, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("sync-project"); + +type SyncCommand = Extract; +type LinkCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject(id: ProjectId = PROJECT_ID): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeLink( + number: number, + snapshot: Partial | null = null, + overrides: Partial = {}, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "owner/repository", + number, + url: `https://github.com/owner/repository/pull/${number}`, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + snapshot: + snapshot === null + ? null + : { + state: "open", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-08-27T00:00:00.000Z", + syncedAt: "2026-08-27T00:00:00.000Z", + ...snapshot, + }, + stack: null, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + snapshotSequence = 1, +): OrchestrationShellSnapshot { + return { + snapshotSequence, + projects: [makeProject()], + threads, + updatedAt: NOW, + }; +} + +function makeSummary( + input: PullRequestRef, + overrides: Partial = {}, +): PullRequestSummary { + return { + provider: "github", + projectId: input.projectId, + repository: input.repository, + number: input.number, + title: "Pull request", + url: `https://github.com/${input.repository}/pull/${input.number}`, + state: "open", + headBranch: "feature", + baseBranch: "main", + updatedAt: "2026-08-27T00:00:00.000Z", + ...overrides, + }; +} + +interface HarnessOptions { + readonly snapshot: OrchestrationShellSnapshot; + readonly summary?: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly stack?: ( + input: PullRequestRef, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReads = yield* Queue.unbounded(); + const syncCommands = yield* Ref.make>([]); + const linkCommands = yield* Ref.make>([]); + const summaryCalls = yield* Ref.make>([]); + const stackCalls = yield* Ref.make>([]); + + const summary: PullRequestService["Service"]["summary"] = (input, readOptions) => + Effect.gen(function* () { + assert.strictEqual(readOptions?.recoverTransientFailure, false); + yield* Ref.update(summaryCalls, (calls) => [...calls, input]); + return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input)); + }); + + const stack: PullRequestService["Service"]["stack"] = (input) => + Effect.gen(function* () { + yield* Ref.update(stackCalls, (calls) => [...calls, input]); + return yield* options.stack?.(input) ?? Effect.succeed(null); + }); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type === "thread.pull-request.sync") { + return Ref.update(syncCommands, (recorded) => [...recorded, command]).pipe( + Effect.as({ sequence: 1 }), + ); + } + if (command.type === "thread.pull-request.link") { + return Ref.update(linkCommands, (recorded) => [...recorded, command]).pipe( + Effect.as({ sequence: 1 }), + ); + } + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + }; + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))), + }), + Layer.mock(PullRequestService)({ summary, stack }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReads, + syncCommands, + linkCommands, + summaryCalls, + stackCalls, + layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +type Harness = Effect.Success>; + +const startAndSweep = Effect.fn("startPullRequestSyncHarness")(function* (fixture: Harness) { + const reactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + return reactor; +}); + +const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( + fixture: Harness, + reactor: PullRequestSyncReactor.PullRequestSyncReactor["Service"], +) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; +}); + +/** What the reactor would have persisted, so the next sweep sees its own writes. */ +function applySync( + snapshot: OrchestrationShellSnapshot, + commands: ReadonlyArray, +): OrchestrationShellSnapshot { + return { + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((thread) => ({ + ...thread, + pullRequests: thread.pullRequests.map((link) => { + const command = commands.find( + (candidate) => candidate.threadId === thread.id && candidate.number === link.number, + ); + return command === undefined + ? link + : { ...link, snapshot: command.snapshot, stack: command.stack }; + }), + })), + }; +} + +describe("PullRequestSyncReactor", () => { + it.effect("snapshots an unsynced link once and writes it to the thread", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), + summary: (input) => + Effect.succeed(makeSummary(input, { title: "Ship it", isDraft: true })), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ + { + projectId: PROJECT_ID, + host: "github.com", + repository: "owner/repository", + number: 42, + }, + ]); + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map(({ commandId: _, ...rest }) => rest), + [ + { + type: "thread.pull-request.sync", + threadId: ThreadId.make("one"), + host: "github.com", + repository: "owner/repository", + number: 42, + snapshot: { + state: "open", + title: "Ship it", + headBranch: "feature", + baseBranch: "main", + isDraft: true, + updatedAt: "2026-08-27T00:00:00.000Z", + syncedAt: NOW, + }, + stack: null, + }, + ], + ); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("asks the host once for a pull request shared by two threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { pullRequests: [makeLink(42)] }), + makeThread("two", { + pullRequests: [makeLink(42, null, { repository: "Owner/Repository" })], + }), + ]), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + const commands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual( + commands + .map((command) => [command.threadId, command.repository] as const) + .sort((left, right) => left[0].localeCompare(right[0])), + [ + [ThreadId.make("one"), "owner/repository"], + [ThreadId.make("two"), "Owner/Repository"], + ], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("dispatches nothing when the host snapshot is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const firstSweep = yield* Ref.get(fixture.syncCommands); + assert.strictEqual(firstSweep.length, 1); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep)); + + yield* sweepAgain(fixture, reactor); + + // Still open on an active thread, so the host was asked again, but nothing changed. + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("stops asking the host once a pull request is terminal", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged", { pullRequests: [makeLink(1, { state: "merged" })] }), + makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }), + ]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* sweepAgain(fixture, reactor); + + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []); + + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), + [1], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("polls open pull requests on settled threads every fifteen minutes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("settled", { + settledOverride: "settled", + settledAt: "2026-08-21T00:00:00.000Z", + pullRequests: [makeLink(5, { state: "open" })], + }), + ]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + for (let index = 0; index < 13; index += 1) yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("auto-links missing native stack layers and leaves dismissed ones alone", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const stack: PullRequestStack = { + id: "stack-1", + number: 42, + url: "https://github.com/owner/repository/stack/1", + base: "main", + layers: [ + { number: 41, headBranch: "layer-1", state: "merged" }, + { number: 42, headBranch: "layer-2", state: "open" }, + { number: 43, headBranch: "layer-3", state: "open" }, + ], + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { + pullRequests: [ + makeLink(42), + makeLink(41, { state: "merged" }, { source: "stack-dismissed" }), + ], + }), + ]), + stack: () => Effect.succeed(stack), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + const syncCommands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual( + syncCommands.map((command) => [command.number, command.stack] as const), + [[42, { kind: "native", ...stack }]], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.linkCommands)).map(({ commandId: _, ...rest }) => rest), + [ + { + type: "thread.pull-request.link", + threadId: ThreadId.make("one"), + host: "github.com", + repository: "owner/repository", + number: 43, + url: "https://github.com/owner/repository/pull/43", + source: "stack", + }, + ], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps existing snapshots and continues when the host fails", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("failing", { pullRequests: [makeLink(7, { state: "open" })] }), + makeThread("fine", { pullRequests: [makeLink(8)] }), + ]), + summary: (input) => + input.number === 7 + ? Effect.fail( + new PullRequestOperationError({ operation: "summary", detail: "host down" }), + ) + : Effect.succeed(makeSummary(input)), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), + [8], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts new file mode 100644 index 00000000000..7b074be28c2 --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -0,0 +1,313 @@ +import { + CommandId, + type OrchestrationThreadShell, + type PullRequestSummary, + type ThreadPullRequestKey, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, + type ThreadPullRequestStack, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; + +/** + * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads + * the shell snapshot, groups visible links by pull request so the host is asked once per PR + * no matter how many threads share it, and writes back only what changed. Native stacks the + * host reports are auto-linked to the thread as `source: "stack"`. + */ +export class PullRequestSyncReactor extends Context.Service< + PullRequestSyncReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + /** Force the next sweep to re-read this pull request, even when its snapshot is terminal. */ + readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect; + } +>()("t3/orchestration/PullRequestSyncReactor") {} + +const SETTLED_SYNC_INTERVAL_MS = 15 * 60 * 1_000; + +type SnapshotFields = Omit; + +interface LinkEntry { + readonly thread: OrchestrationThreadShell; + readonly link: ThreadPullRequestLink; +} + +function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { + return { + state: summary.state, + title: summary.title, + headBranch: summary.headBranch, + baseBranch: summary.baseBranch, + isDraft: summary.isDraft ?? false, + updatedAt: summary.updatedAt, + ...(summary.author === undefined ? {} : { author: summary.author }), + ...(summary.additions === undefined ? {} : { additions: summary.additions }), + ...(summary.deletions === undefined ? {} : { deletions: summary.deletions }), + ...(summary.changedFiles === undefined ? {} : { changedFiles: summary.changedFiles }), + ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }), + ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }), + ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }), + }; +} + +function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boolean { + return ( + left.state === right.state && + left.title === right.title && + left.headBranch === right.headBranch && + left.baseBranch === right.baseBranch && + left.isDraft === right.isDraft && + left.updatedAt === right.updatedAt && + (left.author?.login ?? null) === (right.author?.login ?? null) && + (left.author?.avatarUrl ?? null) === (right.author?.avatarUrl ?? null) && + left.additions === right.additions && + left.deletions === right.deletions && + left.changedFiles === right.changedFiles && + (left.reviewDecision ?? null) === (right.reviewDecision ?? null) && + (left.checksState ?? null) === (right.checksState ?? null) && + left.mergeability === right.mergeability + ); +} + +function stacksEqual( + left: ThreadPullRequestStack | null, + right: ThreadPullRequestStack | null, +): boolean { + if (left === null || right === null) return left === right; + return ( + left.kind === right.kind && + left.id === right.id && + left.number === right.number && + left.url === right.url && + left.base === right.base && + left.layers.length === right.layers.length && + left.layers.every((layer, index) => { + const other = right.layers[index]!; + return ( + layer.number === other.number && + layer.headBranch === other.headBranch && + layer.state === other.state + ); + }) + ); +} + +function isUnsettled(thread: OrchestrationThreadShell): boolean { + return thread.settledOverride !== "settled" && thread.settledAt === null; +} + +/** `.../pull/42` → `.../pull/43`; null when the linked url carries no trailing number. */ +function siblingPullRequestUrl(url: string, number: number): string | null { + const match = /^(.*\/)\d+\/?$/.exec(url); + return match === null ? null : `${match[1]}${number}`; +} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const lastSyncedAt = new Map(); + const requested = new Set(); + + const isDue = (key: string, entries: ReadonlyArray, nowMs: number): boolean => { + if (requested.has(key)) return true; + if (entries.some((entry) => entry.link.snapshot === null)) return true; + if (!entries.some((entry) => entry.link.snapshot?.state === "open")) return false; + if (entries.some((entry) => isUnsettled(entry.thread))) return true; + const last = lastSyncedAt.get(key); + return last === undefined || nowMs - last >= SETTLED_SYNC_INTERVAL_MS; + }; + + const logSkipped = + (message: string, fields: Record) => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning(message, { ...fields, cause: Cause.pretty(cause) }); + + const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const nowIso = DateTime.formatIso(now); + + const groups = new Map>(); + for (const thread of snapshot.threads) { + if (thread.archivedAt !== null) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + const key = threadPullRequestKeyOf(link); + const entries = groups.get(key) ?? []; + entries.push({ thread, link }); + groups.set(key, entries); + } + } + + // Layers auto-linked this sweep, so two links of one thread that share a + // stack do not both try to add the same sibling. + const linkedThisSweep = new Set(); + + const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + entry: LinkEntry, + fields: SnapshotFields, + fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, + ) { + const { thread, link } = entry; + const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack; + const changed = + link.snapshot === null || + !snapshotFieldsEqual(link.snapshot, fields) || + !stacksEqual(link.stack, nextStack); + if (changed) { + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.pull-request.sync", + commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), + threadId: thread.id, + host: link.host, + repository: link.repository, + number: link.number, + snapshot: { ...fields, syncedAt: nowIso }, + stack: nextStack, + }); + } + if (fetchedStack === null || fetchedStack.stack === null) return; + for (const layer of fetchedStack.stack.layers) { + const layerKey = { host: link.host, repository: link.repository, number: layer.number }; + const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`; + if (linkedThisSweep.has(dedupeKey)) continue; + // Tombstones count as present: a dismissed layer is never re-added. + if ( + thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, layerKey)) + ) { + continue; + } + const url = siblingPullRequestUrl(link.url, layer.number); + if (url === null) continue; + linkedThisSweep.add(dedupeKey); + const uuid = yield* crypto.randomUUIDv4; + yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`), + threadId: thread.id, + ...layerKey, + url, + source: "stack", + }) + .pipe( + Effect.catchCause( + logSkipped("pull request stack layer link skipped", { + threadId: thread.id, + number: layer.number, + }), + ), + ); + } + }); + + const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* ( + key: string, + entries: ReadonlyArray, + ) { + const first = entries[0]!; + const ref = { + projectId: first.thread.projectId, + host: first.link.host, + repository: first.link.repository, + number: first.link.number, + }; + const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); + const fields = snapshotFieldsOf(summary); + const needsStack = entries.some( + (entry) => + entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), + ); + const fetchedStack = needsStack + ? yield* pullRequests.stack(ref).pipe( + Effect.map((stack) => ({ + stack: stack === null ? null : ({ kind: "native", ...stack } as const), + })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("pull request stack lookup failed", { + key, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ) + : null; + // The host answered, so the cadence clock ticks even if a dispatch below is rejected. + lastSyncedAt.set(key, nowMs); + requested.delete(key); + yield* Effect.forEach( + entries, + (entry) => + syncEntry(entry, fields, fetchedStack).pipe( + Effect.catchCause( + logSkipped("pull request sync skipped", { threadId: entry.thread.id, key }), + ), + ), + { discard: true }, + ); + }); + + yield* Effect.forEach( + groups, + ([key, entries]) => + isDue(key, entries, nowMs) + ? syncGroup(key, entries).pipe( + Effect.catchCause(logSkipped("pull request sync skipped", { key })), + ) + : Effect.void, + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), + ); + + const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( + "PullRequestSyncReactor.start", + )(function* () { + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + }); + + const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => + Effect.suspend(() => { + requested.add(threadPullRequestKeyOf(key)); + return worker.enqueue(undefined); + }); + + return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"]; +}); + +export const layer = Layer.effect(PullRequestSyncReactor, make); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf8959..29468dc3f84 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,9 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema, + ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema, + ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; +export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; +export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 08d2d2af24a..9bc5ab44b23 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -20,6 +20,7 @@ const makeThread = ( interactionMode: "default", branch: "feature", worktreePath: "/repo", + pullRequests: [], latestTurn: null, createdAt: "2026-08-01T00:00:00.000Z", updatedAt: "2026-08-20T00:00:00.000Z", @@ -34,20 +35,96 @@ const makeThread = ( ...overrides, }); +type PullRequest = { state: "open" | "closed" | "merged"; updatedAt: string | null }; + const decide = ( thread: OrchestrationThreadShell, - pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + pullRequest: PullRequest | ReadonlyArray | null = null, settings: { days?: number | null; merge?: boolean } = {}, ) => shouldAutoSettleThread({ thread, - pullRequest, + pullRequests: + pullRequest === null ? [] : Array.isArray(pullRequest) ? pullRequest : [pullRequest], now: NOW, autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, autoSettleOnMerge: settings.merge ?? true, }); describe("shouldAutoSettleThread", () => { + it("decides across every linked pull request", () => { + const recent = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); + const merged = (updatedAt: string): PullRequest => ({ state: "merged", updatedAt }); + const cases: ReadonlyArray<{ + readonly name: string; + readonly thread: OrchestrationThreadShell; + readonly pullRequests: ReadonlyArray; + readonly settings?: { days?: number | null; merge?: boolean }; + readonly expected: boolean; + }> = [ + { + name: "one open link blocks even when others merged", + thread: makeThread(), + pullRequests: [merged(NOW), { state: "open", updatedAt: NOW }], + expected: false, + }, + { + name: "open link blocks the inactivity rule too", + thread: makeThread(), + pullRequests: [{ state: "open", updatedAt: null }], + expected: false, + }, + { + name: "all merged settles on the latest layer", + thread: recent, + pullRequests: [merged("2026-08-26T00:00:00.000Z"), merged("2026-08-27T12:00:00.000Z")], + settings: { days: null }, + expected: true, + }, + { + name: "latest terminal update older than the user keeps the thread active", + thread: recent, + pullRequests: [merged("2026-08-25T00:00:00.000Z"), merged("2026-08-26T00:00:00.000Z")], + settings: { days: null }, + expected: false, + }, + { + name: "merged layers need the merge setting", + thread: recent, + pullRequests: [merged(NOW), { state: "closed", updatedAt: "2026-08-26T00:00:00.000Z" }], + settings: { days: null, merge: false }, + expected: false, + }, + { + name: "closed latest layer settles without the merge setting", + thread: recent, + pullRequests: [merged("2026-08-26T00:00:00.000Z"), { state: "closed", updatedAt: NOW }], + settings: { days: null, merge: false }, + expected: true, + }, + { + name: "terminal links without a timestamp fall through to inactivity", + thread: makeThread(), + pullRequests: [ + { state: "closed", updatedAt: null }, + { state: "merged", updatedAt: null }, + ], + expected: true, + }, + { + name: "no links keeps the inactivity rule", + thread: makeThread(), + pullRequests: [], + expected: true, + }, + ]; + for (const testCase of cases) { + expect(decide(testCase.thread, testCase.pullRequests, testCase.settings), testCase.name).toBe( + testCase.expected, + ); + } + }); + it("settles inactive threads and leaves never-used threads active", () => { expect(decide(makeThread())).toBe(true); expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 5a10307956a..b9bfa0a9c88 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -8,6 +8,12 @@ export interface SettlementPullRequest { const DAY_MS = 24 * 60 * 60 * 1_000; export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +function parseMs(value: string | null | undefined): number { + if (value == null) return Number.NEGATIVE_INFINITY; + const ms = Date.parse(value); + return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms; +} + function latestTimestamp(values: ReadonlyArray): string | null { let latest: string | null = null; let latestMs = Number.NEGATIVE_INFINITY; @@ -62,18 +68,26 @@ function pullRequestSettles( return pullRequestAt >= userAnchorAt; } +/** + * Any open pull request keeps the thread active. With only terminal ones, the most recently + * updated decides, so a thread whose stack merged layer by layer settles once the last layer + * lands. Threads without pull requests fall through to the inactivity rule. + */ export function shouldAutoSettleThread(input: { readonly thread: OrchestrationThreadShell; - readonly pullRequest: SettlementPullRequest | null; + readonly pullRequests: ReadonlyArray; readonly now: string; readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; }): boolean { - const { thread, pullRequest } = input; + const { thread, pullRequests } = input; if (!isAutoSettlementCandidate(thread, input.now)) return false; - if (pullRequest !== null) { - if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true; - if (pullRequest.state === "open") return false; + if (pullRequests.some((pullRequest) => pullRequest.state === "open")) return false; + if (pullRequests.length > 0) { + const latest = pullRequests.reduce((current, candidate) => + parseMs(candidate.updatedAt) > parseMs(current.updatedAt) ? candidate : current, + ); + if (pullRequestSettles(thread, latest, input.autoSettleOnMerge)) return true; } if (input.autoSettleAfterDays === null) return false; const activityAt = latestTimestamp([ diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 37ea643328c..0b3e78419f3 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -2,15 +2,15 @@ import { DEFAULT_SERVER_SETTINGS, ProjectId, ProviderInstanceId, - PullRequestOperationError, ThreadId, type OrchestrationCommand, type OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadShell, - type PullRequestSummary, type ServerSettings, type ServerSettingsPatch, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { assert, describe, it } from "@effect/vitest"; @@ -25,7 +25,6 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { GitManager } from "../git/GitManager.ts"; -import { PullRequestService } from "../pullRequest/PullRequestService.ts"; import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -78,6 +77,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-08-01T00:00:00.000Z", updatedAt: "2026-08-20T00:00:00.000Z", @@ -105,24 +105,33 @@ function makeSnapshot( }; } -function makePullRequestSummary(input: { - readonly projectId: ProjectId; - readonly repository: string; - readonly number: number; - readonly state: "open" | "closed" | "merged"; - readonly updatedAt?: string; -}): PullRequestSummary { +function makeLink( + number: number, + snapshot: Partial | null = {}, + overrides: Partial = {}, +): ThreadPullRequestLink { return { - provider: "github", - projectId: input.projectId, - repository: input.repository, - number: input.number, - title: "Pull request", - url: `https://example.test/${input.repository}/pull/${input.number}`, - state: input.state, - headBranch: "feature", - baseBranch: "main", - updatedAt: input.updatedAt ?? NOW, + host: "github.com", + repository: "owner/repository", + number, + url: `https://github.com/owner/repository/pull/${number}`, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + snapshot: + snapshot === null + ? null + : { + state: "open", + title: `Pull request ${number}`, + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + ...snapshot, + }, + stack: null, + ...overrides, }; } @@ -130,7 +139,6 @@ interface HarnessOptions { readonly snapshot: OrchestrationShellSnapshot; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; - readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; readonly onDispatch?: ( command: AutoSettleCommand, ) => Effect.Effect; @@ -147,15 +155,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> >([]); - const summaryCalls = yield* Ref.make< - ReadonlyArray<{ - readonly projectId: ProjectId; - readonly repository: string; - readonly number: number; - }> - >([]); - const summaryRecovery = yield* Ref.make>([]); - const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); @@ -169,24 +168,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), ); - const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => - Effect.gen(function* () { - yield* Ref.update(summaryCalls, (calls) => [...calls, input]); - yield* Ref.update(summaryRecovery, (values) => [ - ...values, - readOptions?.recoverTransientFailure, - ]); - return yield* ( - options.pullRequestSummary?.(input, readOptions) ?? - Effect.succeed( - makePullRequestSummary({ - ...input, - state: "open", - }), - ) - ); - }); - const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { if (command.type !== "thread.auto-settle") { return Effect.die(new Error(`Unexpected command: ${command.type}`)); @@ -217,7 +198,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: ), }), Layer.mock(GitManager)({ branchPullRequest }), - Layer.mock(PullRequestService)({ summary: pullRequestSummary }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch, @@ -236,8 +216,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: snapshotReads, commands, branchCalls, - summaryCalls, - summaryRecovery, updateSettings, layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), }; @@ -259,12 +237,6 @@ describe("ThreadSettlementReactor", () => { Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); - const linkedPullRequest = { - projectId: LINKED_PROJECT_ID, - repository: "owner/repository", - number: 42, - url: "https://example.test/owner/repository/pull/42", - } as const; const skipped = [ makeThread("pending-approval", { branch: "skip-approval", @@ -276,17 +248,15 @@ describe("ThreadSettlementReactor", () => { }), ]; const fixture = yield* makeHarness({ - snapshot: makeSnapshot( - [ - makeThread("inactive", { branch: "inactive-feature" }), - makeThread("closed-pr", { linkedPullRequest }), - ...skipped, - ], - [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], - ), + snapshot: makeSnapshot([ + makeThread("inactive", { branch: "inactive-feature" }), + makeThread("closed-pr", { + branch: "closed-feature", + pullRequests: [makeLink(42, { state: "closed" })], + }), + ...skipped, + ]), branchPullRequest: () => Effect.succeed(null), - pullRequestSummary: (input) => - Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })), }); yield* Effect.gen(function* () { @@ -314,13 +284,10 @@ describe("ThreadSettlementReactor", () => { }, ], ); + // The linked thread decided from its synced snapshot, without a host or git lookup. assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ { cwd: "/workspace/project", branch: "inactive-feature" }, ]); - assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ - { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, - ]); - assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -437,32 +404,26 @@ describe("ThreadSettlementReactor", () => { ), ); - it.effect("keeps an unknown pull request active and continues with other candidates", () => + it.effect("keeps linked threads active while any link is open or not yet synced", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); const fixture = yield* makeHarness({ - snapshot: makeSnapshot( - [ - makeThread("lookup-failed", { - linkedPullRequest: { - projectId: LINKED_PROJECT_ID, - repository: "owner/repository", - number: 9, - url: "https://example.test/owner/repository/pull/9", - }, - }), - makeThread("inactive-without-pr"), - ], - [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], - ), - pullRequestSummary: () => - Effect.fail( - new PullRequestOperationError({ - operation: "summary", - detail: "host unavailable", - }), - ), + snapshot: makeSnapshot([ + makeThread("open-link", { + branch: "saved-feature", + pullRequests: [makeLink(9, { state: "merged" }), makeLink(10, { state: "open" })], + }), + makeThread("unsynced-link", { + branch: "saved-feature", + pullRequests: [makeLink(11, null)], + }), + makeThread("dismissed-only", { + pullRequests: [makeLink(12, { state: "open" }, { source: "stack-dismissed" })], + }), + makeThread("inactive-without-pr"), + ]), + branchPullRequest: () => Effect.succeed({ state: "merged", updatedAt: NOW }), }); yield* Effect.gen(function* () { @@ -470,35 +431,68 @@ describe("ThreadSettlementReactor", () => { yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); assert.deepStrictEqual( - (yield* Ref.get(fixture.commands)).map((command) => command.threadId), - [ThreadId.make("inactive-without-pr")], + (yield* Ref.get(fixture.commands)) + .map((command) => command.threadId) + .sort((left, right) => left.localeCompare(right)), + [ThreadId.make("dismissed-only"), ThreadId.make("inactive-without-pr")], ); - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + // Linked threads never consult git; the tombstone-only thread has no links to read. + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); }).pipe(Effect.provide(fixture.layer)); }), ), ); - it.effect("keeps threads active when their pull request project is unavailable", () => + it.effect("settles a thread once every linked pull request is terminal", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("stack-merged", { + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + pullRequests: [ + makeLink(20, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }), + makeLink(21, { state: "merged", updatedAt: "2026-08-27T06:00:00.000Z" }), + ], + }), + makeThread("single-merged", { + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + pullRequests: [makeLink(22, { state: "merged", updatedAt: NOW })], + }), + ]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map((command) => command.threadId) + .sort((left, right) => left.localeCompare(right)), + [ThreadId.make("single-merged"), ThreadId.make("stack-merged")], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps threads active when their project is unavailable", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); - const linkedPullRequest = { - projectId: LINKED_PROJECT_ID, - repository: "owner/repository", - number: 10, - url: "https://example.test/owner/repository/pull/10", - } as const; const fixture = yield* makeHarness({ snapshot: makeSnapshot( - [ - makeThread("missing-own-project", { linkedPullRequest }), - makeThread("missing-branch-project", { branch: "saved-feature" }), - ], + [makeThread("missing-branch-project", { branch: "saved-feature" })], [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), - pullRequestSummary: (input) => - Effect.succeed(makePullRequestSummary({ ...input, state: "open" })), }); yield* Effect.gen(function* () { @@ -506,25 +500,16 @@ describe("ThreadSettlementReactor", () => { yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); - assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ - { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, - ]); assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); }).pipe(Effect.provide(fixture.layer)); }), ), ); - it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () => + it.effect("deduplicates saved-branch lookups and reads linked threads from snapshots", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); - const linkedPullRequest = { - projectId: LINKED_PROJECT_ID, - repository: "owner/repository", - number: 77, - url: "https://example.test/owner/repository/pull/77", - } as const; const fixture = yield* makeHarness({ snapshot: makeSnapshot( [ @@ -536,17 +521,12 @@ describe("ThreadSettlementReactor", () => { branch: "saved-feature", worktreePath: "/deleted/worktree-two", }), - makeThread("linked-one", { linkedPullRequest }), - makeThread("linked-two", { linkedPullRequest }), - ], - [ - makeProject(PROJECT_ID, "/workspace/project-root"), - makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), + makeThread("linked-one", { pullRequests: [makeLink(77, { state: "merged" })] }), + makeThread("linked-two", { pullRequests: [makeLink(77, { state: "merged" })] }), ], + [makeProject(PROJECT_ID, "/workspace/project-root")], ), branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), - pullRequestSummary: (input) => - Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); yield* Effect.gen(function* () { @@ -556,9 +536,6 @@ describe("ThreadSettlementReactor", () => { assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ { cwd: "/workspace/project-root", branch: "saved-feature" }, ]); - assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ - { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, - ]); assert.deepStrictEqual( new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), new Set([ diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index edd52a8abd0..bc1ee5c2f3f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -1,5 +1,6 @@ import { CommandId } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -11,7 +12,6 @@ import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; -import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; @@ -35,7 +35,6 @@ export const make = Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settingsService = yield* ServerSettings.ServerSettingsService; const git = yield* GitManager.GitManager; - const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { @@ -43,14 +42,11 @@ export const make = Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + // Linked threads read their pull requests from the synced snapshots, so + // only unlinked threads share a saved-branch lookup. const lookupKey = (thread: (typeof candidates)[number]) => { - if (thread.linkedPullRequest != null) { - return JSON.stringify([ - "linked", - thread.linkedPullRequest.projectId, - thread.linkedPullRequest.repository, - thread.linkedPullRequest.number, - ]); + if (visibleThreadPullRequests(thread.pullRequests).length > 0) { + return JSON.stringify(["linked", thread.id]); } if (thread.branch === null) return JSON.stringify(["none", thread.id]); const project = projects.get(thread.projectId); @@ -62,39 +58,37 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(candidates, lookupKey); - const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( + const pullRequestsFor = Effect.fn("ThreadSettlementReactor.pullRequestsFor")(function* ( thread: (typeof candidates)[number], ) { - if (thread.linkedPullRequest != null) { - if (!projects.has(thread.linkedPullRequest.projectId)) { - return yield* Effect.die(new Error("linked pull request project not found")); - } - const summary = yield* pullRequests.summary( - { - projectId: thread.linkedPullRequest.projectId, - repository: thread.linkedPullRequest.repository, - number: thread.linkedPullRequest.number, - }, - { recoverTransientFailure: false }, + const links = visibleThreadPullRequests(thread.pullRequests); + if (links.length > 0) { + // A link the sync reactor has not snapshotted yet counts as open: the + // thread stays active until the host has said otherwise. + return links.map( + (link): SettlementPullRequest => + link.snapshot === null + ? { state: "open", updatedAt: null } + : { state: link.snapshot.state, updatedAt: link.snapshot.updatedAt }, ); - return { - state: summary.state, - updatedAt: summary.updatedAt, - } satisfies SettlementPullRequest; } - if (thread.branch === null) return null; + if (thread.branch === null) return [] as ReadonlyArray; const project = projects.get(thread.projectId); if (project === undefined) { return yield* Effect.die(new Error("thread project not found")); } - return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + const pullRequest = yield* git.branchPullRequest({ + cwd: project.workspaceRoot, + branch: thread.branch, + }); + return pullRequest === null ? [] : [pullRequest satisfies SettlementPullRequest]; }); yield* Effect.forEach( groups.values(), (group) => Effect.gen(function* () { - const pullRequest = yield* pullRequestFor(group[0]!); + const pullRequests = yield* pullRequestsFor(group[0]!); yield* Effect.forEach( group, (thread) => @@ -104,7 +98,7 @@ export const make = Effect.gen(function* () { if ( !shouldAutoSettleThread({ thread, - pullRequest, + pullRequests, now: decisionNow, autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9aaeba94342..431a7650e57 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -64,6 +64,7 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -89,6 +90,7 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index 4ad00ba994b..7d6f55d8f62 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -36,6 +36,7 @@ function makeReadModel(input: { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.pullRequests.test.ts b/apps/server/src/orchestration/decider.pullRequests.test.ts new file mode 100644 index 00000000000..faedfd437c7 --- /dev/null +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -0,0 +1,297 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationReadModel, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +type PlannedEvent = Omit; + +function expectSingleEvent( + decided: PlannedEvent | ReadonlyArray, + type: Type, +): Omit, "sequence"> { + const event = Array.isArray(decided) ? decided[0] : (decided as PlannedEvent); + if (event === undefined || event.type !== type) { + throw new Error(`expected ${type}, got ${String(event?.type)}`); + } + return event as Omit, "sequence">; +} + +const NOW = "2026-01-01T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); + +function makeLink(overrides: Partial = {}): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: NOW, + snapshot: null, + stack: null, + ...overrides, + }; +} + +function makeReadModel(pullRequests: ReadonlyArray): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const snapshot: ThreadPullRequestSnapshot = { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, +}; + +it.layer(NodeServices.layer)("pull request link decider", (it) => { + it.effect("links a pull request with a normalized key and empty host state", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-link"), + threadId: THREAD_ID, + host: " GitHub.com ", + repository: "T3Tools/T3Code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + }, + readModel: makeReadModel([]), + }); + expect(Array.isArray(decided)).toBe(false); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + expect(event.payload.link).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: event.payload.updatedAt, + snapshot: null, + stack: null, + }); + expect(event.payload.updatedAt).not.toBe(NOW); + }), + ); + + it.effect("rejects linking a pull request that is already linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-link-dup"), + threadId: THREAD_ID, + host: "GITHUB.COM", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "agent", + }, + readModel: makeReadModel([makeLink()]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("re-linking a dismissed stack member un-dismisses it", () => + Effect.gen(function* () { + const dismissed = makeLink({ + source: "stack-dismissed", + snapshot, + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [{ number: 42, headBranch: "feat/links", state: "open" }], + }, + }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-relink"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + }, + readModel: makeReadModel([dismissed]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + // Host state survives the flip; only the source changes. + expect(event.payload.link).toEqual({ ...dismissed, source: "manual" }); + }), + ); + + it.effect("rejects a stack sync re-adding a dismissed stack member", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-stack-readd"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "stack", + }, + readModel: makeReadModel([makeLink({ source: "stack-dismissed" })]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("unlinks a manual pull request", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink"), + threadId: THREAD_ID, + host: "GitHub.com", + repository: "t3tools/t3code", + number: 42, + }, + readModel: makeReadModel([makeLink()]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-unlinked"); + expect(event.payload).toMatchObject({ + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + }); + }), + ); + + it.effect("unlinking a stack member leaves a stack-dismissed tombstone", () => + Effect.gen(function* () { + const member = makeLink({ source: "stack", snapshot }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink-stack"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + }, + readModel: makeReadModel([member]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + expect(event.payload.link).toEqual({ ...member, source: "stack-dismissed" }); + }), + ); + + it.effect("rejects unlinking a pull request that is not linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink-missing"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 7, + }, + readModel: makeReadModel([makeLink()]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects syncing a pull request that is not linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.sync", + commandId: CommandId.make("cmd-sync-missing"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }, + readModel: makeReadModel([]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("sync emits the host snapshot for a linked pull request", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.sync", + commandId: CommandId.make("cmd-sync"), + threadId: THREAD_ID, + host: "GitHub.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }, + readModel: makeReadModel([makeLink()]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-synced"); + expect(event.payload).toMatchObject({ + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index e470ba33c79..62cc968cbbc 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -47,6 +47,7 @@ function makeReadModel( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 1012240b18a..505bb79df03 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -41,6 +41,7 @@ function makeReadModel(input: { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index b29c8ffda67..c032f33d0d0 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: UPDATED_AT, updatedAt: UPDATED_AT, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2e863cc1cfd..b7b9e489554 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,7 +4,10 @@ import { type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, + type ThreadPullRequestKey, + type ThreadPullRequestLink, } from "@t3tools/contracts"; +import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequests"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -112,6 +115,22 @@ function hasQueuedTurnStartForThread( ); } +/** Link identity as stored: host and repository lowercased so equal keys persist equal. */ +function normalizePullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey { + return { + host: key.host.trim().toLowerCase(), + repository: key.repository.trim().toLowerCase(), + number: key.number, + }; +} + +function findPullRequestLink( + thread: Pick, + key: ThreadPullRequestKey, +): ThreadPullRequestLink | undefined { + return thread.pullRequests.find((link) => threadPullRequestKeysEqual(link, key)); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -814,9 +833,129 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), - ...(command.linkedPullRequest !== undefined - ? { linkedPullRequest: command.linkedPullRequest } - : {}), + updatedAt: occurredAt, + }, + }; + } + + case "thread.pull-request.link": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizePullRequestKey(command); + const existing = findPullRequestLink(thread, key); + // An explicit link on a dismissed stack member un-dismisses it; any + // other duplicate is a no-op the engine would reject as zero-event. + const undismisses = + existing?.source === "stack-dismissed" && + (command.source === "manual" || command.source === "agent" || command.source === "created"); + if (existing !== undefined && !undismisses) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pull-request-linked", + payload: { + threadId: command.threadId, + link: + existing !== undefined + ? { ...existing, url: command.url, source: command.source } + : { + ...key, + url: command.url, + source: command.source, + linkedAt: occurredAt, + snapshot: null, + stack: null, + }, + updatedAt: occurredAt, + }, + }; + } + + case "thread.pull-request.unlink": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizePullRequestKey(command); + const existing = findPullRequestLink(thread, key); + if (existing === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + const eventBase = yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }); + // A host-discovered stack member leaves a tombstone instead of a hole, + // so the next sync does not re-add what the user just removed. + if (existing.source === "stack") { + return { + ...eventBase, + type: "thread.pull-request-linked", + payload: { + threadId: command.threadId, + link: { ...existing, source: "stack-dismissed" }, + updatedAt: occurredAt, + }, + }; + } + return { + ...eventBase, + type: "thread.pull-request-unlinked", + payload: { + threadId: command.threadId, + ...key, + updatedAt: occurredAt, + }, + }; + } + + case "thread.pull-request.sync": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizePullRequestKey(command); + if (findPullRequestLink(thread, key) === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pull-request-synced", + payload: { + threadId: command.threadId, + ...key, + snapshot: command.snapshot, + stack: command.stack, updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/projector.pullRequests.test.ts b/apps/server/src/orchestration/projector.pullRequests.test.ts new file mode 100644 index 00000000000..07d28472c25 --- /dev/null +++ b/apps/server/src/orchestration/projector.pullRequests.test.ts @@ -0,0 +1,360 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, + type OrchestrationReadModel, + type RepositoryIdentity, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const LATER = "2026-01-02T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); +const PROJECT_ID = ProjectId.make("project-1"); + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: NOW, + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +function makeLink(overrides: Partial = {}): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: NOW, + snapshot: null, + stack: null, + ...overrides, + }; +} + +const snapshot: ThreadPullRequestSnapshot = { + state: "merged", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: LATER, + syncedAt: LATER, +}; + +const createThread = (model: OrchestrationReadModel) => + projectEvent( + model, + makeEvent({ + sequence: model.snapshotSequence + 1, + type: "thread.created", + payload: { + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: NOW, + updatedAt: NOW, + }, + }), + ); + +const createProject = (model: OrchestrationReadModel, repositoryIdentity: RepositoryIdentity) => + projectEvent(model, { + ...makeEvent({ + sequence: model.snapshotSequence + 1, + type: "project.created", + payload: { + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + }, + }), + aggregateKind: "project", + aggregateId: PROJECT_ID, + }).pipe( + Effect.map((next) => ({ + ...next, + projects: next.projects.map((project) => + project.id === PROJECT_ID ? { ...project, repositoryIdentity } : project, + ), + })), + ); + +it.effect("seeds threads with no pull requests", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + expect(created.threads[0]?.pullRequests).toEqual([]); + expect(created.threads[0]?.linkedPullRequest ?? null).toBeNull(); + }), +); + +it.effect("projects link, sync, and unlink onto the thread", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const link = makeLink(); + + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link, updatedAt: LATER }, + }), + ); + expect(linked.threads[0]?.pullRequests).toEqual([link]); + expect(linked.threads[0]?.updatedAt).toBe(LATER); + // The legacy field is derived from the array so old clients keep working. + expect(linked.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }); + + // A second link for the same key replaces in place (used for un-dismiss + // and stack tombstones), never duplicates. + const relinked = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.pull-request-linked", + payload: { + threadId: THREAD_ID, + link: { ...link, host: "GITHUB.COM", source: "agent" }, + updatedAt: LATER, + }, + }), + ); + expect(relinked.threads[0]?.pullRequests).toHaveLength(1); + expect(relinked.threads[0]?.pullRequests[0]?.source).toBe("agent"); + + const synced = yield* projectEvent( + relinked, + makeEvent({ + sequence: 4, + type: "thread.pull-request-synced", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: LATER, + }, + }), + ); + expect(synced.threads[0]?.pullRequests[0]?.snapshot).toEqual(snapshot); + + const unlinked = yield* projectEvent( + synced, + makeEvent({ + sequence: 5, + type: "thread.pull-request-unlinked", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + updatedAt: LATER, + }, + }), + ); + expect(unlinked.threads[0]?.pullRequests).toEqual([]); + expect(unlinked.threads[0]?.linkedPullRequest).toBeNull(); + }), +); + +it.effect("ignores a sync for a pull request that is no longer linked", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const other = makeLink({ number: 7, url: "https://github.com/t3tools/t3code/pull/7" }); + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link: other, updatedAt: NOW }, + }), + ); + const synced = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.pull-request-synced", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: LATER, + }, + }), + ); + expect(synced.threads[0]?.pullRequests).toEqual([other]); + expect(synced.threads[0]?.updatedAt).toBe(NOW); + }), +); + +it.effect("mirrors legacy meta-updated links into pullRequests using the project host", () => + Effect.gen(function* () { + const withProject = yield* createProject(createEmptyReadModel(NOW), { + canonicalKey: "GitHub.com/t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + }); + const created = yield* createThread(withProject); + const agentLink = makeLink({ + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + source: "agent", + }); + const withAgentLink = yield* projectEvent( + created, + makeEvent({ + sequence: 3, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link: agentLink, updatedAt: NOW }, + }), + ); + + const legacyLinked = yield* projectEvent( + withAgentLink, + makeEvent({ + sequence: 4, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "T3Tools/T3Code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }, + updatedAt: LATER, + }, + }), + ); + expect(legacyLinked.threads[0]?.pullRequests).toEqual([ + agentLink, + { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: LATER, + snapshot: null, + stack: null, + }, + ]); + // Two open links read as a stack; the derived field points at the top. + expect(legacyLinked.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }); + + // Null clears only the manual link; the agent's stays. + const legacyCleared = yield* projectEvent( + legacyLinked, + makeEvent({ + sequence: 5, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, linkedPullRequest: null, updatedAt: LATER }, + }), + ); + expect(legacyCleared.threads[0]?.pullRequests).toEqual([agentLink]); + expect(legacyCleared.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + }); + }), +); + +it.effect("falls back to the link URL host when the project has no repository identity", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const legacyLinked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://GitLab.example.com/t3tools/t3code/-/merge_requests/42", + }, + updatedAt: LATER, + }, + }), + ); + expect(legacyLinked.threads[0]?.pullRequests[0]?.host).toBe("gitlab.example.com"); + }), +); + +it.effect("leaves pullRequests alone when meta-updated carries no legacy link", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const link = makeLink(); + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link, updatedAt: NOW }, + }), + ); + const retitled = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, title: "Renamed", updatedAt: LATER }, + }), + ); + expect(retitled.threads[0]?.title).toBe("Renamed"); + expect(retitled.threads[0]?.pullRequests).toEqual([link]); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index dad3d07370f..c03c50d4fdb 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -85,6 +85,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index dab3f8d52f1..22dc85867a4 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,10 +1,22 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { + OrchestrationEvent, + OrchestrationProject, + OrchestrationReadModel, + ThreadId, + ThreadLinkedPullRequest, + ThreadPullRequestKey, + ThreadPullRequestLink, +} from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { + legacyLinkedPullRequestOf, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -25,6 +37,9 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadPullRequestLinkedPayload, + ThreadPullRequestSyncedPayload, + ThreadPullRequestUnlinkedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -76,6 +91,74 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } +/** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */ +function pullRequestsPatch( + thread: Pick, + pullRequests: ReadonlyArray, +): Pick { + return { + pullRequests, + linkedPullRequest: legacyLinkedPullRequestOf(pullRequests, thread.projectId), + }; +} + +function upsertPullRequestLink( + pullRequests: ReadonlyArray, + link: ThreadPullRequestLink, +): ReadonlyArray { + const index = pullRequests.findIndex((entry) => threadPullRequestKeysEqual(entry, link)); + return index === -1 + ? [...pullRequests, link] + : pullRequests.map((entry, entryIndex) => (entryIndex === index ? link : entry)); +} + +function removePullRequestLink( + pullRequests: ReadonlyArray, + key: ThreadPullRequestKey, +): ReadonlyArray { + return pullRequests.filter((entry) => !threadPullRequestKeysEqual(entry, key)); +} + +/** + * Host for a legacy `linkedPullRequest` being replayed into the link array. + * Legacy links never carried one; the project's canonical key + * (`//`) is the best witness, then the link URL. + */ +function legacyPullRequestHost( + project: OrchestrationProject | undefined, + linked: ThreadLinkedPullRequest, +): string { + const canonicalHost = project?.repositoryIdentity?.canonicalKey.split("/")[0]; + if (canonicalHost) return canonicalHost.toLowerCase(); + try { + return new URL(linked.url).hostname.toLowerCase(); + } catch { + return "unknown"; + } +} + +function legacyLinkToPullRequests( + thread: Pick, + project: OrchestrationProject | undefined, + linked: ThreadLinkedPullRequest | null, + linkedAt: string, +): ReadonlyArray { + // The legacy field held one user-chosen link, so null clears exactly the + // manual ones and leaves created/agent/stack links alone. + const withoutManual = thread.pullRequests.filter((entry) => entry.source !== "manual"); + if (linked === null) return withoutManual; + return upsertPullRequestLink(withoutManual, { + host: legacyPullRequestHost(project, linked), + repository: linked.repository.toLowerCase(), + number: linked.number, + url: linked.url, + source: "manual", + linkedAt, + snapshot: null, + stack: null, + }); +} + function decodeForEvent( schema: Schema.Decoder, value: unknown, @@ -299,6 +382,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + pullRequests: [], latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -458,24 +542,117 @@ export function projectEvent( case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.titleRegeneration !== undefined - ? { titleRegeneration: payload.titleRegeneration } - : {}), - ...(payload.modelSelection !== undefined - ? { modelSelection: payload.modelSelection } - : {}), - ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), - ...(payload.linkedPullRequest !== undefined - ? { linkedPullRequest: payload.linkedPullRequest } - : {}), - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + // Legacy single-link events replay into the link array so the + // derived linkedPullRequest and pullRequests never disagree. + const legacyLinkPatch = + thread !== undefined && payload.linkedPullRequest !== undefined + ? pullRequestsPatch( + thread, + legacyLinkToPullRequests( + thread, + nextBase.projects.find((project) => project.id === thread.projectId), + payload.linkedPullRequest, + payload.updatedAt, + ), + ) + : {}; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.titleRegeneration !== undefined + ? { titleRegeneration: payload.titleRegeneration } + : {}), + ...(payload.modelSelection !== undefined + ? { modelSelection: payload.modelSelection } + : {}), + ...(payload.branch !== undefined ? { branch: payload.branch } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...legacyLinkPatch, + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-linked": + return decodeForEvent( + ThreadPullRequestLinkedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch( + thread, + upsertPullRequestLink(thread.pullRequests, payload.link), + ), + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-unlinked": + return decodeForEvent( + ThreadPullRequestUnlinkedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch(thread, removePullRequestLink(thread.pullRequests, payload)), + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-synced": + return decodeForEvent( + ThreadPullRequestSyncedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + // A sync for a link the user removed in the meantime is stale; drop it. + if ( + !thread || + !thread.pullRequests.some((link) => threadPullRequestKeysEqual(link, payload)) + ) { + return nextBase; + } + const pullRequests = thread.pullRequests.map((link) => + threadPullRequestKeysEqual(link, payload) + ? { ...link, snapshot: payload.snapshot, stack: payload.stack } + : link, + ); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch(thread, pullRequests), + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.runtime-mode-set": diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index adc3ca40cbb..9c6e425336c 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -8,13 +8,19 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; +import { ProjectionThreadPullRequestRepositoryLive } from "./ProjectionThreadPullRequests.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import { + ProjectionThreadPullRequestRepository, + type ProjectionThreadPullRequest, +} from "../Services/ProjectionThreadPullRequests.ts"; const projectionRepositoriesLayer = it.layer( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadPullRequestRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), ); @@ -258,4 +264,117 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); }), ); + + it.effect("round-trips pull request links with JSON snapshot and stack columns", () => + Effect.gen(function* () { + const pullRequests = yield* ProjectionThreadPullRequestRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-pr-links"); + const otherThreadId = ThreadId.make("thread-pr-links-other"); + + const unsynced: ProjectionThreadPullRequest = { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-03-24T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const synced: ProjectionThreadPullRequest = { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "stack", + linkedAt: "2026-03-23T00:00:00.000Z", + snapshot: { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-03-23T01:00:00.000Z", + syncedAt: "2026-03-23T02:00:00.000Z", + }, + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/pingdotgg/t3code/stack/1", + base: "main", + layers: [ + { number: 7, headBranch: "feat/links", state: "open" }, + { number: 42, headBranch: "feat/links-ui", state: "open" }, + ], + }, + }; + const sharedOnOtherThread: ProjectionThreadPullRequest = { + ...unsynced, + threadId: otherThreadId, + source: "agent", + linkedAt: "2026-03-25T00:00:00.000Z", + }; + + yield* pullRequests.upsert(unsynced); + yield* pullRequests.upsert(synced); + yield* pullRequests.upsert(sharedOnOtherThread); + + const rawRows = yield* sql<{ + readonly number: number; + readonly snapshotJson: string | null; + readonly stackJson: string | null; + }>` + SELECT number, snapshot_json AS "snapshotJson", stack_json AS "stackJson" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY number ASC + `; + assert.strictEqual(rawRows[0]?.number, 7); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(rawRows[0]?.snapshotJson ?? "null"), synced.snapshot); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(rawRows[0]?.stackJson ?? "null"), synced.stack); + assert.strictEqual(rawRows[1]?.snapshotJson, null); + assert.strictEqual(rawRows[1]?.stackJson, null); + + // Ordered by linked_at, then number. + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, unsynced]); + + // One pull request across threads, ordered by linked_at. + assert.deepStrictEqual( + yield* pullRequests.listByPullRequest({ + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + }), + [unsynced, sharedOnOtherThread], + ); + + // Upsert on the composite key replaces snapshot and stack in place. + const resynced = { ...unsynced, snapshot: synced.snapshot, stack: null } as const; + yield* pullRequests.upsert(resynced); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, resynced]); + + yield* pullRequests.delete({ + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [resynced]); + + yield* pullRequests.deleteByThreadIdAndSource({ threadId, source: "manual" }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), [ + sharedOnOtherThread, + ]); + + yield* pullRequests.deleteByThreadId({ threadId: otherThreadId }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), []); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts new file mode 100644 index 00000000000..216942f085a --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts @@ -0,0 +1,194 @@ +import { ThreadPullRequestSnapshot, ThreadPullRequestStack } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionThreadPullRequestInput, + DeleteProjectionThreadPullRequestsBySourceInput, + DeleteProjectionThreadPullRequestsInput, + ListProjectionThreadPullRequestsByPullRequestInput, + ListProjectionThreadPullRequestsInput, + ProjectionThreadPullRequest, + ProjectionThreadPullRequestRepository, + type ProjectionThreadPullRequestRepositoryShape, +} from "../Services/ProjectionThreadPullRequests.ts"; + +const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields( + Struct.assign({ + snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), + stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), + }), +); + +const makeProjectionThreadPullRequestRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadPullRequestRow = SqlSchema.void({ + Request: ProjectionThreadPullRequest, + execute: (row) => sql` + INSERT INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES ( + ${row.threadId}, + ${row.host}, + ${row.repository}, + ${row.number}, + ${row.url}, + ${row.source}, + ${row.linkedAt}, + ${row.snapshot === null ? null : JSON.stringify(row.snapshot)}, + ${row.stack === null ? null : JSON.stringify(row.stack)} + ) + ON CONFLICT (thread_id, host, repository, number) + DO UPDATE SET + url = excluded.url, + source = excluded.source, + linked_at = excluded.linked_at, + snapshot_json = excluded.snapshot_json, + stack_json = excluded.stack_json + `, + }); + + const listProjectionThreadPullRequestRows = SqlSchema.findAll({ + Request: ListProjectionThreadPullRequestsInput, + Result: ProjectionThreadPullRequestDbRow, + execute: ({ threadId }) => sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY linked_at ASC, number ASC + `, + }); + + const listProjectionThreadPullRequestRowsByPullRequest = SqlSchema.findAll({ + Request: ListProjectionThreadPullRequestsByPullRequestInput, + Result: ProjectionThreadPullRequestDbRow, + execute: ({ host, repository, number }) => sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE host = ${host} + AND repository = ${repository} + AND number = ${number} + ORDER BY linked_at ASC, thread_id ASC + `, + }); + + const deleteProjectionThreadPullRequestRow = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestInput, + execute: ({ threadId, host, repository, number }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + AND host = ${host} + AND repository = ${repository} + AND number = ${number} + `, + }); + + const deleteProjectionThreadPullRequestRows = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestsInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + `, + }); + + const deleteProjectionThreadPullRequestRowsBySource = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestsBySourceInput, + execute: ({ threadId, source }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + AND source = ${source} + `, + }); + + const upsert: ProjectionThreadPullRequestRepositoryShape["upsert"] = (row) => + upsertProjectionThreadPullRequestRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadPullRequestRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"), + ), + ); + + const listByPullRequest: ProjectionThreadPullRequestRepositoryShape["listByPullRequest"] = ( + input, + ) => + listProjectionThreadPullRequestRowsByPullRequest(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByPullRequest:query"), + ), + ); + + const deleteLink: ProjectionThreadPullRequestRepositoryShape["delete"] = (input) => + deleteProjectionThreadPullRequestRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query")), + ); + + const deleteByThreadId: ProjectionThreadPullRequestRepositoryShape["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.deleteByThreadId:query"), + ), + ); + + const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepositoryShape["deleteByThreadIdAndSource"] = + (input) => + deleteProjectionThreadPullRequestRowsBySource(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadPullRequestRepository.deleteByThreadIdAndSource:query", + ), + ), + ); + + return { + upsert, + listByThreadId, + listByPullRequest, + delete: deleteLink, + deleteByThreadId, + deleteByThreadIdAndSource, + } satisfies ProjectionThreadPullRequestRepositoryShape; +}); + +export const ProjectionThreadPullRequestRepositoryLive = Layer.effect( + ProjectionThreadPullRequestRepository, + makeProjectionThreadPullRequestRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 17a160037e7..e47161212e9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -57,6 +57,7 @@ import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; +import Migration0046 from "./Migrations/046_ProjectionThreadPullRequests.ts"; /** * Migration loader with all migrations defined inline. @@ -114,6 +115,7 @@ export const migrationEntries = [ [43, "ProjectionThreadsUnsettledAt", Migration0043], [44, "ClearAutomaticProjectModelDefaults", Migration0044], [45, "ProjectionProjectsAutoPull", Migration0045], + [46, "ProjectionThreadPullRequests", Migration0046], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.test.ts b/apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.test.ts new file mode 100644 index 00000000000..3530f1ea1c7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.test.ts @@ -0,0 +1,154 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +interface PullRequestRow { + readonly threadId: string; + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; + readonly source: string; + readonly linkedAt: string; + readonly snapshotJson: string | null; + readonly stackJson: string | null; +} + +layer("046_ProjectionThreadPullRequests", (it) => { + it.effect("creates the link table and backfills legacy single links", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 45 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '[]', + '2026-03-01T00:00:00.000Z', + '2026-03-01T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + linked_pull_request_json, + created_at, + updated_at + ) + VALUES + ( + 'thread-github', + 'project-1', + 'GitHub link', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"projectId":"project-1","repository":"PingDotGG/T3Code","number":42,"url":"https://GitHub.com/pingdotgg/t3code/pull/42"}', + '2026-03-01T00:00:01.000Z', + '2026-03-02T00:00:00.000Z' + ), + ( + 'thread-bad-url', + 'project-1', + 'Unparseable URL', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"projectId":"project-1","repository":"acme/widgets","number":7,"url":"not a url"}', + '2026-03-01T00:00:02.000Z', + '2026-03-03T00:00:00.000Z' + ), + ( + 'thread-malformed', + 'project-1', + 'Malformed JSON', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"repository":"acme/widgets"}', + '2026-03-01T00:00:03.000Z', + '2026-03-04T00:00:00.000Z' + ), + ( + 'thread-unlinked', + 'project-1', + 'No link', + '{"instanceId":"codex","model":"gpt-5.4"}', + NULL, + '2026-03-01T00:00:04.000Z', + '2026-03-05T00:00:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 46 }); + + const rows = yield* sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshotJson", + stack_json AS "stackJson" + FROM projection_thread_pull_requests + ORDER BY thread_id ASC + `; + + assert.deepStrictEqual(rows, [ + { + threadId: "thread-bad-url", + host: "unknown", + repository: "acme/widgets", + number: 7, + url: "not a url", + source: "manual", + linkedAt: "2026-03-03T00:00:00.000Z", + snapshotJson: null, + stackJson: null, + }, + { + threadId: "thread-github", + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://GitHub.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-03-02T00:00:00.000Z", + snapshotJson: null, + stackJson: null, + }, + ]); + + // The legacy column stays so a rollback keeps its data. + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_thread_pull_requests) + `; + assert.ok(indexes.some((index) => index.name === "idx_projection_thread_pull_requests_pr")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.ts new file mode 100644 index 00000000000..82c8dc19932 --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_ProjectionThreadPullRequests.ts @@ -0,0 +1,103 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +interface LegacyLinkedThreadRow { + readonly threadId: string; + readonly updatedAt: string; + readonly linkedPullRequestJson: string; +} + +interface LegacyLinkedPullRequest { + readonly repository: string; + readonly number: number; + readonly url: string; +} + +function parseLegacyLinkedPullRequest(json: string): LegacyLinkedPullRequest | null { + try { + const value: unknown = JSON.parse(json); + if (typeof value !== "object" || value === null) return null; + const { repository, number, url } = value as Record; + if (typeof repository !== "string" || repository.trim().length === 0) return null; + if (typeof number !== "number" || !Number.isInteger(number) || number < 1) return null; + if (typeof url !== "string" || url.trim().length === 0) return null; + return { repository, number, url }; + } catch { + return null; + } +} + +/** + * Projects never persisted their repository identity, so the pull request URL + * is the only host source available to a migration. + */ +function hostFromUrl(url: string): string { + try { + const hostname = new URL(url).hostname.trim().toLowerCase(); + return hostname.length > 0 ? hostname : "unknown"; + } catch { + return "unknown"; + } +} + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_pull_requests ( + thread_id TEXT NOT NULL, + host TEXT NOT NULL, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + url TEXT NOT NULL, + source TEXT NOT NULL, + linked_at TEXT NOT NULL, + snapshot_json TEXT, + stack_json TEXT, + PRIMARY KEY (thread_id, host, repository, number) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_pull_requests_pr + ON projection_thread_pull_requests(host, repository, number) + `; + + const legacyRows = yield* sql` + SELECT + thread_id AS "threadId", + updated_at AS "updatedAt", + linked_pull_request_json AS "linkedPullRequestJson" + FROM projection_threads + WHERE linked_pull_request_json IS NOT NULL + `; + + for (const row of legacyRows) { + const linked = parseLegacyLinkedPullRequest(row.linkedPullRequestJson); + if (linked === null) continue; + yield* sql` + INSERT OR IGNORE INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES ( + ${row.threadId}, + ${hostFromUrl(linked.url)}, + ${linked.repository.trim().toLowerCase()}, + ${linked.number}, + ${linked.url}, + 'manual', + ${row.updatedAt}, + NULL, + NULL + ) + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts new file mode 100644 index 00000000000..2a1b37a0a15 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts @@ -0,0 +1,84 @@ +import { + IsoDateTime, + PositiveInt, + ThreadId, + ThreadPullRequestKey, + ThreadPullRequestLinkSource, + ThreadPullRequestSnapshot, + ThreadPullRequestStack, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadPullRequest = Schema.Struct({ + threadId: ThreadId, + host: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, + source: ThreadPullRequestLinkSource, + linkedAt: IsoDateTime, + snapshot: Schema.NullOr(ThreadPullRequestSnapshot), + stack: Schema.NullOr(ThreadPullRequestStack), +}); +export type ProjectionThreadPullRequest = typeof ProjectionThreadPullRequest.Type; + +export const ListProjectionThreadPullRequestsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadPullRequestsInput = + typeof ListProjectionThreadPullRequestsInput.Type; + +export const ListProjectionThreadPullRequestsByPullRequestInput = ThreadPullRequestKey; +export type ListProjectionThreadPullRequestsByPullRequestInput = + typeof ListProjectionThreadPullRequestsByPullRequestInput.Type; + +export const DeleteProjectionThreadPullRequestInput = Schema.Struct({ + threadId: ThreadId, + ...ThreadPullRequestKey.fields, +}); +export type DeleteProjectionThreadPullRequestInput = + typeof DeleteProjectionThreadPullRequestInput.Type; + +export const DeleteProjectionThreadPullRequestsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadPullRequestsInput = + typeof DeleteProjectionThreadPullRequestsInput.Type; + +export const DeleteProjectionThreadPullRequestsBySourceInput = Schema.Struct({ + threadId: ThreadId, + source: ThreadPullRequestLinkSource, +}); +export type DeleteProjectionThreadPullRequestsBySourceInput = + typeof DeleteProjectionThreadPullRequestsBySourceInput.Type; + +export interface ProjectionThreadPullRequestRepositoryShape { + readonly upsert: ( + row: ProjectionThreadPullRequest, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadPullRequestsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByPullRequest: ( + input: ListProjectionThreadPullRequestsByPullRequestInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly delete: ( + input: DeleteProjectionThreadPullRequestInput, + ) => Effect.Effect; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadPullRequestsInput, + ) => Effect.Effect; + readonly deleteByThreadIdAndSource: ( + input: DeleteProjectionThreadPullRequestsBySourceInput, + ) => Effect.Effect; +} + +export class ProjectionThreadPullRequestRepository extends Context.Service< + ProjectionThreadPullRequestRepository, + ProjectionThreadPullRequestRepositoryShape +>()("t3/persistence/Services/ProjectionThreadPullRequests/ProjectionThreadPullRequestRepository") {} diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d924f3b2f47..6526f1a201e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1724,6 +1724,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( "-c", 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', ], + browserToolsAvailable: mcpSession.preview, } : {}), }; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 4b88b7ce01c..d2891e906d7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + /** + * Whether the attached `t3-code` MCP server exposes the preview tools. The + * server is attached for every session now (the pull request toolkit is + * always on), so its presence in `appServerArgs` no longer implies browser + * access; the credential's own capability decides the developer prompt. + */ + readonly browserToolsAvailable?: boolean; } export interface CodexSessionRuntimeSendTurnInput { @@ -2322,10 +2329,12 @@ export const makeCodexSessionRuntime = ( ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - // Derived from the session's own MCP configuration rather than the + // Derived from the session's own credential rather than the // setting, so the prompt describes the tools this turn actually // has even if the setting changed after the session started. - browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs), + browserToolsAvailable: + hasConfiguredMcpServer(options.appServerArgs) && + (options.browserToolsAvailable ?? true), }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 84157388fcb..f0059bc89bf 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -2548,11 +2548,9 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { }); describe("agent browser access", () => { - const revokedThreads: Array = []; - const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) => Effect.gen(function* () { - const issued: Array = []; + const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = []; const codex = makeFakeCodexAdapter(); const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, @@ -2567,10 +2565,9 @@ describe("agent browser access", () => { const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { - issued.push(request.threadId); + issued.push({ threadId: request.threadId, preview: request.preview }); return undefined; }), - revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)), }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), @@ -2598,38 +2595,26 @@ describe("agent browser access", () => { return issued; }); - // Credential issuance is the observable that matters: it is the only place a - // credential is minted, and `/mcp` accepts nothing else, so withholding it is - // what actually denies every provider and external MCP client. - it.effect("requests no MCP credential when agent browser access is off", () => - Effect.gen(function* () { - const issued = yield* startSessionWith(false, asThreadId("thread-browser-off")); - - assert.deepEqual(issued, []); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("revokes an already-issued credential when access is off", () => + // The capability on the credential is the observable that matters: a session + // always gets a credential (the pull request toolkit is never withheld), and + // `preview` on it is what actually grants or denies the browser tools. + it.effect("issues a credential without preview when agent browser access is off", () => Effect.gen(function* () { - const threadId = asThreadId("thread-browser-revoke"); - revokedThreads.length = 0; + const threadId = asThreadId("thread-browser-off"); - yield* startSessionWith(false, threadId); + const issued = yield* startSessionWith(false, threadId); - // Clearing the in-memory map is not enough: a token issued before the - // toggle flipped stays valid against `/mcp` for its whole liveness - // window, and later turns refresh it. - assert.deepEqual(revokedThreads, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: false }]); }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("requests an MCP credential when agent browser access is on", () => + it.effect("issues a credential with preview when agent browser access is on", () => Effect.gen(function* () { const threadId = asThreadId("thread-browser-on"); const issued = yield* startSessionWith(true, threadId); - assert.deepEqual(issued, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: true }]); }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7db5e30e187..eaf9962076c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -79,8 +79,6 @@ export interface ProviderServiceLiveOptions { * test see whether a credential was requested at all. */ readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; - /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */ - readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread; } type ProviderServiceMethod = @@ -233,8 +231,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const serverSettings = yield* ServerSettings.ServerSettingsService; const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; - const revokeMcpCredential = - options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map< ThreadId, @@ -249,14 +245,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); /** - * Attach the `t3-code` MCP server to the session that is about to start. + * Whether the credential minted below may drive the user's browser. * - * This is the only place a credential is minted, so withholding one here is - * what disables agent browser access everywhere: every adapter already - * treats a missing session as "no MCP server", and the `/mcp` endpoint - * accepts nothing but tokens issued from this path. - */ - /** * Deny on an unreadable settings file rather than letting the read failure * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen * a union every caller handles, for a branch that only decides whether one @@ -274,20 +264,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); + /** + * Attach the `t3-code` MCP server to the session that is about to start. + * + * Every session gets a credential: the pull request toolkit is always on, + * since it only registers links on the session's own thread. Browser access + * is a capability on that credential, so turning the setting off withholds + * the preview tools without taking the server away. `issueActiveMcpCredential` + * revokes the thread's previous token first, which matters because a session + * restart (runtime mode, cwd, model) re-prepares without stopping. + */ const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled)) { - // Revoke as well as clear. Every other prepare path reaches - // `issueActiveMcpCredential`, which revokes the thread first, so - // skipping it here would leave a previously issued bearer token valid - // against `/mcp` for the rest of its liveness window — and later turns - // would keep refreshing it. A session restart (runtime mode, cwd, - // model) re-prepares without stopping, so it relies on this. - yield* revokeMcpCredential(threadId); - yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)); - return undefined; - } - const credential = yield* issueMcpCredential({ threadId, providerInstanceId }); + const preview = yield* agentBrowserAccessEnabled; + const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview }); if (credential) { yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)); } diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 2f17f9ea9fa..fe425ac69ac 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -98,6 +98,7 @@ function makeReadModel( runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 37f38deb5ce..0534baa79cc 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -183,18 +183,37 @@ afterEach(() => { }); layer("GitHubPullRequestCli.layer", (it) => { - it.effect("reads linked pull request status through one narrow request", () => + it.effect("reads linked pull request status with the overview fields in one request", () => Effect.gen(function* () { - mockedGetPullRequest.mockReturnValueOnce( - Effect.succeed({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - baseRefName: "main", - headRefName: "feat/summary", - state: "open", - updatedAt: "2026-08-24T12:34:56.000Z", - }), + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, + ], + body: "", + }), + ), + ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -205,21 +224,187 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, }); - assert.deepStrictEqual(summary, { + assert.deepStrictEqual( + { + number: summary.number, + state: summary.state, + headBranch: summary.headBranch, + isDraft: summary.isDraft, + author: summary.author?.login, + additions: summary.additions, + deletions: summary.deletions, + changedFiles: summary.changedFiles, + reviewDecision: summary.reviewDecision, + checksState: summary.checksState, + mergeability: summary.mergeability, + }, + { + number: 7, + state: "open", + headBranch: "feat/summary", + isDraft: false, + author: "octocat", + additions: 12, + deletions: 3, + changedFiles: 2, + reviewDecision: "approved", + checksState: "passing", + mergeability: "mergeable", + }, + ); + expect(mockedExecute).toHaveBeenCalledOnce(); + expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + "pr", + "view", + "7", + "--repo", + "github.com/acme/web", + "--json", + expect.stringContaining("statusCheckRollup"), + ]); + expect(mockedGetPullRequest).not.toHaveBeenCalled(); + }), + ); + + it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: 42, + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main" }, + pull_requests: [ + { + number: 6, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02", + }, + { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, + ], + }, + ]), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "ghe.example.com", + number: 7, + }); + + assert.deepStrictEqual(stack, { + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" }, + { number: 7, headBranch: "feat/two", state: "open" }, + ], + }); + assert.deepStrictEqual(callAt(0).args, [ + "api", + "--hostname", + "ghe.example.com", + "repos/acme/web/stacks?pull_request=7", + ]); + }), + ); + + it.effect("reads an empty stacks listing as not stacked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - headBranch: "feat/summary", - baseBranch: "main", - state: "open", - updatedAt: "2026-08-24T12:34:56.000Z", }); - expect(mockedGetPullRequest).toHaveBeenCalledOnce(); - expect(mockedGetPullRequest).toHaveBeenCalledWith({ + + assert.isNull(stack); + }), + ); + + it.effect("reads a host that refuses the stacks preview as not stacked", () => + Effect.gen(function* () { + // A GitHub Enterprise install without the preview, or a repository it is off for, answers + // 404 — which `gh api` reports as a plain failed command. + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), + }), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ cwd: "/w", - reference: "https://github.com/acme/web/pull/7", + repository: "acme/web", + host: "github.com", + number: 7, }); - expect(mockedExecute).not.toHaveBeenCalled(); + + assert.isNull(stack); + }), + ); + + it.effect("does not read a signed-out gh as an unstacked pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/w", + cause: new Error("gh auth login"), + }), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubCliAuthenticationError"); + }), + ); + + it.effect("reports a stacks answer it cannot read against the stack read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + if (error._tag !== "GitHubPullRequestReadError") return; + assert.strictEqual(error.operation, "getPullRequestStack"); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index f11bd264c4b..b30abd617c7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -37,6 +37,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodePullRequestStatsJson, decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, @@ -76,13 +77,14 @@ import { type GitHubPullRequestActivity, type GitHubPullRequestListItem, type GitHubPullRequestSearchItem, + type GitHubPullRequestStack, type GitHubReviewThreadComments, type GitHubRepositoryAccess, type GitHubReviewThreadEntry, type GitHubReviewThreadPage, type GitHubViewerAccess, } from "./gitHubPullRequestJson.ts"; -import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; /** * Names the read that produced unusable output, so a failure reports the call it came from @@ -385,18 +387,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect< - { - readonly number: number; - readonly title: string; - readonly url: string; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string; - }, - GitHubPullRequestCliError - >; + }) => Effect.Effect; readonly getPullRequestDetail: (input: { readonly cwd: string; @@ -405,6 +396,17 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; + /** + * The host-native stack this pull request is in, or null when it is in none — which is also + * the answer for a host that refuses the stacks preview altogether. + */ + readonly getPullRequestStack: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + /** * How far the branch trails its base, and whether this viewer may update it. Its own read * because the comparison needs the head ref the detail answers with — a fork's branch is not @@ -1364,33 +1366,53 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. getPullRequestSummary: (input) => github - .getPullRequest({ + .execute({ cwd: input.cwd, - reference: `https://${input.host}/${input.repository}/pull/${input.number}`, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], }) .pipe( - Effect.flatMap((summary) => - summary.updatedAt === undefined - ? Effect.fail( - new GitHubPullRequestUpdatedAtUnavailableError({ - command: "gh", - cwd: input.cwd, - repository: input.repository, - number: input.number, - }), - ) - : Effect.succeed({ - number: summary.number, - title: summary.title, - url: summary.url, - headBranch: summary.headRefName, - baseBranch: summary.baseRefName, - state: summary.state ?? "open", - updatedAt: summary.updatedAt, + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, }), - ), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), ), getPullRequestDetail: (input) => @@ -1422,6 +1444,44 @@ export const make = Effect.gen(function* () { }), ), + getPullRequestStack: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + // Stacks are a preview: a host without it, or a repository it is switched off for, + // answers 404, which is "not stacked" rather than a failure worth showing. `gh` + // reports no status code, so the narrowing is to a command that ran and was refused + // — a missing `gh`, a signed-out one, or a rate limit still fail the same way for + // every request and are not swallowed here. + Effect.catchTags({ + GitHubCliCommandError: () => Effect.succeed(null), + GitHubPullRequestNotFoundError: () => Effect.succeed(null), + }), + ); + }, + getPullRequestBaseComparison: (input) => { const { owner, name } = parseRepositorySelector(input.repository); return graphqlRead({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index afbeee63823..93180fe97bb 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -24,6 +24,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => baseBranch: "main", state: "open" as const, updatedAt: "2026-08-24T12:34:56.000Z", + author: { login: "octocat", name: null, avatarUrl: null }, }; }), }), @@ -41,6 +42,66 @@ it.effect("uses one narrow read for a linked pull request summary", () => expect(summary.state).toBe("open"); expect(summaryReads).toBe(1); + // The author's avatar comes from the login-shaped URL, not a second request. + expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); + }), +); + +it.effect("declares host-native stacks and passes the one the CLI reads through", () => + Effect.gen(function* () { + const stack = { + id: "42", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" as const }, + { number: 7, headBranch: "feat/two", state: "open" as const }, + ], + }; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), + }), + ), + ); + + expect(provider.capabilities.stacks).toBe(true); + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const ref = { cwd: "/w", repository: "acme/web", host: "github.com" }; + expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack); + expect(yield* readStack({ ...ref, number: 8 })).toBeNull(); + }), +); + +it.effect("reports a failed stack read against its own operation", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestStack: () => + Effect.fail( + new GitHubPullRequestCli.GitHubPullRequestReadError({ + command: "gh", + cwd: "/w", + operation: "getPullRequestStack", + cause: new Error("unreadable"), + }), + ), + }), + ), + ); + + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const error = yield* Effect.flip( + readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), + ); + + expect(error.operation).toBe("getChangeRequestStack"); + expect(error.reason).toBe("failed"); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index ff8f31c818d..8c933601cf1 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -44,6 +44,7 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + stacks: true, }; /** @@ -243,7 +244,20 @@ export const make = Effect.gen(function* () { .pipe(Effect.mapError(fail("listChangeRequestStats"))), getChangeRequestSummary: (input) => - cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), + cli.getPullRequestSummary(input).pipe( + // `gh pr view` names the author without an avatar; the login-shaped URL every user + // has stands in, without the second request the listing spends on it. + Effect.map((summary) => ({ + ...summary, + ...(summary.author === undefined + ? {} + : { author: withAvatar(summary.author, new Map(), input.host) }), + })), + Effect.mapError(fail("getChangeRequestSummary")), + ), + + getChangeRequestStack: (input) => + cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), getChangeRequest: (input) => Effect.all( diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 3235538287b..034795a45ac 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -96,6 +96,36 @@ export interface ProviderChangeRequestSummary { readonly baseBranch: string; readonly state: PullRequestState; readonly updatedAt: string; + /** Absent where the cheap read does not carry it; the consumer treats absent as not draft. */ + readonly isDraft?: boolean | undefined; + /** Overview fields, present where the host's single read returns them at no extra cost. */ + readonly author?: PullRequestActor | null | undefined; + readonly additions?: number | undefined; + readonly deletions?: number | undefined; + readonly changedFiles?: number | undefined; + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + readonly checksState?: PullRequestChecksState | null | undefined; + readonly mergeability?: PullRequestMergeability | undefined; +} + +/** One layer of a host-native stack, bottom to top order is the array's. */ +export interface ProviderChangeRequestStackLayer { + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +/** + * A host-native stack: an ordered set of change requests the host itself merges and retargets as + * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay + * ignorant of which host said so. + */ +export interface ProviderChangeRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + readonly layers: ReadonlyArray; } export interface ProviderChangeRequestPage { @@ -320,6 +350,14 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * The host-native stack a change request belongs to, or null when it is not stacked. Optional + * because most hosts have no such object; the service derives chains from base branches there. + */ + readonly getChangeRequestStack?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + /** Comments, line threads, and commits, kept off the critical path for the core detail. */ readonly getChangeRequestActivity: ( input: ProviderRepositoryRef & { readonly number: number }, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index d688430bdf5..f4377742133 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1484,6 +1484,92 @@ it.effect("refuses a repository that does not belong to the requested project", }), ); +it.effect("reads a host-native stack through the provider and null where it has none", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestStack: () => + Effect.succeed({ + id: "9", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { number: 7, headBranch: "a", state: "open" as const }, + { number: 8, headBranch: "b", state: "open" as const }, + ], + }), + }), + ], + }); + + const stack = yield* service.stack({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + }); + assert.deepStrictEqual( + stack?.layers.map((layer) => layer.number), + [7, 8], + ); + }), +); + +it.effect("routes a hosted reference to another repository through a project on that host", () => + Effect.gen(function* () { + const seen: Array<{ cwd: string; repository: string; host: string }> = []; + const service = yield* makeService({ + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + + const summary = yield* service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, + { recoverTransientFailure: false }, + ); + + assert.strictEqual(summary.number, 7); + assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); + }), +); + +it.effect("refuses a hosted reference when nothing is checked out from that host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], + providers: [fakeProvider("github")], + }); + + const error = yield* service + .summary( + { + projectId: "frontend" as ProjectId, + host: "gitlab.com", + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + }), +); + it.effect("refuses a diff on a host that cannot produce one", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 29496bff64d..cd5f6582d35 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -39,6 +39,7 @@ import { type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, type PullRequestSubmitReviewInput, + type PullRequestStack, type PullRequestSummary, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, @@ -133,6 +134,13 @@ export class PullRequestService extends Context.Service< input: PullRequestRef, options?: { readonly recoverTransientFailure?: boolean }, ) => Effect.Effect; + /** + * The host-native stack the pull request belongs to, or null when it is not in one or the + * host keeps no such object. Cached like a summary; a stack changes about as often. + */ + readonly stack: ( + input: PullRequestRef, + ) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -446,6 +454,9 @@ function withRateLimitBackoff( : { getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), }), + ...(api.getChangeRequestStack === undefined + ? {} + : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }), getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), ...(api.getReviewThreadComments === undefined ? {} @@ -633,16 +644,30 @@ export const make = Effect.gen(function* () { }), ); + /** + * The project whose checkout and credentials serve a reference. The project's own + * repository is the default; a reference that names a `host` may instead point at any + * repository on that host, served through the first project living there, so a thread in + * one repository can link a pull request from another. The returned `repository` is the + * reference's, since that is what every provider call after this addresses. + */ const requireProject = (ref: PullRequestRef): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( Effect.flatMap(({ supported }): Effect.Effect => { - const match = supported[0]; - if (!match) { - return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + const own = supported[0]; + const repository = ref.repository.trim(); + const host = ref.host?.trim().toLowerCase(); + if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) { + // Hostless references only ever meant the project's own repository, and a hosted one + // naming it still is; either way the project serves itself. + if (host === undefined || host === own.host) return Effect.succeed(own); } - // The repository travels through the client, so it is checked against the project's - // own remote rather than being handed to a provider verbatim. - if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + if (host === undefined) { + if (own === undefined) { + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being handed to a provider verbatim. return Effect.fail( new PullRequestOperationError({ operation: "resolveRepository", @@ -650,7 +675,17 @@ export const make = Effect.gen(function* () { }), ); } - return Effect.succeed(match); + return listWorkspaceProjects({ host }).pipe( + Effect.flatMap(({ supported: onHost }) => { + const route = onHost[0]; + if (route === undefined) { + return Effect.fail( + new PullRequestUnavailableError({ reason: "provider-unsupported" }), + ); + } + return Effect.succeed({ ...route, repository }); + }), + ); }), ); @@ -1226,12 +1261,63 @@ export const make = Effect.gen(function* () { headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, updatedAt: changeRequest.updatedAt, + ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), + ...(changeRequest.additions === undefined + ? {} + : { additions: changeRequest.additions }), + ...(changeRequest.deletions === undefined + ? {} + : { deletions: changeRequest.deletions }), + ...(changeRequest.changedFiles === undefined + ? {} + : { changedFiles: changeRequest.changedFiles }), + ...(changeRequest.reviewDecision === undefined + ? {} + : { reviewDecision: changeRequest.reviewDecision }), + ...(changeRequest.checksState === undefined + ? {} + : { checksState: changeRequest.checksState }), + ...(changeRequest.mergeability === undefined + ? {} + : { mergeability: changeRequest.mergeability }), }), ), ); }), ); + const stackUncached: PullRequestService["Service"]["stack"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getChangeRequestStack; + if (read === undefined) return Effect.succeed(null); + return read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe( + Effect.mapError(toPullRequestError("stack")), + Effect.map((stack): PullRequestStack | null => + stack === null + ? null + : { + id: stack.id, + number: stack.number, + url: stack.url, + base: stack.base, + layers: stack.layers.map((layer) => ({ + number: layer.number, + headBranch: layer.headBranch, + state: layer.state, + })), + }, + ), + ); + }), + ); + const detailUncached: PullRequestService["Service"]["detail"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -2005,10 +2091,34 @@ export const make = Effect.gen(function* () { let listingsEpoch = 0; const refEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; - const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; + const refScope = (ref: PullRequestRef) => + `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; + // Keys carry the reference back out of the cache loader, so the slot layout is shared with + // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: PullRequestRef) => - JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); + JSON.stringify([ + refEpoch(ref), + ref.projectId, + ref.host?.toLowerCase() ?? null, + ref.repository.toLowerCase(), + ref.number, + ]); + const refOfCacheKey = (key: string): PullRequestRef => { + const [, projectId, host, repository, number] = JSON.parse(key) as [ + number, + string, + string | null, + string, + number, + ]; + return { + projectId, + ...(host === null ? {} : { host }), + repository, + number, + } as PullRequestRef; + }; const bumpRefEpoch = (ref: PullRequestRef) => { const scope = refScope(ref); if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { @@ -2037,8 +2147,7 @@ export const make = Effect.gen(function* () { const summaryCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return summaryUncached({ projectId, repository, number } as PullRequestRef); + return summaryUncached(refOfCacheKey(key)); }, { capacity: DETAIL_CACHE_CAPACITY, @@ -2053,6 +2162,13 @@ export const make = Effect.gen(function* () { : lastGoodSummary.read(key, cached); }; + const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), + }); + const stack: PullRequestService["Service"]["stack"] = (input) => + Cache.get(stackCache, refCacheKey(input)); + // Keys serialize positionally and parse back in the lookup, so the cache is the only holder // of in-flight state: concurrent identical reads coalesce on the key into one host request. // The continuation cursors are part of the key, entries sorted so one continuation is one @@ -2132,8 +2248,7 @@ export const make = Effect.gen(function* () { const detailCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return detailUncached({ projectId, repository, number } as PullRequestRef); + return detailUncached(refOfCacheKey(key)); }, { capacity: DETAIL_CACHE_CAPACITY, @@ -2147,8 +2262,7 @@ export const make = Effect.gen(function* () { const activityCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return activityUncached({ projectId, repository, number } as PullRequestRef); + return activityUncached(refOfCacheKey(key)); }, { capacity: DETAIL_CACHE_CAPACITY, @@ -2162,9 +2276,10 @@ export const make = Effect.gen(function* () { const diffCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [ + const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [ number, string, + string | null, string, number, string | null, @@ -2172,6 +2287,7 @@ export const make = Effect.gen(function* () { ]; return diffUncached({ projectId, + ...(host === null ? {} : { host }), repository, number, ...(cursor === null ? {} : { cursor }), @@ -2182,7 +2298,7 @@ export const make = Effect.gen(function* () { capacity: DIFF_CACHE_CAPACITY, timeToLive: (exit, key) => { if (!Exit.isSuccess(exit)) return Duration.zero; - const commit = (JSON.parse(key) as ReadonlyArray)[5]; + const commit = (JSON.parse(key) as ReadonlyArray)[6]; return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; }, }, @@ -2191,7 +2307,8 @@ export const make = Effect.gen(function* () { const key = JSON.stringify([ refEpoch(input), input.projectId, - input.repository, + input.host?.toLowerCase() ?? null, + input.repository.toLowerCase(), input.number, input.cursor ?? null, input.commit ?? null, @@ -2260,6 +2377,7 @@ export const make = Effect.gen(function* () { list, listStats, summary, + stack, detail, activity, threadComments, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f372ac3000a..44be765dc25 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -11,6 +11,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, decodeReviewThreadCommentsJson, @@ -1361,3 +1362,81 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +describe("host-native stack decoding", () => { + /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ + function stack(overrides: Record = {}) { + return { + id: 42, + number: 3, + node_id: "STK_kwDO", + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main", sha: "abc" }, + open: true, + created_at: "2026-09-01T00:00:00Z", + pull_requests: [ + { + number: 10, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02T00:00:00Z", + }, + { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, + { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, + ], + ...overrides, + }; + } + + /** The one stack a listing answered with, which these reads all expect to find. */ + function expectStack(overrides: Record = {}) { + const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); + if (decoded === null) throw new Error("expected a stack"); + return decoded; + } + + it("reads the first stack, bottom to top, with merged_at outranking state", () => { + expect(expectStack()).toEqual({ + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 10, headBranch: "feat/one", state: "merged" }, + { number: 11, headBranch: "feat/two", state: "open" }, + { number: 12, headBranch: "feat/three", state: "closed" }, + ], + }); + }); + + it("accepts a base named as a bare branch, which is what the preview started out sending", () => { + expect(expectStack({ base: "develop" }).base).toBe("develop"); + }); + + it("prefers the page a person opens over the API URL, where the host reports one", () => { + expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( + "https://github.com/acme/web/stacks/3", + ); + }); + + it("falls back to the node id, then the number, for a stack without an id", () => { + expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); + expect(expectStack({ id: null, node_id: null }).id).toBe("3"); + }); + + it("reads an empty listing as not stacked", () => { + expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); + }); + + it("refuses a stack without a number or without its pull requests", () => { + expect( + Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), + ).toBe(false); + expect( + Result.isSuccess( + decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), + ), + ).toBe(false); + expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6ec17ea111b..9f34dc2aeb5 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -2239,3 +2239,69 @@ export function decodePullRequestFilesJson( omittedFileStats, }); } + +/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ +const RawStackPullRequestSchema = Schema.Struct({ + number: Schema.Int, + head: Schema.Struct({ ref: Schema.String }), + state: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may + * still move. Only what a stack is made of is required — where it lives, what it stands on, and + * its pull requests — and `base` is accepted both as the ref object the preview sends today and + * as the bare branch name it started out as. + */ +const RawStackSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), + number: Schema.Int, + node_id: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), + pull_requests: Schema.Array(RawStackPullRequestSchema), +}); + +const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); + +export interface GitHubPullRequestStackLayer { + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +export interface GitHubPullRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + /** Bottom to top, which is the order GitHub lists them in. */ + readonly layers: ReadonlyArray; +} + +/** + * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in + * at most one stack, so the array is GitHub's way of saying "none" rather than a page. + */ +export function decodePullRequestStacksJson( + raw: string, +): Result.Result { + const decoded = decodeStacks(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const stack = decoded.success[0]; + if (stack === undefined) return Result.succeed(null); + return Result.succeed({ + id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), + number: stack.number, + // The page a person opens where the preview reports one; the API URL is what it always has. + url: trimmed(stack.html_url) ?? stack.url, + base: typeof stack.base === "string" ? stack.base : stack.base.ref, + layers: stack.pull_requests.map((pullRequest) => ({ + number: pullRequest.number, + headBranch: pullRequest.head.ref, + state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), + })), + }); +} diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 2465052a33c..a1bb70a4615 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -301,6 +301,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -442,6 +443,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: { turnId: "turn-1" as TurnId, state: "running", @@ -601,6 +603,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: { turnId: "turn-1" as TurnId, state: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d3e94e4eea4..d5c85805d0b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -109,6 +109,7 @@ import { } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; @@ -268,6 +269,7 @@ const makeDefaultOrchestrationReadModel = () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -298,6 +300,7 @@ const makeDefaultOrchestrationThreadShell = ( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -850,6 +853,11 @@ const buildAppUnderTest = (options?: { drainThrough: () => Effect.void, ...options?.layers?.threadDeletionReactor, }), + Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({ + start: () => Effect.void, + drain: Effect.void, + requestSync: () => Effect.void, + }), ), ), Layer.provide( @@ -6657,6 +6665,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e74a4fa3c31..3f68e1979f3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -63,6 +63,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -266,6 +267,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), + Layer.provideMerge(PullRequestSyncReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 28ade015f8b..65613824542 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -63,6 +63,9 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + type PullRequestRef, + pullRequestHostOf, + type SourceControlProviderKind, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -112,6 +115,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -126,6 +130,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -464,6 +469,26 @@ const makeWsRpcLayer = ( const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + /** A reference's host-level link key; the project's own host where the ref names none. */ + const resolvePullRequestSyncKey = (reference: PullRequestRef) => + reference.host !== undefined + ? Effect.succeed({ + host: reference.host, + repository: reference.repository, + number: reference.number, + }) + : projectionSnapshotQuery.getProjectShellById(reference.projectId).pipe( + Effect.map((project) => { + if (Option.isNone(project) || project.value.repositoryIdentity == null) return null; + const identity = project.value.repositoryIdentity; + return { + host: pullRequestHostOf(identity, identity.provider as SourceControlProviderKind), + repository: reference.repository, + number: reference.number, + }; + }), + Effect.orElseSucceed(() => null), + ); const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const threadDeletionReactor = yield* ThreadDeletionReactor; const analytics = yield* AnalyticsService.AnalyticsService; @@ -585,6 +610,7 @@ const makeWsRpcLayer = ( const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const pullRequests = yield* PullRequestService.PullRequestService; + const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1898,6 +1924,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsStack]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", @@ -1961,9 +1991,23 @@ const makeWsRpcLayer = ( "rpc.aggregate": "pull-requests", }), [WS_METHODS.pullRequestsInvalidate]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsInvalidate, + pullRequests.invalidate(input).pipe( + // A reader asking for fresh host state also wants the thread badges it feeds to + // catch up, including a merged link the sweep would otherwise never revisit. + Effect.andThen( + input.reference === undefined + ? Effect.void + : resolvePullRequestSyncKey(input.reference).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), + ), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsReviewerCandidates]: (input) => observeRpcEffect( WS_METHODS.pullRequestsReviewerCandidates, @@ -2225,8 +2269,25 @@ const makeWsRpcLayer = ( .pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( + onSuccess: (result) => + (input.threadId === undefined + ? Effect.void + : linkCreatedPullRequest({ + threadId: input.threadId, + result, + commandId: serverCommandId("pr-created-link"), + }).pipe( + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + ) + ).pipe( + Effect.andThen(refreshGitStatus(input.cwd)), Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), ), }), diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index c3e536d70ae..ae3e14acc94 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -22,8 +22,7 @@ vi.mock("../editorPreferences", () => ({ usePreferredEditor: () => [null, vi.fn()], })); vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectForChangeRequest: () => undefined, - matchesLinkedPullRequestUrl: () => false, + findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b08377e36a2..a1ac2564914 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -26,7 +26,7 @@ import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill, - ThreadLinkedPullRequest, + ThreadPullRequestKey, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, @@ -71,6 +71,10 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; @@ -159,8 +163,7 @@ import { WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { - findProjectForChangeRequest, - matchesLinkedPullRequestUrl, + findProjectOnChangeRequestHost, parseChangeRequestUrl, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; @@ -2010,7 +2013,10 @@ function ChatMarkdown({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + const linkThreadPullRequest = useAtomCommand(threadEnvironment.linkPullRequest, { + reportFailure: false, + }); + const unlinkThreadPullRequest = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: false, }); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; @@ -2143,53 +2149,88 @@ function ChatMarkdown({ event.clipboardData.setData("text/html", payload.html); }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + /** + * A chat link as a thread link: host-level, so any project on the link's host makes it + * linkable, even one checked out from a different repository. Nothing for a URL the parser + * does not recognise, a host nothing here is checked out from, or a server without links. + */ const resolveThreadPullRequest = useCallback( - (href: string): ThreadLinkedPullRequest | null => { + (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { if ( threadRef === undefined || readThreadShell(threadRef) === null || - threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true + threadServerConfig?.environment.capabilities.threadPullRequests !== true ) { return null; } const parsed = parseChangeRequestUrl(href); if (parsed === null) return null; - const project = findProjectForChangeRequest( + const project = findProjectOnChangeRequestHost( projects.filter((candidate) => candidate.environmentId === threadRef.environmentId), parsed, ); if (project === undefined) return null; return { - projectId: project.id, - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + host: parsed.host, + repository: parsed.repository, number: parsed.number, url: href, }; }, [projects, threadRef, threadServerConfig], ); + const linkedThreadPullRequestFor = useCallback( + (href: string) => { + if (threadRef === undefined) return null; + const parsed = parseChangeRequestUrl(href); + if (parsed === null) return null; + return ( + visibleThreadPullRequests(readThreadShell(threadRef)?.pullRequests ?? []).find((link) => + threadPullRequestKeysEqual(link, parsed), + ) ?? null + ); + }, + [threadRef], + ); const updateThreadPullRequestLink = useCallback( async (href: string, linked: boolean) => { if (threadRef === undefined) return; - const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; - if (linked && linkedPullRequest === null) { - throw new Error("The pull request is not available in this environment."); - } - if (!linked) { - const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; - if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { - return; + if (linked) { + const pullRequest = resolveThreadPullRequest(href); + if (pullRequest === null) { + throw new Error("The pull request is not available in this environment."); } + const result = await linkThreadPullRequest({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, ...pullRequest, source: "manual" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + return; } - const result = await updateThreadMetadata({ + const current = linkedThreadPullRequestFor(href); + if (current === null) return; + const result = await unlinkThreadPullRequest({ environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, linkedPullRequest }, + input: { + threadId: threadRef.threadId, + host: current.host, + repository: current.repository, + number: current.number, + }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { throw squashAtomCommandFailure(result); } }, - [resolveThreadPullRequest, threadRef, updateThreadMetadata], + [ + linkThreadPullRequest, + linkedThreadPullRequestFor, + resolveThreadPullRequest, + threadRef, + unlinkThreadPullRequest, + ], ); const openExternalLinkInPreview = useCallback( (url: string) => { @@ -2505,14 +2546,10 @@ function ChatMarkdown({ event.stopPropagation(); const api = readLocalApi(); if (!api) return; - const pullRequest = resolveThreadPullRequest(href); - const currentPullRequest = - threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; const threadLinkAction = - currentPullRequest != null && - matchesLinkedPullRequestUrl(currentPullRequest, href) + linkedThreadPullRequestFor(href) !== null ? "unlink-from-thread" - : pullRequest === null + : resolveThreadPullRequest(href) === null ? undefined : "link-to-thread"; void showExternalLinkContextMenu({ @@ -2747,6 +2784,7 @@ function ChatMarkdown({ openExternalLinkInPreview, openMarkdownFileInPreview, preferredEditorMenuLabel, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, revealMarkdownFileInFileManager, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 172793bacb0..cbbb8efdd3a 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -36,8 +36,7 @@ vi.mock("../editorPreferences", () => ({ usePreferredEditor: () => [null, vi.fn()], })); vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectForChangeRequest: () => undefined, - matchesLinkedPullRequestUrl: () => false, + findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 290e3435abc..46e835440eb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -433,6 +433,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], activities: [], checkpoints: [], + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -550,6 +551,7 @@ describe("buildLoadingThreadFromShell", () => { snoozedUntil: null, snoozedAt: null, session: null, + pullRequests: [], latestUserMessageAt: now, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e9a18d01687..099010931cd 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -249,6 +249,7 @@ export function buildLocalDraftThread( branch: draftThread.branch, worktreePath: draftThread.worktreePath, checkpoints: [], + pullRequests: [], activities: [], proposedPlans: [], }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6afaedad5bf..c9e5e1b7312 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -174,6 +174,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; +import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; +import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { deriveAgentPanelModel, foldSubagentActivities, @@ -3656,6 +3658,12 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().open(activeThreadRef, "agents"); }, [activeThreadRef]); + const supportsThreadPullRequests = + serverConfig?.environment.capabilities.threadPullRequests === true; + const addPullRequestsSurface = useCallback(() => { + if (!activeThreadRef || !supportsThreadPullRequests) return; + useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); + }, [activeThreadRef, supportsThreadPullRequests]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -4658,6 +4666,7 @@ function ChatViewContent(props: ChatViewProps) { const linkedPullRequestStatus = useLinkedThreadPullRequest( activeThreadRef?.environmentId ?? null, linkedThreadPullRequest, + activeThread?.pullRequests, ); const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, @@ -7224,6 +7233,8 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> + ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "agents" ? ( @@ -7771,12 +7784,14 @@ function ChatViewContent(props: ChatViewProps) { onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} + onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} + pullRequestsAvailable={isServerThread && supportsThreadPullRequests} agentsAvailable liveAgentCount={agentPanelModel.liveCount} > @@ -7785,6 +7800,7 @@ function ChatViewContent(props: ChatViewProps) { ) : null} + {expandedImage && ( = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4c94c3cb0c2..c93cd4edbe2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -45,6 +45,7 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + GitPullRequestArrowIcon, LinkIcon, MessageSquareIcon, PaletteIcon, @@ -83,7 +84,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProject, useProjects, useThreadShells } from "../state/entities"; +import { useProject, useProjects, useServerConfigs, useThreadShells } from "../state/entities"; import { useThreadSearch } from "../state/queries"; import * as ThreadPr from "./ThreadStatusIndicators"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -147,6 +148,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; +import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; @@ -625,6 +627,9 @@ function OpenCommandPaletteDialog(props: { ), retainTerminalOnBranchMismatch: activeThread.worktreePath === null, })?.url ?? null); + const activeThreadServerConfig = useServerConfigs().get( + activeThread?.environmentId ?? ("" as EnvironmentId), + ); const activeThreadReferenceCopyTarget = activeThread == null ? null @@ -1606,6 +1611,33 @@ function OpenCommandPaletteDialog(props: { }); } + if ( + activeThread !== null && + activeThreadServerConfig?.environment.capabilities.threadPullRequests === true + ) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + actionItems.push({ + kind: "action", + value: "action:link-pull-request", + searchTerms: ["link", "pull request", "pr", "attach", "stack"], + title: "Link pull request to thread", + icon: , + run: async () => { + openLinkPullRequestDialog(threadRef); + }, + }); + actionItems.push({ + kind: "action", + value: "action:open-thread-pull-requests", + searchTerms: ["pull requests", "linked", "stack", "prs"], + title: "Show linked pull requests", + icon: , + run: async () => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + }, + }); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1c75476b96d..780d9670ae1 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1419,6 +1419,9 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), + // A pull request the action opens is linked to the thread it ran beside. Drafts + // have no server thread yet, so there is nothing to link to. + ...(activeServerThread ? { threadId: activeServerThread.id } : {}), onProgress: applyProgressEvent, }); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 6d48cf6538b..1f56d83b076 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -464,6 +464,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const linkedPullRequestStatus = useLinkedThreadPullRequest( leaseLiveStatus ? thread.environmentId : null, leaseLiveStatus ? thread.linkedPullRequest : null, + thread.pullRequests, ); const visibleGitStatus = useRetainedValue( JSON.stringify([thread.environmentId, gitCwd]), diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 81367d75580..dbd079a561d 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -117,6 +117,7 @@ function renderTabs( onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} + onAddPullRequests={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} onAddAgents={() => undefined} @@ -126,6 +127,7 @@ function renderTabs( diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} + pullRequestsAvailable={false} agentsAvailable={false} >
content
diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6d3ba4f1ba9..455a3b0de54 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -11,6 +11,7 @@ import { FileDiff, Files, GitPullRequest, + GitPullRequestArrow, Globe2, Plus, TerminalSquare, @@ -101,12 +102,14 @@ interface RightPanelTabsProps { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; pullRequestStatusSeeds?: Readonly>; /** Running + waiting subagents; badges the Agents card in the empty state. */ @@ -136,6 +139,7 @@ const SURFACE_DISABLED_REASONS = { files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", + pullRequests: "Linked pull requests are only available for server threads.", agents: "Agents are only available from a thread.", } as const; @@ -158,6 +162,7 @@ const SURFACE_UNAVAILABLE_HINTS = { files: "Available when a project is open.", diff: "Available for Git repositories.", pullRequest: "No pull request on this branch yet.", + pullRequests: "Available for server threads.", agents: "Available from a thread.", } as const; @@ -287,12 +292,14 @@ function RightPanelEmptyState(props: { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; liveAgentCount: number; }) { @@ -350,6 +357,16 @@ function RightPanelEmptyState(props: { onClick: props.onAddPullRequest, badgeCount: 0, }, + { + label: "Linked pull requests", + description: "Every pull request this thread has linked, stacks included.", + icon: GitPullRequestArrow, + shortcut: "L", + available: props.pullRequestsAvailable, + disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, + onClick: props.onAddPullRequests, + badgeCount: 0, + }, { label: "Agents", description: "Follow subagents and workflows.", @@ -544,6 +561,8 @@ function surfaceTitle( ); case "pull-request": return `#${surface.number}`; + case "pull-requests": + return "Pull requests"; case "agents": return "Agents"; case "preview": { @@ -625,6 +644,8 @@ function SurfaceIcon({ seed={pullRequestStatusSeeds?.[surface.id]} /> ); + case "pull-requests": + return ; case "agents": return ; } @@ -712,6 +733,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.pullRequest, onClick: props.onAddPullRequest, }, + { + label: "Linked pull requests", + icon: GitPullRequestArrow, + shortcut: "L", + available: props.pullRequestsAvailable, + disabledReason: SURFACE_DISABLED_REASONS.pullRequests, + onClick: props.onAddPullRequests, + }, { label: "Agents", icon: Bot, @@ -1057,12 +1086,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} + onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} + pullRequestsAvailable={props.pullRequestsAvailable} agentsAvailable={props.agentsAvailable} liveAgentCount={props.liveAgentCount} /> diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index dbf8fcf7853..69f0e25b647 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1399,6 +1399,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98f2f997875..2ac6a2a20c4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -67,6 +67,7 @@ import { } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; +import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, @@ -149,11 +150,15 @@ import { } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { + PR_STATE_COLOR_CLASS, + ThreadPullRequestBadgeIcon, + ThreadPullRequestsMiniList, ThreadWorktreeIndicator, nextThreadChangeRequestSnapshot, prStatusIndicator, resolveDisplayedThreadPr, resolveDisplayedThreadPrProvider, + resolveThreadPullRequestBadge, setThreadChangeRequestSnapshot, settledPrHoverColorClass, terminalStatusFromRunningIds, @@ -382,6 +387,11 @@ function SidebarThreadTooltip({ ) : null} + {thread.pullRequests.length > 0 ? ( +
+ +
+ ) : null} ); @@ -805,6 +815,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const linkedPullRequestStatus = useLinkedThreadPullRequest( leaseLiveStatus ? thread.environmentId : null, leaseLiveStatus ? thread.linkedPullRequest : null, + thread.pullRequests, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null @@ -927,7 +938,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; useEffect(() => { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, @@ -1182,29 +1192,59 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); - // A real link so cmd/ctrl+click and middle-click open the host in the - // browser. A plain click still opens T3's pull request view. + // One badge shape for every thread: the glyph says stack or not, the number is the current + // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and + // middle-click open the host in the browser; a plain click opens T3's pull request view. + const prBadgeShape = resolveThreadPullRequestBadge(thread.pullRequests); + const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) => + cn( + // Sidebar chrome follows the interface font; tabular digits keep the number from + // reflowing as PR states stream in. A border rather than text-decoration, so the line + // runs under the glyph as well as the number. + "inline-flex shrink-0 cursor-pointer items-center gap-0.5 border-b border-transparent text-xs tabular-nums hover:border-current", + variant === "slim" && variantAction === "unsettle" + ? props.isActive + ? "text-secondary-label" + : cn("text-secondary-label transition-colors", settledPrHoverColorClass(state)) + : colorClass, + ); + const handlePrStackClick = useCallback(() => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + if (!props.isActive) onThreadActivate(threadRef); + }, [onThreadActivate, props.isActive, threadRef]); const prBadge = - prStatus && pr ? ( + prBadgeShape?.kind === "stack" ? ( + // A stack is one thing with N layers; naming one of them would misrepresent it, so the + // badge counts layers and opens the thread's pull-requests surface. + + ) : prStatus && pr ? (
event.stopPropagation()} onClick={handlePrClick} - className={cn( - // Sidebar chrome follows the interface font; tabular digits keep the - // number from reflowing as PR states stream in. - "shrink-0 text-xs tabular-nums hover:underline", - variant === "slim" && variantAction === "unsettle" - ? props.isActive - ? "text-secondary-label" - : cn("text-secondary-label transition-colors", settledPrHoverClass) - : prStatus.colorClass, - )} - aria-label={prStatus.tooltip} + className={prBadgeClassName(pr.state, prStatus.colorClass)} + aria-label={ + prBadgeShape && prBadgeShape.others > 0 + ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked` + : prStatus.tooltip + } > - #{pr.number} + + {pr.number} + {prBadgeShape && prBadgeShape.others > 0 ? ( + +{prBadgeShape.others} + ) : null} ) : null; const terminalStatusIcon = terminalStatus ? ( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e879c78b971..cc31a307e05 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,10 +4,27 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; -import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts"; +import type { + EnvironmentId, + ThreadLinkedPullRequest, + ThreadPullRequestLink, + VcsStatusResult, +} from "@t3tools/contracts"; +import { + resolveThreadPullRequestChains, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { Atom } from "effect/unstable/reactivity"; -import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { + CloudIcon, + FolderGit2Icon, + GitPullRequestArrowIcon, + GitPullRequestIcon, + LayersIcon, + TerminalIcon, +} from "lucide-react"; import { useMemo } from "react"; +import { cn } from "../lib/utils"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; @@ -21,6 +38,8 @@ import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic" import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { pullRequestListLines, type PullRequestListLine } from "./pullRequest/pullRequestListLines"; +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; export interface PrStatusIndicator { label: string; @@ -70,10 +89,24 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } +/** + * Live state of the thread's current pull request. `pullRequests` supplies the host of the + * compat `linkedPullRequest`, which has none of its own; without it the server routes the read + * through the project's own repository, which is wrong for a link from another repository. + */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, + pullRequests?: ReadonlyArray, ): LinkedThreadPullRequestStatus | null { + const host = + linkedPullRequest == null + ? undefined + : pullRequests?.find( + (link) => + link.number === linkedPullRequest.number && + link.repository.toLowerCase() === linkedPullRequest.repository.toLowerCase(), + )?.host; const detail = useEnvironmentQuery( environmentId === null || linkedPullRequest == null ? null @@ -81,6 +114,7 @@ export function useLinkedThreadPullRequest( environmentId, input: { projectId: linkedPullRequest.projectId, + ...(host === undefined ? {} : { host }), repository: linkedPullRequest.repository, number: linkedPullRequest.number, }, @@ -103,6 +137,121 @@ export function useLinkedThreadPullRequest( ); } +/** A single stack is when every visible link sits in one chain of two or more. */ +export function isSingleStack(lines: ReadonlyArray): boolean { + return lines.length > 1 && new Set(lines.map((line) => line.chainKey)).size === 1; +} + +/** + * How a row's pull-request badge reads. A thread whose links are one stack shows the layers + * glyph and the layer count, coloured by where the stack stands as a whole; any other set of + * links shows the pull-request glyph, the current number, and how many others sit behind it. + * Null when the thread has no links, so the badge falls back to whatever the branch reports. + */ +export type ThreadPullRequestBadge = + | { + readonly kind: "stack"; + readonly layers: number; + readonly state: NonNullable["state"]; + } + | { readonly kind: "pull-request"; readonly others: number }; + +export function resolveThreadPullRequestBadge( + pullRequests: ReadonlyArray | undefined, +): ThreadPullRequestBadge | null { + const visible = visibleThreadPullRequests(pullRequests ?? []); + if (visible.length === 0) return null; + const lines = pullRequestListLines(resolveThreadPullRequestChains(visible)); + if (isSingleStack(lines)) { + const states = lines.map((line) => line.link.snapshot?.state ?? "open"); + // Open while any layer is; merged once every layer merged; closed otherwise. + const state = states.includes("open") + ? "open" + : states.every((entry) => entry === "merged") + ? "merged" + : "closed"; + return { kind: "stack", layers: lines.length, state }; + } + return { kind: "pull-request", others: visible.length - 1 }; +} + +/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */ +export function ThreadPullRequestBadgeIcon({ + icon, + className, +}: { + icon: "stack" | "pull-request"; + className?: string | undefined; +}) { + const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon; + return ; +} + +/** + * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, + * so the hover answers "what is in here" without opening the surface. + */ +export function ThreadPullRequestsMiniList({ + pullRequests, +}: { + pullRequests: ReadonlyArray; +}) { + const lines = useMemo( + () => + pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))), + [pullRequests], + ); + if (lines.length === 0) return null; + return ( +
    + {lines.map((line) => { + const snapshot = line.link.snapshot; + const presentation = + snapshot === null + ? null + : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft }); + return ( +
  • + {presentation ? ( + + ) : ( + + )} + #{line.link.number} + + {snapshot?.title ?? line.link.repository} + + {line.stack ? ( + + {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size} + + ) : null} +
  • + ); + })} +
+ ); +} + +/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ +export const PR_STATE_COLOR_CLASS: Record["state"], string> = { + open: "text-emerald-600 dark:text-emerald-300/90", + merged: "text-violet-600 dark:text-violet-300/90", + closed: "text-red-600 dark:text-red-300/90", +}; + export function settledPrHoverColorClass(state: NonNullable["state"]): string { switch (state) { case "open": @@ -561,6 +710,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const linkedPullRequest = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, + thread.pullRequests, ); const gitStatus = useEnvironmentQuery( thread.linkedPullRequest == null && diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts new file mode 100644 index 00000000000..28ebc43e8a9 --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; + +const project = { + host: "github.com", + repository: "acme/web", + webUrl: (number: number) => changeRequestWebUrl("github", "github.com", "acme/web", number), +}; + +describe("resolveLinkPullRequestInput", () => { + it("returns null for input that is not a reference", () => { + expect( + resolveLinkPullRequestInput({ reference: "hello", project, hostHasProject: () => true }), + ).toBeNull(); + }); + + it("resolves a bare number against the thread's own repository", () => { + expect( + resolveLinkPullRequestInput({ reference: "#42", project, hostHasProject: () => true }), + ).toEqual({ + link: { + host: "github.com", + repository: "acme/web", + number: 42, + url: "https://github.com/acme/web/pull/42", + }, + }); + }); + + it("links a URL from another repository on a host with a project", () => { + expect( + resolveLinkPullRequestInput({ + reference: "https://github.com/acme/api/pull/7", + project, + hostHasProject: (host) => host === "github.com", + }), + ).toEqual({ + link: { + host: "github.com", + repository: "acme/api", + number: 7, + url: "https://github.com/acme/api/pull/7", + }, + }); + }); + + it("refuses a URL on a host nothing is checked out from", () => { + const result = resolveLinkPullRequestInput({ + reference: "https://gitlab.com/acme/api/-/merge_requests/7", + project, + hostHasProject: () => false, + }); + expect(result).toMatchObject({ error: expect.stringContaining("gitlab.com") }); + }); + + it("asks for a URL when a bare number has no project to resolve against", () => { + expect( + resolveLinkPullRequestInput({ reference: "12", project: null, hostHasProject: () => true }), + ).toMatchObject({ error: expect.stringContaining("full URL") }); + }); + + it("accepts a checkout command as a reference", () => { + expect( + resolveLinkPullRequestInput({ + reference: "gh pr checkout https://github.com/acme/web/pull/3", + project, + hostHasProject: () => true, + }), + ).toMatchObject({ link: { number: 3, repository: "acme/web" } }); + }); +}); + +describe("changeRequestWebUrl", () => { + it("knows the four hosts and nothing else", () => { + expect(changeRequestWebUrl("gitlab", "gitlab.com", "g/sub/repo", 5)).toBe( + "https://gitlab.com/g/sub/repo/-/merge_requests/5", + ); + expect(changeRequestWebUrl("unknown", "x", "a/b", 1)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx new file mode 100644 index 00000000000..6d263c98bce --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -0,0 +1,281 @@ +import { + pullRequestHostOf, + type ScopedThreadRef, + type SourceControlProviderKind, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { parsePullRequestReference } from "~/pullRequestReference"; +import { useProjects, useThreadShell } from "~/state/entities"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { Atom } from "effect/unstable/reactivity"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; + +/** + * Which thread has the link dialog open, set by whichever entry point asked (command palette, + * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives + * a palette that closes the moment its command runs. + */ +export const linkPullRequestDialogThreadAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("pull-requests:link-dialog-thread"), +); + +export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void { + appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef); +} + +interface LinkPullRequestDialogProps { + open: boolean; + threadRef: ScopedThreadRef; + /** The thread's own project: bare numbers resolve against its repository. */ + projectId: string | null; + onOpenChange: (open: boolean) => void; +} + +/** Mounted once per chat view; shows the dialog for whichever thread asked for it. */ +export function LinkPullRequestDialogHost() { + const threadRef = useAtomValue(linkPullRequestDialogThreadAtom); + const thread = useThreadShell(threadRef); + if (threadRef === null) return null; + return ( + { + if (!open) appAtomRegistry.set(linkPullRequestDialogThreadAtom, null); + }} + /> + ); +} + +interface ResolvedLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** + * Which pull request an input names, or why it cannot. A URL carries its own host and + * repository and may point at any repository on a host this environment has a project for; a + * bare `#123` can only mean the thread's own repository. + */ +export function resolveLinkPullRequestInput(input: { + readonly reference: string; + readonly project: { + readonly host: string; + readonly repository: string; + readonly webUrl: (number: number) => string | null; + } | null; + readonly hostHasProject: (host: string) => boolean; +}): { link: ResolvedLink } | { error: string } | null { + const parsed = parsePullRequestReference(input.reference); + if (parsed === null) return null; + const url = parseChangeRequestUrl(parsed); + if (url !== null) { + if (!input.hostHasProject(url.host)) { + return { error: `No project in this environment is checked out from ${url.host}.` }; + } + return { + link: { host: url.host, repository: url.repository, number: url.number, url: parsed }, + }; + } + const number = Number(parsed); + if (!Number.isSafeInteger(number) || number < 1) return null; + if (input.project === null) { + return { error: "Paste a full URL to link a pull request from another repository." }; + } + const webUrl = input.project.webUrl(number); + if (webUrl === null) { + return { error: "Paste a full URL; this project's host has no known pull request URL." }; + } + return { + link: { host: input.project.host, repository: input.project.repository, number, url: webUrl }, + }; +} + +/** The pull request page for a number on the hosts whose URL shape is known. */ +export function changeRequestWebUrl( + provider: string | undefined, + host: string, + repository: string, + number: number, +): string | null { + switch (provider) { + case "github": + return `https://${host}/${repository}/pull/${number}`; + case "gitlab": + return `https://${host}/${repository}/-/merge_requests/${number}`; + case "bitbucket": + return `https://${host}/${repository}/pull-requests/${number}`; + case "azure-devops": + return `https://${host}/${repository}/pullrequest/${number}`; + default: + return null; + } +} + +export function LinkPullRequestDialog({ + open, + threadRef, + projectId, + onOpenChange, +}: LinkPullRequestDialogProps) { + const inputRef = useRef(null); + const [reference, setReference] = useState(""); + const [dirty, setDirty] = useState(false); + const [submitError, setSubmitError] = useState(null); + const projects = useProjects(); + const environmentProjects = useMemo( + () => projects.filter((project) => project.environmentId === threadRef.environmentId), + [projects, threadRef.environmentId], + ); + const ownProject = useMemo(() => { + const project = environmentProjects.find((candidate) => candidate.id === projectId); + const identity = project?.repositoryIdentity; + if (!project || !identity) return null; + const repository = + identity.displayName ?? + (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); + if (repository === null) return null; + const kind = identity.provider as SourceControlProviderKind; + const host = pullRequestHostOf(identity, kind); + return { + host, + repository, + webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number), + }; + }, [environmentProjects, projectId]); + const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false }); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!open) return; + setReference(""); + setDirty(false); + setSubmitError(null); + const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [open]); + + const resolved = useMemo( + () => + resolveLinkPullRequestInput({ + reference, + project: ownProject, + hostHasProject: (host) => + findProjectOnChangeRequestHost(environmentProjects, { + host, + repository: "", + number: 1, + }) !== undefined, + }), + [environmentProjects, ownProject, reference], + ); + + const submit = useCallback(async () => { + setDirty(true); + if (resolved === null || "error" in resolved) return; + setSubmitError(null); + setPending(true); + const result = await link({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, ...resolved.link, source: "manual" }, + }).finally(() => setPending(false)); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const cause = squashAtomCommandFailure(result); + setSubmitError(cause instanceof Error ? cause.message : "Could not link the pull request."); + } + return; + } + onOpenChange(false); + }, [link, onOpenChange, resolved, threadRef]); + + const validation = !dirty + ? null + : reference.trim().length === 0 + ? "Paste a pull request URL or enter 123 / #123." + : resolved === null + ? "Use a pull request URL, 123, or #123." + : "error" in resolved + ? resolved.error + : null; + + return ( + (pending ? undefined : onOpenChange(next))}> + + + Link pull request + + Attach a pull request to this thread. A full URL can point at any repository on a host + this environment has a project for. + + + + { + setDirty(true); + setReference(event.target.value); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void submit(); + }} + /> + {resolved !== null && "link" in resolved ? ( +

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

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

{validation ?? submitError}

+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index ab7b1e61d20..1c98a416cf0 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -61,8 +61,16 @@ import { useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { pullRequestEnvironment } from "~/state/pullRequests"; +import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; +import { useThreadShell } from "~/state/entities"; +import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { PullRequestStackMap } from "./PullRequestStackMap"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -583,6 +591,51 @@ export function PullRequestDetailPanel({ const isStackedPullRequest = detail !== null && isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); + // The host's own stack, where it keeps one. Only asked for once the detail has landed so a + // pull request nobody can read costs one request rather than two. + const nativeStack = useEnvironmentQuery( + detail === null || detail.capabilities.stacks !== true + ? null + : pullRequestStackAtom({ environmentId, input: reference }), + ).data; + // Beside a thread, the panel can attach the pull request it shows to that thread. The thread + // ref is the composer target when it is one; a draft has no thread to link to yet. + const linkableThreadRef = + context === "thread" && + composerDraftTarget !== undefined && + typeof composerDraftTarget !== "string" + ? composerDraftTarget + : null; + const linkableThread = useThreadShell(linkableThreadRef); + const linkedHere = + detail !== null && + linkableThread !== null && + (() => { + const parsed = parseChangeRequestUrl(detail.url); + return ( + parsed !== null && + visibleThreadPullRequests(linkableThread.pullRequests).some((link) => + threadPullRequestKeysEqual(link, parsed), + ) + ); + })(); + const linkToThread = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: true }); + const linkThisPullRequest = useCallback(() => { + if (detail === null || linkableThreadRef === null) return; + const parsed = parseChangeRequestUrl(detail.url); + if (parsed === null) return; + void linkToThread({ + environmentId: linkableThreadRef.environmentId, + input: { + threadId: linkableThreadRef.threadId, + host: parsed.host, + repository: parsed.repository, + number: parsed.number, + url: detail.url, + source: "manual", + }, + }); + }, [detail, linkToThread, linkableThreadRef]); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1293,6 +1346,12 @@ export function PullRequestDetailPanel({ It asks where, because the two answers are not interchangeable: one leaves your work where it is, the other moves the repository you are standing in. Only on the page: beside a thread the branch is already checked out right there. */} + {linkableThreadRef !== null && !linkedHere ? ( + + ) : null} {context === "page" ? ( + {nativeStack ? ( + + ) : null} ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestStackMap.tsx b/apps/web/src/components/pullRequest/PullRequestStackMap.tsx new file mode 100644 index 00000000000..59d8f1fc659 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackMap.tsx @@ -0,0 +1,86 @@ +import type { PullRequestStack } from "@t3tools/contracts"; +import { GitPullRequestArrowIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { resolvePullRequestState } from "./pullRequestPresentation"; + +/** + * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors + * the map GitHub draws above a stacked pull request so a reader who came from there finds the + * same shape here. + */ +export function PullRequestStackMap({ + stack, + currentNumber, + onSelect, + className, +}: { + stack: PullRequestStack; + currentNumber: number; + /** Opens another layer in the same panel; absent where the panel cannot swap references. */ + onSelect?: ((number: number) => void) | undefined; + className?: string; +}) { + return ( +
+ + } + > + + {stack.base} + + + Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it. + + + {stack.layers.map((layer) => { + const presentation = resolvePullRequestState({ state: layer.state, isDraft: false }); + const isCurrent = layer.number === currentNumber; + const chip = ( + + # + {layer.number} + + ); + return ( + + + → + + + onSelect(layer.number)} /> + ) : ( + + ) + } + > + {chip} + + + #{layer.number} · {layer.headBranch} · {presentation.label} + + + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx new file mode 100644 index 00000000000..85f83f3905c --- /dev/null +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -0,0 +1,282 @@ +import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import { + resolveThreadPullRequestChains, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { + GitPullRequestArrow, + LayersIcon, + LinkIcon, + MoreHorizontalIcon, + PlusIcon, +} from "lucide-react"; +import { useCallback, useMemo } from "react"; + +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { cn } from "~/lib/utils"; +import { useThreadShell } from "~/state/entities"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { ScrollArea } from "../ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { openLinkPullRequestDialog } from "./LinkPullRequestDialog"; +import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines"; +import { + PullRequestActorAvatar, + PullRequestDiffStat, + PullRequestStateGlyph, + pullRequestChecksStatePresentation, +} from "./pullRequestPresentation"; + +const SOURCE_LABELS: Record = { + manual: "Linked by you", + created: "Created from this thread", + agent: "Linked by the agent", + stack: "Found in the stack", + "stack-dismissed": "Dismissed", +}; + +function ChecksGlyph({ + state, +}: { + state: NonNullable["checksState"] & string; +}) { + const presentation = pullRequestChecksStatePresentation(state); + return ( + + }> + + + {presentation.label} + + ); +} + +function LinkRow({ + line, + threadRef, + onUnlink, +}: { + line: PullRequestListLine; + threadRef: ScopedThreadRef; + onUnlink: (link: ThreadPullRequestLink) => void; +}) { + const openPrLink = useOpenPrLink(threadRef); + const { link, depth, stack } = line; + const snapshot = link.snapshot; + return ( +
+ {depth > 0 ? : null} + {snapshot === null ? ( + + ) : ( + + )} + openPrLink(event, link.url, threadRef)} + className="min-w-0 flex-1" + > + + + + } + > + #{link.number} + + + {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)} + + + + {snapshot?.title ?? link.repository} + + {/* Right-aligned signals, in the order a reviewer scans them: are checks green, + has someone ruled, how big is it. Each is absent rather than neutral when the + host said nothing, so a row without them reads as unknown, not as fine. */} + + {snapshot?.checksState ? : null} + {snapshot?.state === "open" && + (snapshot.reviewDecision === "approved" || + snapshot.reviewDecision === "changes-requested") ? ( + + {snapshot.reviewDecision === "approved" ? "Approved" : "Changes requested"} + + ) : null} + {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? ( + Conflicts + ) : null} + + + + + {stack ? ( + + + } + > + {stack.kind === "native" ? ( + + ) : ( + + )} + {stack.size} + + + {stack.kind === "native" + ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.` + : `${stack.size} pull requests chained by base branch.`} + + + ) : null} + {snapshot?.author ? ( + + + {snapshot.author.login} + + ) : null} + + {snapshot !== null + ? `${snapshot.headBranch} → ${snapshot.baseBranch}` + : `${link.host}/${link.repository}`} + + {snapshot?.updatedAt ? ( + · {formatRelativeTimeLabel(snapshot.updatedAt)} + ) : null} + + + + + + + } + /> + + void writeTextToClipboard(link.url, "link")}>Copy link + openPrLink(event, link.url, threadRef)}>Open + onUnlink(link)}> + {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} + + + +
+ ); +} + +export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { + const thread = useThreadShell(threadRef); + const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); + const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); + const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); + const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); + const handleUnlink = useCallback( + (link: ThreadPullRequestLink) => { + void unlink({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + host: link.host, + repository: link.repository, + number: link.number, + }, + }); + }, + [threadRef, unlink], + ); + const openCount = useMemo( + () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length, + [links], + ); + const lastSynced = useMemo(() => { + let latest: string | null = null; + for (const link of links) { + const at = link.snapshot?.syncedAt; + if (at !== undefined && (latest === null || at > latest)) latest = at; + } + return latest; + }, [links]); + + if (links.length === 0) { + return ( +
+ +

No linked pull requests

+

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

+ +
+ ); + } + + return ( +
+ +
+ {lines.map((line) => ( + + ))} +
+
+
+ + {openCount} open · {links.length} linked + {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""} + + +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestListLines.test.ts b/apps/web/src/components/pullRequest/pullRequestListLines.test.ts new file mode 100644 index 00000000000..fc86c0a694d --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListLines.test.ts @@ -0,0 +1,76 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import { resolveThreadPullRequestChains } from "@t3tools/shared/threadPullRequests"; +import { describe, expect, it } from "vite-plus/test"; + +import { pullRequestListLines } from "./pullRequestListLines"; + +function link( + number: number, + head: string, + base: string, + updatedAt: string, + stack: ThreadPullRequestLink["stack"] = null, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "acme/web", + number, + url: `https://github.com/acme/web/pull/${number}`, + source: "manual", + linkedAt: "2026-01-01T00:00:00.000Z", + snapshot: { + state: "open", + title: `PR ${number}`, + headBranch: head, + baseBranch: base, + isDraft: false, + updatedAt, + syncedAt: updatedAt, + }, + stack, + }; +} + +describe("pullRequestListLines", () => { + it("orders newest first and keeps a stack together under its base layer", () => { + const lines = pullRequestListLines( + resolveThreadPullRequestChains([ + link(1, "a", "main", "2026-01-01T10:00:00Z"), + link(2, "b", "a", "2026-01-01T12:00:00Z"), + link(9, "solo", "main", "2026-01-01T11:00:00Z"), + link(5, "old", "main", "2026-01-01T09:00:00Z"), + ]), + ); + expect(lines.map((line) => [line.link.number, line.depth, line.stack?.size ?? null])).toEqual([ + // The stack's newest layer is #2 at 12:00, so the whole stack outranks #9 at 11:00. + [1, 0, 2], + [2, 1, null], + [9, 0, null], + [5, 0, null], + ]); + }); + + it("marks native stacks on their base layer", () => { + const stack = { + kind: "native" as const, + id: "1", + number: 1, + url: "https://github.com/acme/web/stacks/1", + base: "main", + layers: [ + { number: 3, headBranch: "x", state: "open" as const }, + { number: 4, headBranch: "y", state: "open" as const }, + ], + }; + const lines = pullRequestListLines( + resolveThreadPullRequestChains([ + link(4, "y", "x", "2026-01-01T10:00:00Z", stack), + link(3, "x", "main", "2026-01-01T10:00:00Z", stack), + ]), + ); + expect(lines.map((line) => [line.link.number, line.depth, line.stack?.kind ?? null])).toEqual([ + [3, 0, "native"], + [4, 1, null], + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestListLines.ts b/apps/web/src/components/pullRequest/pullRequestListLines.ts new file mode 100644 index 00000000000..6602b2f391f --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListLines.ts @@ -0,0 +1,49 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ThreadPullRequestChain } from "@t3tools/shared/threadPullRequests"; + +/** One line of a thread's pull-request list: a link plus how deep it sits in its stack. */ +export interface PullRequestListLine { + readonly link: ThreadPullRequestLink; + /** 0 for a pull request on the base branch; each layer above steps in by one. */ + readonly depth: number; + /** Which chain the line belongs to, so callers can tell one stack's lines from another's. */ + readonly chainKey: string; + /** Set on the bottom layer of a multi-layer stack, so that row can name the whole stack. */ + readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; +} + +function activityAt(link: ThreadPullRequestLink): number { + const ms = Date.parse(link.snapshot?.updatedAt ?? link.linkedAt); + return Number.isNaN(ms) ? 0 : ms; +} + +function chainKeyOf(chain: ThreadPullRequestChain): string { + const bottom = chain.layers[0]!; + return `${bottom.host}/${bottom.repository}#${bottom.number}`; +} + +/** + * Flattens chains into indented lines, newest first. A stack sorts by its most recent layer and + * then reads bottom to top beneath that slot, so the layer you would review first is at the + * bottom of the indent and a fresh push anywhere in the stack floats the whole stack up. + */ +export function pullRequestListLines( + chains: ReadonlyArray, +): ReadonlyArray { + const ordered = [...chains].sort( + (left, right) => + Math.max(...right.layers.map(activityAt)) - Math.max(...left.layers.map(activityAt)), + ); + return ordered.flatMap((chain) => { + const chainKey = chainKeyOf(chain); + return chain.layers.map((link, depth) => ({ + link, + depth, + chainKey, + stack: + depth === 0 && chain.layers.length > 1 + ? { kind: chain.kind, size: chain.layers.length } + : null, + })); + }); +} diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index bd8cfe3d72d..b65ea220a6e 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { changeRequestRepositoryUrl, findProjectForChangeRequest, + findProjectOnChangeRequestHost, gitHubPullRequestBrowserUrl, matchesLinkedPullRequestUrl, openPullRequestLink, @@ -300,6 +301,53 @@ describe("parseChangeRequestUrl", () => { }); }); +describe("findProjectOnChangeRequestHost", () => { + const project = (id: string, identity: Record) => + ({ id, repositoryIdentity: identity }) as never; + const frontend = project("frontend", { + canonicalKey: "github.com/acme/frontend", + provider: "github", + owner: "acme", + name: "frontend", + }); + const backend = project("backend", { + canonicalKey: "github.com/acme/backend", + provider: "github", + owner: "acme", + name: "backend", + }); + + it("prefers the project checked out from the link's own repository", () => { + expect( + findProjectOnChangeRequestHost([frontend, backend], { + host: "github.com", + repository: "acme/backend", + number: 7, + }), + ).toBe(backend); + }); + + it("lends any project on the host to a repository nobody has checked out", () => { + expect( + findProjectOnChangeRequestHost([frontend], { + host: "github.com", + repository: "acme/backend", + number: 7, + }), + ).toBe(frontend); + }); + + it("finds nothing on a host nothing is checked out from", () => { + expect( + findProjectOnChangeRequestHost([frontend], { + host: "gitlab.com", + repository: "acme/backend", + number: 7, + }), + ).toBeUndefined(); + }); +}); + describe("findProjectForChangeRequest", () => { const project = (identity: Record) => ({ id: "p1", repositoryIdentity: identity }) as never; diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 810956c5279..90504f8853a 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -10,6 +10,7 @@ import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { readLocalApi } from "../localApi"; @@ -91,74 +92,12 @@ export function gitHubPullRequestBrowserUrl( } /** - * A change request the page can open, named the way the page names one: the host below which the - * repository is addressed, the repository path as that host writes it, and the number. - * - * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce - * from a git remote — lower case, no port, the full path below the host — because the page matches - * a link against those. Anything else opens nothing. - */ -export interface ChangeRequestLink { - readonly host: string; - readonly repository: string; - readonly number: number; -} - -/** The host itself, one of its subdomains, or an install named after the provider. */ -function isHostOf(hostname: string, apex: string, label?: string): boolean { - if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; - return label !== undefined && hostname.startsWith(`${label}.`); -} - -/** - * The repository and number behind a change request URL on a host the page can read, or null for - * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an - * ordinary link. Null means the system browser, so a doubtful match is worse than no match: it - * takes the reader out of their browser and into a page that cannot find the change request. - * - * Each host is recognised by the path shape it alone uses, guarded by a hostname it could - * plausibly be served from, since self-hosted installs are named whatever their admin chose: - * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic - * enough that it is only believed from a GitHub-ish host. + * The parser is shared with the server (the `create_pr` auto-link and the MCP link tool read the + * same URLs), so there is exactly one opinion on which links are change requests. On the page a + * null result means the system browser: a doubtful match is worse than no match, since it takes + * the reader out of their browser and into a page that cannot find the change request. */ -export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open. - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - // Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test`, - // `github.com-evil.test` and the rest are an open set, and blocking spellings of it costs real - // hosts (`gitlab.com.br` is a registrable domain, not a disguise). What a claim is worth is - // decided where it is used: only a link matching a repository this workspace has checked out - // opens the page, and everything else stays the ordinary link it was. - const host = url.hostname.toLowerCase(); - - // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} - if (isHostOf(host, "github.com", "github")) { - const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/` - // separator is GitLab's own, so the hostname is not asked about. - const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (gitlab) return claim(host, gitlab); - // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n} - if (isHostOf(host, "bitbucket.org", "bitbucket")) { - const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part - // of the repository path there, as it is in the remote URL the identity is read from. - if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) { - const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - return null; -} +export { parseChangeRequestUrl, type ChangeRequestLink }; /** Match a stored PR without requiring its project to remain available. */ export function matchesLinkedPullRequestUrl( @@ -193,14 +132,6 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null { return url.toString(); } -function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { - const repository = match?.[1]; - const number = Number(match?.[2]); - return repository && Number.isSafeInteger(number) && number > 0 - ? { host, repository: repository.toLowerCase(), number } - : null; -} - /** * Returns a click handler that opens a pull request URL in the system browser. * @@ -234,6 +165,28 @@ export function findProjectForChangeRequest( }); } +/** + * Any project checked out from the link's host. Thread links are host-level, so a pull request + * from a repository nobody has checked out is still linkable as long as one project on that + * host can lend the server its credentials. The link's own project, when it exists, comes first. + */ +export function findProjectOnChangeRequestHost( + projects: ReadonlyArray, + link: ChangeRequestLink, +): EnvironmentProject | undefined { + const own = findProjectForChangeRequest(projects, link); + if (own !== undefined) return own; + return projects.find((project) => { + const identity = project.repositoryIdentity; + const kind = identity?.provider as SourceControlProviderKind | undefined; + return ( + identity != null && + kind !== undefined && + pullRequestHostOf(identity, kind) === link.host.toLowerCase() + ); + }); +} + /** * Opens a change request link on the page, and says whether it did. Anything else — another * organisation's repository, a host nothing here is checked out from, a link that merely looks diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index ca9a5986c66..4f2f2a58656 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -34,6 +34,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 2c70c884467..f28cc15cd10 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -21,6 +21,7 @@ export const RIGHT_PANEL_KINDS = [ "preview", "terminal", "pull-request", + "pull-requests", "agents", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -63,6 +64,8 @@ export type RightPanelSurface = repository: string; number: number; } + /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */ + | { id: "pull-requests"; kind: "pull-requests" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; @@ -135,6 +138,8 @@ const singletonSurface = ( return { id: "diff", kind }; case "files": return { id: "files", kind }; + case "pull-requests": + return { id: "pull-requests", kind }; case "agents": return { id: "agents", kind }; } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index e3465b49c73..49da5a8e315 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1914,12 +1914,14 @@ function PullRequestsRouteView() { onAddDiff={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} + onAddPullRequests={() => undefined} onAddAgents={() => undefined} browserAvailable={false} terminalAvailable={false} diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} + pullRequestsAvailable={false} agentsAvailable={false} liveAgentCount={0} pullRequestStatusSeeds={listedPullRequestTabStatuses} diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 601b6efa3dc..a57dd420296 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, + createPullRequestStackAtomFamily, } from "@t3tools/client-runtime/state/pull-requests"; import type { EnvironmentId, @@ -24,6 +25,7 @@ import { formatEnvironmentQueryError } from "./query"; export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); +export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime); export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df..021fb255a4b 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -218,6 +218,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { commitMessage?: string; featureBranch?: boolean; filePaths?: string[]; + threadId?: ThreadId; onProgress?: (event: GitActionProgressEvent) => void; }) => { if (resolveScope(scope) === null) { @@ -237,6 +238,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), ...(input.onProgress ? { onProgress: input.onProgress } : {}), }); }, diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 89734357889..b16342ced78 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -21,6 +21,7 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], checkpoints: [], + pullRequests: [], activities: [], proposedPlans: [], createdAt: "2026-02-13T00:00:00.000Z", diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index c1b4251f91d..c006f1d2719 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -43,6 +43,18 @@ A single user-to-assistant work cycle inside a thread. It starts with user input A user-visible log item attached to a thread. In [the contracts][1], activities cover important non-message events like approvals, tool actions, and failures. They are projected into thread state in [projector.ts][4]. +#### Pull request link + +A thread's durable association with one pull request, identified at the host level by `(host, repository, number)` so the same pull request linked from two projects is one identity. A thread holds any number of links (`pullRequests` in [the contracts][1]) and a pull request can be linked from any number of threads. Each link records its `source` (`manual`, `created` by the git action, `agent` via the MCP tool, or `stack` when discovered as a member of a host-native stack) and, once the sync reactor has read it, a `snapshot` of host state and any native `stack` it belongs to. Links are created by `thread.pull-request.link` and removed by `thread.pull-request.unlink` in [decider.ts][8]; unlinking a `stack` member leaves a `stack-dismissed` tombstone so the next sync does not re-add it. Persisted in `projection_thread_pull_requests` by [ProjectionPipeline.ts][11]. + +#### Pull request sync + +`PullRequestSyncReactor` (`apps/server/src/orchestration/PullRequestSyncReactor.ts`) sweeps every minute, groups every visible link across threads by its host-level key, and reads the host once per key through `PullRequestService.summary` (and `stack` when the summary changed). Cadence per key: unsynced → now; open with an unsettled thread → every sweep; open with every thread settled → 15 minutes; merged or closed → only on `requestSync`, which the `pullRequests.invalidate` RPC calls. A changed snapshot dispatches `thread.pull-request.sync` for each thread whose stored link differs; an unchanged one dispatches nothing. Members of a host-native stack that the thread does not yet link are linked with `source: "stack"` unless a `stack-dismissed` tombstone says the user removed them. `ThreadSettlementReactor` reads link snapshots instead of the host: any open link blocks settlement, all-terminal links settle on the latest `updatedAt`. + +#### Current pull request + +The one link a single-slot surface (sidebar badge, tab icon, copy-link) shows, derived from a thread's links by `resolveThreadCurrentPullRequest` in `@t3tools/shared/threadPullRequests`: a single open link wins; several open links are reported as a stack with its top layer; with nothing open, the most recently updated terminal link stands in. The legacy `linkedPullRequest` field on a thread is this derivation, kept on the wire until every client reads `pullRequests`. + ### Orchestration Orchestration is the server-side domain layer that turns runtime activity into stable app state. The main entry point is [OrchestrationEngine.ts][7], with core logic in [decider.ts][8] and [projector.ts][4]. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index ac11e3a06f4..5c75d070747 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -52,6 +52,28 @@ T3 Code works with the platforms your team already uses: - Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open it in your browser instead of in T3 Code - Check out a teammate's branch to review code locally +**Keep every pull request with the thread that made it** + +- A thread can hold any number of linked pull requests. Creating one from the Git actions + controls links it. The agent links the ones it opens itself, including each layer of a stack, + through its `link_pull_request` tool. Paste a URL or a `#123` into **Link pull request** from + the command palette, the **Linked pull requests** panel, or a review open beside the thread +- A URL can point at a different repository on the same host, so a frontend thread can carry the + backend pull request it caused +- The **Linked pull requests** panel (add surface → **Linked pull requests**, or `L` in the add + menu) lists them all. Stacks GitHub knows about appear as one group, bottom to top; pull + requests that chain by base branch on other hosts are grouped the same way +- The sidebar shows the open pull request. With several open at once it shows a stack badge with + the count; click it to open the panel +- Unlink from the panel row's menu. A stack layer you unlink stays out even when the stack is + next synced +- Link state refreshes on the server, once per pull request no matter how many threads share + it: every minute while the pull request is open and a thread is active, every fifteen minutes + once every thread is settled, and never once it is merged or closed unless you refresh the + review +- With **Auto-settle merged threads** on, a thread settles once every linked pull request has + merged or closed; a single open one keeps it active + **Fix what you wrote, in place** - Rewrite a pull request's title and description from the review itself, in Markdown, with a diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2f24e22b3b9..e00ab55220d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -31,8 +31,10 @@ When you un-settle a thread, it returns to the top of the active list so you can away. Its timestamps do not change. Other threads keep their positions. Right-click a pull request link in a thread and choose **Link to thread** to show that pull request -in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged -threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it. +in the sidebar. A thread can hold any number of linked pull requests, including ones from another +repository on the same host, and the sidebar shows the open one. The thread settles when the linked +pull request merges if **Auto-settle merged threads** is enabled. Right-click the same link and +choose **Unlink from thread** to remove it. On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu and choose **Move up** or **Move down**. The order is stored by the server and appears on your diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b77..41ad66ad20c 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -43,6 +43,8 @@ export type PinThreadInput = CommandInput<"thread.pin">; export type UnpinThreadInput = CommandInput<"thread.unpin">; export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; +export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">; +export type UnlinkThreadPullRequestInput = CommandInput<"thread.pull-request.unlink">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; export type StartThreadTurnInput = CommandInput<"thread.turn.start">; @@ -240,6 +242,24 @@ export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => Command }); }); +export const linkThreadPullRequest: (input: LinkThreadPullRequestInput) => CommandEffect = + Effect.fn("EnvironmentCommands.linkThreadPullRequest")(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.pull-request.link", + commandId: yield* commandId(input), + }); + }); + +export const unlinkThreadPullRequest: (input: UnlinkThreadPullRequestInput) => CommandEffect = + Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.pull-request.unlink", + commandId: yield* commandId(input), + }); + }); + export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( "EnvironmentCommands.setThreadRuntimeMode", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index d3bb6680208..51e474ab8c9 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -98,6 +98,7 @@ const THREAD_SHELL = { archivedAt: null, settledOverride: null, settledAt: null, + pullRequests: [], session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index b98524e0482..1bcb3edf209 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -47,6 +47,18 @@ export function createLinkedPullRequestSummaryAtomFamily( }); } +/** The host-native stack a pull request belongs to; null where it is not stacked. */ +export function createPullRequestStackAtomFamily( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:stack", + tag: WS_METHODS.pullRequestsStack, + staleTimeMs: 60_000, + idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, + }); +} + export function pullRequestDetailToVcsStatus( detail: PullRequestDetail | PullRequestSummary, ): NonNullable { diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index fdccc4c47dd..6ed44ce62e7 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -38,6 +38,7 @@ const stubThread = { archivedAt: null, settledOverride: null, settledAt: null, + pullRequests: [], latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c540644289d..d20b78df1a4 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,6 +12,7 @@ import { type CreateThreadInput, type DeleteThreadInput, type InterruptThreadTurnInput, + type LinkThreadPullRequestInput, type RespondToThreadApprovalInput, type RespondToThreadUserInputInput, type RevertThreadCheckpointInput, @@ -24,6 +25,7 @@ import { type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnlinkThreadPullRequestInput, type UnpinThreadInput, type UnsettleThreadInput, type UnsnoozeThreadInput, @@ -32,6 +34,7 @@ import { createThread, deleteThread, interruptThreadTurn, + linkThreadPullRequest, respondToThreadApproval, respondToThreadUserInput, revertThreadCheckpoint, @@ -44,6 +47,7 @@ import { startThreadTurn, stopThreadSession, unarchiveThread, + unlinkThreadPullRequest, unpinThread, unsettleThread, unsnoozeThread, @@ -56,6 +60,7 @@ export type { CreateThreadInput, DeleteThreadInput, InterruptThreadTurnInput, + LinkThreadPullRequestInput, RespondToThreadApprovalInput, RespondToThreadUserInputInput, RevertThreadCheckpointInput, @@ -68,6 +73,7 @@ export type { StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnlinkThreadPullRequestInput, UnpinThreadInput, UnsettleThreadInput, UnsnoozeThreadInput, @@ -156,6 +162,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + linkPullRequest: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:link-pull-request", + execute: (input: LinkThreadPullRequestInput) => linkThreadPullRequest(input), + scheduler, + concurrency, + }), + unlinkPullRequest: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unlink-pull-request", + execute: (input: UnlinkThreadPullRequestInput) => unlinkThreadPullRequest(input), + scheduler, + concurrency, + }), setRuntimeMode: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:set-runtime-mode", execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 40198099766..9537089167a 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -37,6 +37,7 @@ const baseThread: OrchestrationThread = { archivedAt: null, settledOverride: null, settledAt: null, + pullRequests: [], deletedAt: null, messages: [], proposedPlans: [], @@ -352,6 +353,129 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread pull request links", () => { + const link = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual" as const, + linkedAt: "2026-04-01T05:00:00.000Z", + snapshot: null, + stack: null, + }; + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 42 }; + const linkEvent = (sequence: number) => + ({ + ...baseEventFields, + sequence, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pull-request-linked", + payload: { + threadId: ThreadId.make("thread-1"), + link, + updatedAt: "2026-04-01T05:00:00.000Z", + }, + }) as const; + + it("links, derives the compat field, and replaces by key", () => { + const linked = applyThreadDetailEvent(baseThread, linkEvent(5)); + expect(linked.kind).toBe("updated"); + if (linked.kind !== "updated") return; + expect(linked.thread.pullRequests).toEqual([link]); + expect(linked.thread.linkedPullRequest).toEqual({ + projectId: baseThread.projectId, + repository: "pingdotgg/t3code", + number: 42, + url: link.url, + }); + + const relinked = applyThreadDetailEvent(linked.thread, { + ...linkEvent(6), + payload: { + threadId: ThreadId.make("thread-1"), + link: { ...link, host: "GitHub.com", source: "agent" }, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); + if (relinked.kind !== "updated") throw new Error("expected update"); + expect(relinked.thread.pullRequests).toHaveLength(1); + expect(relinked.thread.pullRequests[0]?.source).toBe("agent"); + }); + + it("syncs snapshot and stack onto the matching link only", () => { + const linked = applyThreadDetailEvent(baseThread, linkEvent(5)); + if (linked.kind !== "updated") throw new Error("expected update"); + const snapshot = { + state: "merged" as const, + title: "Ship it", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-04-02T00:00:00.000Z", + syncedAt: "2026-04-02T00:01:00.000Z", + }; + const synced = applyThreadDetailEvent(linked.thread, { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-02T00:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pull-request-synced", + payload: { + threadId: ThreadId.make("thread-1"), + ...key, + snapshot, + stack: null, + updatedAt: "2026-04-02T00:01:00.000Z", + }, + }); + if (synced.kind !== "updated") throw new Error("expected update"); + expect(synced.thread.pullRequests[0]?.snapshot).toEqual(snapshot); + + const unknown = applyThreadDetailEvent(synced.thread, { + ...baseEventFields, + sequence: 7, + occurredAt: "2026-04-02T00:02:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pull-request-synced", + payload: { + threadId: ThreadId.make("thread-1"), + ...key, + number: 99, + snapshot, + stack: null, + updatedAt: "2026-04-02T00:02:00.000Z", + }, + }); + expect(unknown.kind).toBe("unchanged"); + }); + + it("unlinks and clears the compat field", () => { + const linked = applyThreadDetailEvent(baseThread, linkEvent(5)); + if (linked.kind !== "updated") throw new Error("expected update"); + const unlinked = applyThreadDetailEvent(linked.thread, { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pull-request-unlinked", + payload: { + threadId: ThreadId.make("thread-1"), + ...key, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); + if (unlinked.kind !== "updated") throw new Error("expected update"); + expect(unlinked.thread.pullRequests).toEqual([]); + expect(unlinked.thread.linkedPullRequest).toBeNull(); + }); + }); + describe("thread.message-sent", () => { it("appends a new message", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 690c74bdea0..814e7427f8c 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -10,14 +10,36 @@ import type { OrchestrationSession, OrchestrationThread, OrchestrationThreadActivity, + ThreadPullRequestLink, TurnId, } from "@t3tools/contracts"; +import { + legacyLinkedPullRequestOf, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } | { readonly kind: "deleted" } | { readonly kind: "unchanged" }; +/** Links changed; the compat `linkedPullRequest` field follows them. */ +function withPullRequests( + thread: OrchestrationThread, + pullRequests: ReadonlyArray, + updatedAt: string, +): ThreadDetailReducerResult { + return { + kind: "updated", + thread: { + ...thread, + pullRequests, + linkedPullRequest: legacyLinkedPullRequestOf(pullRequests, thread.projectId), + updatedAt, + }, + }; +} + const proposedPlanOrder = O.combine( O.mapInput(O.String, (p) => p.createdAt), O.mapInput(O.String, (p) => p.id), @@ -105,6 +127,7 @@ export function applyThreadDetailEvent( snoozedUntil: null, snoozedAt: null, deletedAt: null, + pullRequests: [], messages: [], proposedPlans: [], activities: [], @@ -242,6 +265,40 @@ export function applyThreadDetailEvent( }, }; + case "thread.pull-request-linked": { + const link = event.payload.link; + const others = thread.pullRequests.filter( + (existing) => !threadPullRequestKeysEqual(existing, link), + ); + return withPullRequests(thread, [...others, link], event.payload.updatedAt); + } + + case "thread.pull-request-unlinked": + return withPullRequests( + thread, + thread.pullRequests.filter( + (existing) => !threadPullRequestKeysEqual(existing, event.payload), + ), + event.payload.updatedAt, + ); + + case "thread.pull-request-synced": { + if ( + !thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, event.payload)) + ) { + return { kind: "unchanged" }; + } + return withPullRequests( + thread, + thread.pullRequests.map((existing) => + threadPullRequestKeysEqual(existing, event.payload) + ? { ...existing, snapshot: event.payload.snapshot, stack: event.payload.stack } + : existing, + ), + event.payload.updatedAt, + ); + } + case "thread.runtime-mode-set": return { kind: "updated", diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 2cede4f5b3e..032f72f805a 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -104,6 +104,7 @@ const BASE_THREAD: OrchestrationThread = { archivedAt: null, settledOverride: null, settledAt: null, + pullRequests: [], deletedAt: null, messages: [RECENT_MESSAGE], proposedPlans: [], diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index d94ed3a3fd7..eb57f49f91f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -71,6 +71,7 @@ const BASE_THREAD: OrchestrationThread = { archivedAt: null, settledOverride: null, settledAt: null, + pullRequests: [], deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 90597297560..24aa314b1cf 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -1,7 +1,9 @@ import { EnvironmentId, + ThreadId, WS_METHODS, type GitActionProgressEvent, + type GitRunStackedActionInput, type GitRunStackedActionResult, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -589,9 +591,10 @@ describe("vcsActionState", () => { successfulActionId, ); const failedTransportActionId = createVcsActionTransportId(targetKey, failedActionId); + const rpcInputs = new Array(); const client = { - [WS_METHODS.gitRunStackedAction]: (input: { readonly actionId: string }) => - input.actionId === successfulTransportActionId + [WS_METHODS.gitRunStackedAction]: (input: GitRunStackedActionInput) => + (rpcInputs.push(input), input.actionId === successfulTransportActionId) ? Stream.make( progress({ kind: "action_finished", @@ -652,16 +655,22 @@ describe("vcsActionState", () => { const state = vcsRefsCacheStateAtom({ environmentId }); expect(registry.get(state).revision).toBe(0); + const threadId = ThreadId.make("thread-stacked-action"); const successfulResult = yield* Effect.promise(() => manager.runStackedAction(targetKey).run(registry, { actionId: successfulActionId, action, + threadId, }), ); expect(AsyncResult.isSuccess(successfulResult)).toBe(true); expect(registry.get(state).revision).toBe(1); expect(removed).toEqual([`${environmentId}:*`]); + // The server links a created pull request to this thread, so the id must ride along. + expect(rpcInputs).toEqual([ + { actionId: successfulTransportActionId, cwd, action, threadId }, + ]); const failedResult = yield* Effect.promise(() => manager.runStackedAction(targetKey).run(registry, { diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f0c3791e35b..015f105ee14 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -6,6 +6,7 @@ import { type GitRunStackedActionInput, type GitRunStackedActionResult, GitStackedAction, + type ThreadId, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -76,6 +77,8 @@ export interface RunVcsStackedActionInput { readonly commitMessage?: string; readonly featureBranch?: boolean; readonly filePaths?: ReadonlyArray; + /** The thread the action runs beside; the server links a pull request it creates to it. */ + readonly threadId?: ThreadId; readonly onProgress?: (event: GitActionProgressEvent) => void; } @@ -463,6 +466,7 @@ export function createVcsActionManager( ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), }; return consumeVcsActionProgress( runStreamInEnvironment( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 533411e5137..646248dd4f2 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -84,8 +84,13 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), - /** Server persists a pull request reference on thread.meta.update. */ + /** Server persists a pull request reference on thread.meta.update. Superseded by + threadPullRequests; servers that set the new flag no longer set this one. */ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.pull-request.link / .unlink, exposes `pullRequests` on + threads, and routes PullRequestRef.host across projects on the same host. Same + version-skew contract as threadSettlement. */ + threadPullRequests: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d39be34bf6e..237e84168e5 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -119,6 +119,8 @@ export const GitRunStackedActionInput = Schema.Struct({ filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), ), + /** The thread the action runs beside; a pull request it creates is linked to it. */ + threadId: Schema.optional(ThreadId), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 4ae91d27c18..8758c564bf9 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -569,6 +569,76 @@ it.effect("defaults settled fields when decoding historical thread data", () => assert.strictEqual(thread.settledAt, null); assert.strictEqual(shell.settledOverride, null); assert.strictEqual(shell.settledAt, null); + // Pre-link servers omit the array entirely. + assert.deepStrictEqual(thread.pullRequests, []); + assert.deepStrictEqual(shell.pullRequests, []); + }), +); + +it.effect("decodes thread pull request links with snapshot and stack", () => + Effect.gen(function* () { + const shell = yield* decodeOrchestrationThreadShell({ + id: "thread-1", + projectId: "project-1", + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: "feature/stack-2", + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + pullRequests: [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "agent", + linkedAt: "2026-01-01T00:00:00.000Z", + snapshot: null, + stack: null, + }, + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + source: "stack", + linkedAt: "2026-01-01T00:01:00.000Z", + snapshot: { + state: "open", + title: "Layer two", + headBranch: "feature/stack-2", + baseBranch: "feature/stack-1", + isDraft: false, + updatedAt: "2026-01-01T00:02:00.000Z", + syncedAt: "2026-01-01T00:03:00.000Z", + }, + stack: { + kind: "native", + id: "7", + number: 3, + url: "https://github.com/pingdotgg/t3code/stacks/3", + base: "main", + layers: [ + { number: 42, headBranch: "feature/stack-1", state: "open" }, + { number: 43, headBranch: "feature/stack-2", state: "open" }, + ], + }, + }, + ], + }); + + assert.strictEqual(shell.pullRequests.length, 2); + assert.strictEqual(shell.pullRequests[1]?.stack?.layers.length, 2); + assert.strictEqual(shell.pullRequests[1]?.snapshot?.state, "open"); }), ); @@ -798,25 +868,64 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () => }), ); -it.effect("accepts a linked pull request in thread.meta.update", () => +it.effect("accepts thread.pull-request.link and .unlink commands", () => Effect.gen(function* () { - const linkedPullRequest = { - projectId: "project-1", + const link = yield* decodeOrchestrationCommand({ + type: "thread.pull-request.link", + commandId: "cmd-link-pull-request", + threadId: "thread-1", + host: "github.com", repository: "pingdotgg/t3code", number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const parsed = yield* decodeOrchestrationCommand({ - type: "thread.meta.update", - commandId: "cmd-link-pull-request", + source: "manual", + }); + assert.strictEqual(link.type, "thread.pull-request.link"); + if (link.type === "thread.pull-request.link") { + assert.strictEqual(link.source, "manual"); + assert.strictEqual(link.number, 42); + } + + const unlink = yield* decodeOrchestrationCommand({ + type: "thread.pull-request.unlink", + commandId: "cmd-unlink-pull-request", threadId: "thread-1", - linkedPullRequest, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, }); + assert.strictEqual(unlink.type, "thread.pull-request.unlink"); + }), +); - assert.strictEqual(parsed.type, "thread.meta.update"); - if (parsed.type === "thread.meta.update") { - assert.deepStrictEqual(parsed.linkedPullRequest, linkedPullRequest); +it.effect("still decodes a persisted thread.meta-updated event carrying linkedPullRequest", () => + Effect.gen(function* () { + const event = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-legacy-link", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.meta-updated", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-legacy-link", + causationEventId: null, + correlationId: "cmd-legacy-link", + metadata: {}, + payload: { + threadId: "thread-1", + linkedPullRequest: { + projectId: "project-1", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + if (event.type !== "thread.meta-updated") { + assert.fail(`Expected thread.meta-updated event, received ${event.type}.`); } + assert.strictEqual(event.payload.linkedPullRequest?.number, 42); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2d792876f54..842ef195772 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -23,6 +23,13 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + PullRequestActor, + PullRequestChecksState, + PullRequestMergeability, + PullRequestReviewDecision, + PullRequestState, +} from "./pullRequest.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -429,6 +436,11 @@ export const ThreadTitleRegeneration = Schema.Struct({ }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; +/** + * Legacy single-PR link. Still emitted as the thread's derived current pull + * request (see `@t3tools/shared/threadPullRequests`) so clients from before + * `pullRequests` keep working; removed once mobile has shipped on the array. + */ export const ThreadLinkedPullRequest = Schema.Struct({ projectId: ProjectId, repository: TrimmedNonEmptyString, @@ -437,6 +449,78 @@ export const ThreadLinkedPullRequest = Schema.Struct({ }); export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type; +/** Who created a thread ↔ pull request link. `stack-dismissed` is a tombstone + * for a native-stack member the user unlinked, so the sync reactor does not + * re-add it; clients hide it. */ +export const ThreadPullRequestLinkSource = Schema.Literals([ + "manual", + "created", + "agent", + "stack", + "stack-dismissed", +]); +export type ThreadPullRequestLinkSource = typeof ThreadPullRequestLinkSource.Type; + +/** + * Host state persisted on a link by the sync reactor; null until first sync. The overview + * fields are optional: a host whose cheap read lacks them leaves them out, and snapshots + * written before they existed still decode. + */ +export const ThreadPullRequestSnapshot = Schema.Struct({ + state: PullRequestState, + title: TrimmedNonEmptyString, + headBranch: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + isDraft: Schema.Boolean, + updatedAt: Schema.NullOr(IsoDateTime), + syncedAt: IsoDateTime, + author: Schema.optional(Schema.NullOr(PullRequestActor)), + additions: Schema.optional(NonNegativeInt), + deletions: Schema.optional(NonNegativeInt), + changedFiles: Schema.optional(NonNegativeInt), + reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)), + checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)), + mergeability: Schema.optional(PullRequestMergeability), +}); +export type ThreadPullRequestSnapshot = typeof ThreadPullRequestSnapshot.Type; + +export const ThreadPullRequestStackLayer = Schema.Struct({ + number: PositiveInt, + headBranch: TrimmedNonEmptyString, + state: PullRequestState, +}); +export type ThreadPullRequestStackLayer = typeof ThreadPullRequestStackLayer.Type; + +/** A host-native stack the pull request belongs to. Layers run bottom to top. */ +export const ThreadPullRequestStack = Schema.Struct({ + kind: Schema.Literal("native"), + id: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, + base: TrimmedNonEmptyString, + layers: Schema.Array(ThreadPullRequestStackLayer), +}); +export type ThreadPullRequestStack = typeof ThreadPullRequestStack.Type; + +/** Identity of a pull request as a thread link sees it: host-level, so the + * same PR linked from two projects (or two environments) compares equal. */ +export const ThreadPullRequestKey = Schema.Struct({ + host: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + number: PositiveInt, +}); +export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; + +export const ThreadPullRequestLink = Schema.Struct({ + ...ThreadPullRequestKey.fields, + url: TrimmedNonEmptyString, + source: ThreadPullRequestLinkSource, + linkedAt: IsoDateTime, + snapshot: Schema.NullOr(ThreadPullRequestSnapshot), + stack: Schema.NullOr(ThreadPullRequestStack), +}); +export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -449,6 +533,10 @@ export const OrchestrationThread = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + // Optional so payloads from pre-link servers still decode. + pullRequests: Schema.Array(ThreadPullRequestLink).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -526,6 +614,9 @@ export const OrchestrationThreadShell = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + pullRequests: Schema.Array(ThreadPullRequestLink).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -854,7 +945,6 @@ const ThreadMetaUpdateCommand = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), }).check( Schema.makeFilter( (input) => @@ -863,6 +953,22 @@ const ThreadMetaUpdateCommand = Schema.Struct({ ), ); +const ThreadPullRequestLinkCommand = Schema.Struct({ + type: Schema.Literal("thread.pull-request.link"), + commandId: CommandId, + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + url: TrimmedNonEmptyString, + source: ThreadPullRequestLinkSource, +}); + +const ThreadPullRequestUnlinkCommand = Schema.Struct({ + type: Schema.Literal("thread.pull-request.unlink"), + commandId: CommandId, + threadId: ThreadId, + ...ThreadPullRequestKey.fields, +}); + const ThreadRuntimeModeSetCommand = Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), commandId: CommandId, @@ -1008,6 +1114,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUnpinCommand, ThreadPinReorderCommand, ThreadMetaUpdateCommand, + ThreadPullRequestLinkCommand, + ThreadPullRequestUnlinkCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ThreadTurnStartCommand, @@ -1036,6 +1144,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUnpinCommand, ThreadPinReorderCommand, ThreadMetaUpdateCommand, + ThreadPullRequestLinkCommand, + ThreadPullRequestUnlinkCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, @@ -1120,8 +1230,19 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +/** Server-only: the sync reactor writes host state onto an existing link. */ +const ThreadPullRequestSyncCommand = Schema.Struct({ + type: Schema.Literal("thread.pull-request.sync"), + commandId: CommandId, + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + snapshot: ThreadPullRequestSnapshot, + stack: Schema.NullOr(ThreadPullRequestStack), +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadAutoSettleCommand, + ThreadPullRequestSyncCommand, ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, @@ -1155,6 +1276,9 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unpinned", "thread.pin-reordered", "thread.meta-updated", + "thread.pull-request-linked", + "thread.pull-request-unlinked", + "thread.pull-request-synced", "thread.runtime-mode-set", "thread.interaction-mode-set", "thread.message-sent", @@ -1300,10 +1424,35 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // No longer produced; kept so persisted events from before + // thread.pull-request-linked still decode and replay into the link table. linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), updatedAt: IsoDateTime, }); +export const ThreadPullRequestLinkedPayload = Schema.Struct({ + threadId: ThreadId, + link: ThreadPullRequestLink, + updatedAt: IsoDateTime, +}); +export type ThreadPullRequestLinkedPayload = typeof ThreadPullRequestLinkedPayload.Type; + +export const ThreadPullRequestUnlinkedPayload = Schema.Struct({ + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + updatedAt: IsoDateTime, +}); +export type ThreadPullRequestUnlinkedPayload = typeof ThreadPullRequestUnlinkedPayload.Type; + +export const ThreadPullRequestSyncedPayload = Schema.Struct({ + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + snapshot: ThreadPullRequestSnapshot, + stack: Schema.NullOr(ThreadPullRequestStack), + updatedAt: IsoDateTime, +}); +export type ThreadPullRequestSyncedPayload = typeof ThreadPullRequestSyncedPayload.Type; + export const ThreadRuntimeModeSetPayload = Schema.Struct({ threadId: ThreadId, runtimeMode: RuntimeMode, @@ -1515,6 +1664,21 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.meta-updated"), payload: ThreadMetaUpdatedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.pull-request-linked"), + payload: ThreadPullRequestLinkedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.pull-request-unlinked"), + payload: ThreadPullRequestUnlinkedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.pull-request-synced"), + payload: ThreadPullRequestSyncedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.runtime-mode-set"), diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index e33615fa4c0..2f81c936d94 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -631,14 +631,31 @@ export const PreviewAutomationResponse = Schema.Struct({ }); export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; +const McpCapabilityErrorFields = { + environmentId: EnvironmentId, + threadId: ThreadId, + providerSessionId: TrimmedNonEmptyString, + providerInstanceId: ProviderInstanceId, +}; + export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass()( "PreviewAutomationUnavailableError", { capability: Schema.Literal("preview"), - environmentId: EnvironmentId, - threadId: ThreadId, - providerSessionId: TrimmedNonEmptyString, - providerInstanceId: ProviderInstanceId, + ...McpCapabilityErrorFields, + }, +) { + override get message(): string { + return `MCP credential does not grant the ${this.capability} capability.`; + } +} + +/** A `t3-code` MCP tool was called with a credential that does not carry its capability. */ +export class McpCapabilityUnavailableError extends Schema.TaggedErrorClass()( + "McpCapabilityUnavailableError", + { + capability: TrimmedNonEmptyString, + ...McpCapabilityErrorFields, }, ) { override get message(): string { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index ce7fd0973cc..732ad5e9ae2 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -392,6 +392,11 @@ export const PullRequestCapabilities = Schema.Struct({ * what every server before this one was. */ edit: Schema.optional(PullRequestEditCapabilities), + /** + * The host keeps stacks of change requests as objects of its own, so a linked thread can show + * the stack the host shows. Absent means chains are only ever inferred from base branches. + */ + stacks: Schema.optional(Schema.Boolean), }); export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; @@ -583,8 +588,16 @@ export const PullRequestListResult = Schema.Struct({ }); export type PullRequestListResult = typeof PullRequestListResult.Type; +/** + * Addresses one pull request for reads and writes. `projectId` picks the checkout the host + * CLI runs in and, when its repository matches, the credentials; `host` lets the server + * route a pull request from another repository through any project on the same host + * (a frontend project's thread linking a backend PR). Absent `host` means "the project's + * own host", which is every reference from before thread links became host-level. + */ export const PullRequestRef = Schema.Struct({ projectId: ProjectId, + host: Schema.optional(TrimmedNonEmptyString), repository: TrimmedNonEmptyString, number: PositiveInt, }); @@ -605,9 +618,34 @@ export const PullRequestSummary = Schema.Struct({ headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, updatedAt: IsoDateTime, + /** Optional so summaries from servers that never read it still decode. */ + isDraft: Schema.optional(Schema.Boolean), + author: Schema.optional(Schema.NullOr(PullRequestActor)), + additions: Schema.optional(NonNegativeInt), + deletions: Schema.optional(NonNegativeInt), + changedFiles: Schema.optional(NonNegativeInt), + reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)), + checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)), + mergeability: Schema.optional(PullRequestMergeability), }); export type PullRequestSummary = typeof PullRequestSummary.Type; +/** The host-native stack a pull request belongs to, in the thread link's shape. */ +export const PullRequestStack = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, + base: TrimmedNonEmptyString, + layers: Schema.Array( + Schema.Struct({ + number: PositiveInt, + headBranch: TrimmedNonEmptyString, + state: PullRequestState, + }), + ), +}); +export type PullRequestStack = typeof PullRequestStack.Type; + /** * One row's line counts, read after the listing rather than inside it. On GitHub the pair is * 40-60% of the wall clock of the search that answers the whole page — measured over twelve diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9c009baabcc..8d2e164b56f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -97,6 +97,7 @@ import { PullRequestOperationError, PullRequestReactionInput, PullRequestRef, + PullRequestStack, PullRequestSummary, PullRequestReviewerCandidateList, PullRequestReviewerRequestInput, @@ -304,6 +305,7 @@ export const WS_METHODS = { pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", pullRequestsSummary: "pullRequests.summary", + pullRequestsStack: "pullRequests.stack", pullRequestsDetail: "pullRequests.detail", pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", @@ -529,6 +531,12 @@ export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, error: PullRequestRpcError, }); +export const WsPullRequestsStackRpc = Rpc.make(WS_METHODS.pullRequestsStack, { + payload: PullRequestRef, + success: Schema.NullOr(PullRequestStack), + error: PullRequestRpcError, +}); + export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { payload: PullRequestRef, success: PullRequestDetail, @@ -1072,6 +1080,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsListRpc, WsPullRequestsListStatsRpc, WsPullRequestsSummaryRpc, + WsPullRequestsStackRpc, WsPullRequestsDetailRpc, WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index fda7a91b1a2..f7584107549 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -171,6 +171,14 @@ "types": "./src/threadReference.ts", "import": "./src/threadReference.ts" }, + "./threadPullRequests": { + "types": "./src/threadPullRequests.ts", + "import": "./src/threadPullRequests.ts" + }, + "./changeRequestUrl": { + "types": "./src/changeRequestUrl.ts", + "import": "./src/changeRequestUrl.ts" + }, "./composerTrigger": { "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts new file mode 100644 index 00000000000..16560af5184 --- /dev/null +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; + +describe("parseChangeRequestUrl", () => { + it("reads a GitHub pull request, lower-casing the repository", () => { + expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("reads a pull request on a GitHub Enterprise host", () => { + expect(parseChangeRequestUrl("https://github.acme.test/platform/api/pull/7")).toEqual({ + host: "github.acme.test", + repository: "platform/api", + number: 7, + }); + }); + + it("reads a GitLab merge request on any host, nested groups and all", () => { + expect( + parseChangeRequestUrl("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42"), + ).toEqual({ host: "gitlab.com", repository: "t3tools/platform/t3code", number: 42 }); + expect(parseChangeRequestUrl("https://code.acme.test/team/project/-/merge_requests/9")).toEqual( + { host: "code.acme.test", repository: "team/project", number: 9 }, + ); + }); + + it("reads Bitbucket and both Azure DevOps URL forms", () => { + expect(parseChangeRequestUrl("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({ + host: "bitbucket.org", + repository: "workspace/repo", + number: 5, + }); + expect( + parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"), + ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 }); + expect( + parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), + ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); + }); + + it("survives trailing segments, a trailing slash and a query string", () => { + expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("claims nothing it cannot be sure of", () => { + for (const link of [ + "https://github.com/t3tools/t3code/issues/123", + "https://github.com/t3tools/t3code/commit/0a1b2c3", + "https://github.com/t3tools/t3code", + "https://github.com/t3tools/t3code/pull/abc", + "https://gitlab.com/t3tools/t3code/-/issues/12", + "https://blog.example.test/2026/updates/pull/3", + "javascript:alert(1)//github.com/t3tools/t3code/pull/1", + "not a url", + ]) { + expect(parseChangeRequestUrl(link), link).toBeNull(); + } + }); +}); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts new file mode 100644 index 00000000000..3ef0324d007 --- /dev/null +++ b/packages/shared/src/changeRequestUrl.ts @@ -0,0 +1,75 @@ +/** + * A change request named the way a thread link names one: the host below which the repository + * is addressed, the repository path as that host writes it, and the number. + * + * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce + * from a git remote — lower case, no port, the full path below the host — because links are + * matched against those. Anything else matches nothing. + */ +export interface ChangeRequestLink { + readonly host: string; + readonly repository: string; + readonly number: number; +} + +/** The host itself, one of its subdomains, or an install named after the provider. */ +function isHostOf(hostname: string, apex: string, label?: string): boolean { + if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; + return label !== undefined && hostname.startsWith(`${label}.`); +} + +/** + * The repository and number behind a change request URL on a host this can read, or null for + * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an + * ordinary link. A doubtful match is worse than no match, so nothing here guesses. + * + * Each host is recognised by the path shape it alone uses, guarded by a hostname it could + * plausibly be served from, since self-hosted installs are named whatever their admin chose: + * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic + * enough that it is only believed from a GitHub-ish host. + * + * Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test` and + * the rest are an open set, and blocking spellings of it costs real hosts (`gitlab.com.br` is a + * registrable domain). What a claim is worth is decided where it is used. + */ +export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open. + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + + // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} + if (isHostOf(host, "github.com", "github")) { + const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } + // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/` + // separator is GitLab's own, so the hostname is not asked about. + const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (gitlab) return claim(host, gitlab); + // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n} + if (isHostOf(host, "bitbucket.org", "bitbucket")) { + const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } + // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part + // of the repository path there, as it is in the remote URL the identity is read from. + if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) { + const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } + return null; +} + +function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { host, repository: repository.toLowerCase(), number } + : null; +} diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts new file mode 100644 index 00000000000..dbc46e1260c --- /dev/null +++ b/packages/shared/src/threadPullRequests.test.ts @@ -0,0 +1,167 @@ +import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + legacyLinkedPullRequestOf, + resolveThreadCurrentPullRequest, + resolveThreadPullRequestChains, + threadPullRequestKeysEqual, +} from "./threadPullRequests.ts"; + +function snapshot(input: Partial = {}): ThreadPullRequestSnapshot { + return { + state: "open", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-01T00:00:00.000Z", + syncedAt: "2026-01-01T00:00:00.000Z", + ...input, + }; +} + +function link( + number: number, + input: Partial> = {}, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "manual", + linkedAt: `2026-01-01T00:00:${String(number).padStart(2, "0")}.000Z`, + snapshot: null, + stack: null, + ...input, + }; +} + +describe("threadPullRequestKeysEqual", () => { + it("ignores host and repository case", () => { + expect( + threadPullRequestKeysEqual( + { host: "GitHub.com", repository: "PingDotGG/t3code", number: 1 }, + { host: "github.com", repository: "pingdotgg/t3code", number: 1 }, + ), + ).toBe(true); + expect( + threadPullRequestKeysEqual( + { host: "github.com", repository: "pingdotgg/t3code", number: 1 }, + { host: "gitlab.com", repository: "pingdotgg/t3code", number: 1 }, + ), + ).toBe(false); + }); +}); + +describe("resolveThreadCurrentPullRequest", () => { + it("returns null with no visible links", () => { + expect(resolveThreadCurrentPullRequest([])).toBeNull(); + expect(resolveThreadCurrentPullRequest([link(1, { source: "stack-dismissed" })])).toBeNull(); + }); + + it("treats an unsynced link as open", () => { + expect(resolveThreadCurrentPullRequest([link(1)])).toMatchObject({ + kind: "single", + link: { number: 1 }, + }); + }); + + it("prefers the single open link over terminal ones", () => { + const current = resolveThreadCurrentPullRequest([ + link(1, { snapshot: snapshot({ state: "merged" }) }), + link(2, { snapshot: snapshot({ state: "open" }) }), + link(3, { snapshot: snapshot({ state: "closed" }) }), + ]); + expect(current).toMatchObject({ kind: "single", link: { number: 2 } }); + }); + + it("reports a stack when several links are open and puts the highest layer on top", () => { + const stack = { + kind: "native" as const, + id: "s1", + number: 1, + url: "https://github.com/pingdotgg/t3code/stacks/1", + base: "main", + layers: [ + { number: 10, headBranch: "a", state: "open" as const }, + { number: 11, headBranch: "b", state: "open" as const }, + ], + }; + const current = resolveThreadCurrentPullRequest([ + link(11, { snapshot: snapshot(), stack }), + link(10, { snapshot: snapshot(), stack }), + ]); + expect(current).toMatchObject({ kind: "stack", top: { number: 11 } }); + if (current?.kind === "stack") { + expect(current.open.map((entry) => entry.number)).toEqual([11, 10]); + } + }); + + it("orders an open set without stack data by most recent link", () => { + const current = resolveThreadCurrentPullRequest([link(1), link(2)]); + expect(current).toMatchObject({ kind: "stack", top: { number: 2 } }); + }); + + it("falls back to the most recently updated terminal link", () => { + const current = resolveThreadCurrentPullRequest([ + link(1, { snapshot: snapshot({ state: "merged", updatedAt: "2026-01-03T00:00:00.000Z" }) }), + link(2, { snapshot: snapshot({ state: "closed", updatedAt: "2026-01-02T00:00:00.000Z" }) }), + ]); + expect(current).toMatchObject({ kind: "single", link: { number: 1 } }); + }); +}); + +describe("legacyLinkedPullRequestOf", () => { + it("projects the current link into the old shape with the thread's project", () => { + expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never)).toEqual({ + projectId: "project-1", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + }); + expect(legacyLinkedPullRequestOf([], "project-1" as never)).toBeNull(); + }); +}); + +describe("resolveThreadPullRequestChains", () => { + it("chains links by base → head within a repository, bottom to top", () => { + const chains = resolveThreadPullRequestChains([ + link(3, { snapshot: snapshot({ headBranch: "c", baseBranch: "b" }) }), + link(1, { snapshot: snapshot({ headBranch: "a", baseBranch: "main" }) }), + link(2, { snapshot: snapshot({ headBranch: "b", baseBranch: "a" }) }), + link(9, { snapshot: snapshot({ headBranch: "solo", baseBranch: "main" }) }), + ]); + expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([ + ["derived", [1, 2, 3]], + ["derived", [9]], + ]); + }); + + it("uses the native stack order when the host provides one", () => { + const stack = { + kind: "native" as const, + id: "s1", + number: 1, + url: "https://github.com/pingdotgg/t3code/stacks/1", + base: "main", + layers: [ + { number: 5, headBranch: "a", state: "merged" as const }, + { number: 6, headBranch: "b", state: "open" as const }, + ], + }; + const chains = resolveThreadPullRequestChains([ + link(6, { snapshot: snapshot({ headBranch: "b", baseBranch: "main" }), stack }), + link(5, { + snapshot: snapshot({ state: "merged", headBranch: "a", baseBranch: "main" }), + stack, + }), + link(8), + ]); + expect(chains.map((chain) => [chain.kind, chain.layers.map((layer) => layer.number)])).toEqual([ + ["native", [5, 6]], + ["derived", [8]], + ]); + }); +}); diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts new file mode 100644 index 00000000000..6e851eaa834 --- /dev/null +++ b/packages/shared/src/threadPullRequests.ts @@ -0,0 +1,173 @@ +import type { + ThreadLinkedPullRequest, + ThreadPullRequestKey, + ThreadPullRequestLink, +} from "@t3tools/contracts"; + +/** Identity comparison for links: host-level, case-insensitive on host and repository. */ +export function threadPullRequestKeysEqual( + left: ThreadPullRequestKey, + right: ThreadPullRequestKey, +): boolean { + return ( + left.number === right.number && + left.host.toLowerCase() === right.host.toLowerCase() && + left.repository.toLowerCase() === right.repository.toLowerCase() + ); +} + +export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string { + return `${key.host.toLowerCase()}/${key.repository.toLowerCase()}#${key.number}`; +} + +/** Links a user should see. Tombstoned stack members stay in the array only so the + * sync reactor does not re-add them. */ +export function visibleThreadPullRequests( + links: ReadonlyArray, +): ReadonlyArray { + return links.filter((link) => link.source !== "stack-dismissed"); +} + +function isOpen(link: ThreadPullRequestLink): boolean { + // Unsynced links are treated as open: they were just linked, and hiding them + // behind a terminal PR until the first sync would make the link look lost. + return link.snapshot === null || link.snapshot.state === "open"; +} + +function latestUpdatedAt(link: ThreadPullRequestLink): number { + const value = link.snapshot?.updatedAt ?? link.linkedAt; + const ms = Date.parse(value); + return Number.isNaN(ms) ? 0 : ms; +} + +function layerIndex(link: ThreadPullRequestLink): number { + const layers = link.stack?.layers; + if (layers === undefined) return -1; + return layers.findIndex((layer) => layer.number === link.number); +} + +/** The single pull request a one-slot surface (sidebar badge, tab icon, copy link) shows. */ +export type ThreadCurrentPullRequest = + | { readonly kind: "single"; readonly link: ThreadPullRequestLink } + | { + readonly kind: "stack"; + readonly open: ReadonlyArray; + /** Highest layer of the open set; the one "View PR" and copy-link target. */ + readonly top: ThreadPullRequestLink; + }; + +/** + * Prefer open work: one open link is the thread's PR; several open links are a stack and + * the surface shows a stack glyph instead of guessing; with nothing open, the most recently + * updated terminal link stands in so a merged thread still points at what it shipped. + */ +export function resolveThreadCurrentPullRequest( + links: ReadonlyArray, +): ThreadCurrentPullRequest | null { + const visible = visibleThreadPullRequests(links); + if (visible.length === 0) return null; + const open = visible.filter(isOpen); + if (open.length === 1) return { kind: "single", link: open[0]! }; + if (open.length > 1) { + const ordered = [...open].sort((left, right) => { + const layerDelta = layerIndex(right) - layerIndex(left); + if (layerDelta !== 0) return layerDelta; + return Date.parse(right.linkedAt) - Date.parse(left.linkedAt); + }); + return { kind: "stack", open: ordered, top: ordered[0]! }; + } + const terminal = [...visible].sort( + (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), + ); + return { kind: "single", link: terminal[0]! }; +} + +/** The one link a legacy `linkedPullRequest` consumer should see, or null. */ +export function resolveThreadCurrentPullRequestLink( + links: ReadonlyArray, +): ThreadPullRequestLink | null { + const current = resolveThreadCurrentPullRequest(links); + if (current === null) return null; + return current.kind === "single" ? current.link : current.top; +} + +/** + * Compat shape for clients that predate `pullRequests`. `projectId` is the routing hint the + * old shape carried; callers pass the thread's own project because the legacy consumers + * only ever linked pull requests from it. + */ +export function legacyLinkedPullRequestOf( + links: ReadonlyArray, + projectId: ThreadLinkedPullRequest["projectId"], +): ThreadLinkedPullRequest | null { + const link = resolveThreadCurrentPullRequestLink(links); + if (link === null) return null; + return { projectId, repository: link.repository, number: link.number, url: link.url }; +} + +export interface ThreadPullRequestChain { + readonly kind: "native" | "derived"; + /** Bottom to top. */ + readonly layers: ReadonlyArray; +} + +/** + * Groups a thread's links into stacks. Native stacks come from the host and win; the rest + * are chained by matching one link's base branch to another's head branch within the same + * repository. A link that chains to nothing is a one-layer chain. + */ +export function resolveThreadPullRequestChains( + links: ReadonlyArray, +): ReadonlyArray { + const visible = visibleThreadPullRequests(links); + const chains: Array = []; + const placed = new Set(); + + const nativeStacks = new Map>(); + for (const link of visible) { + if (link.stack === null) continue; + const stackKey = `${link.host}/${link.repository}#stack:${link.stack.id}`; + const members = nativeStacks.get(stackKey) ?? []; + members.push(link); + nativeStacks.set(stackKey, members); + } + for (const members of nativeStacks.values()) { + const order = new Map(members[0]!.stack!.layers.map((layer, index) => [layer.number, index])); + members.sort((left, right) => (order.get(left.number) ?? 0) - (order.get(right.number) ?? 0)); + for (const member of members) placed.add(threadPullRequestKeyOf(member)); + chains.push({ kind: "native", layers: members }); + } + + const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); + const byHead = new Map(); + for (const link of remaining) { + if (link.snapshot === null) continue; + byHead.set(`${link.host}/${link.repository}:${link.snapshot.headBranch}`.toLowerCase(), link); + } + const hasChild = new Set(); + for (const link of remaining) { + if (link.snapshot === null) continue; + const parent = byHead.get( + `${link.host}/${link.repository}:${link.snapshot.baseBranch}`.toLowerCase(), + ); + if (parent !== undefined && parent !== link) hasChild.add(threadPullRequestKeyOf(parent)); + } + // Walk from each top (a link nothing builds on) down its base chain. + for (const top of remaining) { + if (hasChild.has(threadPullRequestKeyOf(top))) continue; + const layers: Array = []; + let cursor: ThreadPullRequestLink | undefined = top; + while (cursor !== undefined && !placed.has(threadPullRequestKeyOf(cursor))) { + placed.add(threadPullRequestKeyOf(cursor)); + layers.unshift(cursor); + cursor = + cursor.snapshot === null + ? undefined + : byHead.get( + `${cursor.host}/${cursor.repository}:${cursor.snapshot.baseBranch}`.toLowerCase(), + ); + } + if (layers.length > 0) chains.push({ kind: "derived", layers }); + } + return chains; +} From ca6dbcd3c136c2bfdc0844b6fde182cbd190de68 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 14:55:03 -0700 Subject: [PATCH 02/13] feat(web): back to the thread's pull requests from a pull request panel Beside a thread, the detail header gains a back arrow left of the repository name that opens the thread's Pull requests surface, leaving the pull request tab open behind it. Closing the tab is not the same as going back: the reader came from the list and expects to land on it. Co-Authored-By: Claude Code --- apps/web/src/components/ChatView.tsx | 5 +++ .../pullRequest/PullRequestDetailPanel.tsx | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c9e5e1b7312..83690fe36a4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7232,6 +7232,11 @@ function ChatViewContent(props: ChatViewProps) { } composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} + onBack={ + activeThreadRef !== null && supportsThreadPullRequests + ? addPullRequestsSurface + : undefined + } /> ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 1c98a416cf0..fc0580efc69 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -442,6 +442,7 @@ export function PullRequestDetailPanel({ onStateChange, context = "page", composerDraftTarget, + onBack, }: { environmentId: EnvironmentId; reference: PullRequestRef; @@ -477,6 +478,12 @@ export function PullRequestDetailPanel({ * land here instead of opening a new thread — the branch is already under the reader's feet. */ composerDraftTarget?: ScopedThreadRef | DraftId; + /** + * Beside a thread, the way back to that thread's list of pull requests. The tab strip can + * close this surface, but closing is not going back: the reader came from the list and + * expects to land on it, with this one still open behind. + */ + onBack?: (() => void) | undefined; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; const [tab, setTab] = useState("summary"); @@ -1246,6 +1253,23 @@ export function PullRequestDetailPanel({ > {detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null} {detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null} Date: Tue, 8 Sep 2026 16:10:34 -0700 Subject: [PATCH 03/13] feat(pull-requests): finish thread links and fix sync behavior --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../features/threads/git/GitOverviewSheet.tsx | 52 ++++ .../features/threads/thread-list-items.tsx | 34 ++- .../features/threads/thread-list-v2-items.tsx | 43 ++- .../src/state/thread-pr-presentation.ts | 41 ++- apps/mobile/src/state/use-thread-pr.test.ts | 77 ++++- apps/mobile/src/state/use-thread-pr.ts | 34 ++- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/environment/ServerEnvironment.test.ts | 2 +- .../src/mcp/toolkits/pullRequests/tools.ts | 6 +- .../PullRequestSyncReactor.test.ts | 63 +++++ .../orchestration/PullRequestSyncReactor.ts | 23 +- .../ThreadSettlementPolicy.test.ts | 73 +++++ .../orchestration/ThreadSettlementPolicy.ts | 14 +- .../pullRequest/GitHubPullRequestCli.test.ts | 26 +- .../src/pullRequest/GitHubPullRequestCli.ts | 8 +- .../pullRequest/PullRequestService.test.ts | 129 +++++++++ .../src/pullRequest/PullRequestService.ts | 50 +++- .../src/pullRequest/linkedThreads.test.ts | 96 +++++++ apps/server/src/pullRequest/linkedThreads.ts | 35 +++ apps/server/src/server.test.ts | 2 +- apps/server/src/ws.ts | 19 ++ apps/web/src/components/ChatView.tsx | 3 +- apps/web/src/components/LegacySidebar.tsx | 41 ++- .../src/components/RightPanelTabs.test.tsx | 46 +++ apps/web/src/components/RightPanelTabs.tsx | 59 +++- apps/web/src/components/Sidebar.tsx | 46 ++- .../ThreadStatusIndicators.test.tsx | 47 +++- .../src/components/ThreadStatusIndicators.tsx | 162 +++++------ .../LinkBranchPullRequestButton.tsx | 69 +++++ .../pullRequest/LinkPullRequestDialog.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 63 +---- .../pullRequest/PullRequestThreadLinks.tsx | 265 ++++++++++++++++++ apps/web/src/lib/openPullRequestLink.ts | 12 +- apps/web/src/rightPanelStore.test.ts | 12 + apps/web/src/rightPanelStore.ts | 8 +- docs/internals/glossary.md | 8 + docs/user/source-control.md | 11 + .../client-runtime/src/state/pullRequests.ts | 7 + packages/contracts/src/pullRequest.ts | 13 + packages/contracts/src/rpc.ts | 13 +- .../shared/src/threadPullRequests.test.ts | 109 +++++++ packages/shared/src/threadPullRequests.ts | 82 ++++-- 43 files changed, 1648 insertions(+), 262 deletions(-) create mode 100644 apps/server/src/pullRequest/linkedThreads.test.ts create mode 100644 apps/server/src/pullRequest/linkedThreads.ts create mode 100644 apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index aa714bccb41..74e43d8cc57 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -77,6 +77,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch"; import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; +import IconStack2 from "@tabler/icons-react-native/IconStack2"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; @@ -99,6 +100,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.right.circle": IconArrowRightCircle, "arrow.triangle.branch": IconGitBranch, "arrow.triangle.pull": IconGitPullRequest, + "square.3.layers.3d": IconStack2, "arrow.turn.left.up": IconArrowBackUp, "arrow.up": IconArrowUp, "arrow.up.circle": IconArrowUpCircle, diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 5aefccb4baf..e29ab9cd2e1 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -4,6 +4,10 @@ import { getGitActionDisabledReason, requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, +} from "@t3tools/shared/threadPullRequests"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { CommonActions, @@ -51,6 +55,10 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const threadId = ThreadId.make(props.route.params.threadId); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const linkedPrChains = useMemo( + () => resolveThreadPullRequestChains(selectedThread?.pullRequests ?? []), + [selectedThread?.pullRequests], + ); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const theme = useUniwindTheme(); @@ -285,6 +293,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { /> + {linkedPrChains.length > 0 ? ( + + + Linked pull requests + + {linkedPrChains.map((chain) => ( + + {chain.layers.length > 1 ? ( + + + + {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs + · bottom to top + + + ) : null} + {chain.layers.map((link, index) => ( + + {index > 0 ? : null} + { + void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { + if (!opened) + Alert.alert("Unable to open PR", "The pull request could not be opened."); + }); + }} + /> + + ))} + + ))} + + ) : null} + {currentWorktreePath ? : null} ); diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 1e3094bd071..0ec50c67445 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -56,6 +56,7 @@ function pullRequestTintColor( return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; + case null: case "closed": return dark ? "#a1a1aa" : "#71717a"; } @@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {pr !== null ? ( - - + + {pr.kind === "stack" ? ( + + ) : ( + + )} )} {pr ? ( - - #{pr.label} - + + {pr.kind === "stack" ? ( + + ) : null} + + {pr.kind === "stack" ? pr.label : `#${pr.label}`} + + ) : null} {props.providerInstance ? ( ; export interface ThreadPrPresentation { readonly number: number; - readonly state: ThreadPr["state"]; + readonly state: ThreadPr["state"] | null; + readonly kind: "pull-request" | "stack"; readonly isDraft: boolean; /** Provider-side last activity, bounding when a terminal state landed. */ readonly updatedAt: string | null; @@ -30,6 +36,7 @@ export function presentThreadPr( const presentation = resolveChangeRequestPresentation(provider); const isDraft = pr.state === "open" && pr.isDraft === true; return { + kind: "pull-request", number: pr.number, state: pr.state, isDraft, @@ -40,3 +47,33 @@ export function presentThreadPr( textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } + +/** Persisted links render immediately, including links awaiting their first host sync. */ +export function presentThreadLinkedPullRequests( + links: ReadonlyArray, +): ThreadPrPresentation | null { + const link = resolveThreadCurrentPullRequestLink(links); + const badge = resolveThreadPullRequestBadge(links); + if (link === null || badge === null) return null; + const snapshot = link.snapshot; + const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null); + const isDraft = snapshot?.isDraft === true && state === "open"; + const label = + badge.kind === "stack" + ? String(badge.layers) + : `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`; + return { + kind: badge.kind, + number: link.number, + state, + isDraft, + updatedAt: snapshot?.updatedAt ?? null, + url: link.url, + label, + accessibilityLabel: + badge.kind === "stack" + ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}` + : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`, + textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state], + }; +} diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index f6fddfdc557..e9ed09d5db2 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,7 +1,7 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import type { ThreadPullRequestLink, VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { presentThreadPr } from "./thread-pr-presentation"; +import { presentThreadLinkedPullRequests, presentThreadPr } from "./thread-pr-presentation"; const pullRequest: NonNullable = { number: 3774, @@ -43,3 +43,76 @@ describe("presentThreadPr", () => { }); }); }); + +function linkedPr( + number: number, + overrides: Partial = {}, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number, + url: `https://github.com/t3tools/t3code/pull/${number}`, + source: "manual", + linkedAt: "2026-09-08T00:00:00.000Z", + stack: null, + snapshot: { + state: "open", + title: `Change ${number}`, + headBranch: `change-${number}`, + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-09-08T00:00:00.000Z", + }, + ...overrides, + }; +} + +describe("presentThreadLinkedPullRequests", () => { + it("renders unsynced links with neutral pending status", () => { + expect(presentThreadLinkedPullRequests([linkedPr(1, { snapshot: null })])).toMatchObject({ + number: 1, + label: "1", + state: null, + textClassName: "text-foreground-muted", + accessibilityLabel: "#1 pull request status pending", + }); + }); + + it("counts unrelated links without labelling them a stack", () => { + expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({ + kind: "pull-request", + label: "1 +1", + }); + }); + + it("uses the top of a derived stack even when its bottom was linked later", () => { + const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" }); + const top = linkedPr(2); + expect( + presentThreadLinkedPullRequests([ + bottom, + { + ...top, + snapshot: { ...top.snapshot!, baseBranch: "change-1" }, + }, + ]), + ).toMatchObject({ kind: "stack", label: "2", number: 2, url: top.url }); + }); + + it("hides dismissed stack members", () => { + expect( + presentThreadLinkedPullRequests([linkedPr(1, { source: "stack-dismissed" })]), + ).toBeNull(); + }); + + it("retains merged state from the persisted snapshot", () => { + const link = linkedPr(1); + expect( + presentThreadLinkedPullRequests([ + { ...link, snapshot: { ...link.snapshot!, state: "merged" } }, + ]), + ).toMatchObject({ state: "merged", textClassName: "text-adaptive-violet-600-400" }); + }); +}); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 4a42bae91ee..7939df27b58 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -10,8 +10,13 @@ import { useCallback, useEffect, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; +import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; -import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; +import { + presentThreadLinkedPullRequests, + presentThreadPr, + type ThreadPrPresentation, +} from "./thread-pr-presentation"; const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); const MAX_THREAD_PR_SNAPSHOTS = 500; @@ -35,16 +40,24 @@ export { } from "./thread-pr-presentation"; /** - * Live status for a thread's server-provided PR. Visible rows share a summary - * request for the same PR in the same environment. + * Linked PRs use server snapshots. Branch fallback and legacy references share + * a live summary request across visible rows in the same environment. */ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { - const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; - const host = thread.pullRequests.find( - (link) => - link.number === pullRequestRef?.number && - link.repository.toLowerCase() === pullRequestRef.repository.toLowerCase(), - )?.host; + const supportsLinks = useAtomValue( + serverEnvironment.configValueAtom(thread.environmentId), + (config) => config?.environment.capabilities.threadPullRequests === true, + ); + const linkedPresentation = useMemo( + () => presentThreadLinkedPullRequests(thread.pullRequests), + [thread.pullRequests], + ); + // Legacy servers decode an empty link array. Keep their single-PR reference, + // but never revive a dismissed link from a modern server's compat field. + const legacyPullRequest = + !supportsLinks && thread.pullRequests.length === 0 ? thread.linkedPullRequest : null; + const pullRequestRef = + linkedPresentation !== null ? null : (legacyPullRequest ?? thread.branchPullRequest ?? null); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const snapshotIdentity = JSON.stringify(pullRequestRef); // Select this row's entry so writes for other rows do not re-render it. @@ -63,7 +76,6 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio environmentId: thread.environmentId, input: { projectId: pullRequestRef.projectId, - ...(host === undefined ? {} : { host }), repository: pullRequestRef.repository, number: pullRequestRef.number, }, @@ -107,5 +119,5 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio }); }, [live, snapshotIdentity, threadKey]); - return live === undefined ? snapshot : live; + return linkedPresentation ?? (live === undefined ? snapshot : live); } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 4197ff473ed..a63b07f0ade 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -69,6 +69,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 55ece357835..a12a8242b0d 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -170,7 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequests).toBe(true); - expect(second.capabilities.threadPullRequestLinking).toBeUndefined(); + expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 51a62b302af..2321fdeb591 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -144,7 +144,7 @@ export const ListThreadPullRequestsResult = Schema.Struct({ }); export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; -export const LinkPullRequestTool = Tool.make("link_pull_request", { +const LinkPullRequestTool = Tool.make("link_pull_request", { description: `${REGISTER_EVERY_PR} Links a pull request to this thread so T3 Code tracks it, shows its status beside the thread, and settles the thread when it merges. Pass the URL, or repository plus number. Linking an already-linked pull request succeeds with alreadyLinked=true.`, parameters: PullRequestTargetInput, success: LinkPullRequestResult, @@ -157,7 +157,7 @@ export const LinkPullRequestTool = Tool.make("link_pull_request", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); -export const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { +const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { description: "Remove a pull request link from this thread, for example after closing a pull request you opened by mistake. Pass the URL, or repository plus number. Unlinking a pull request that is not linked succeeds with wasLinked=false.", parameters: PullRequestTargetInput, @@ -171,7 +171,7 @@ export const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); -export const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { +const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, success: ListThreadPullRequestsResult, failure: PullRequestToolError, diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts index e980cf3242e..d68ed2a48ec 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -268,6 +268,69 @@ function applySync( } describe("PullRequestSyncReactor", () => { + it.effect("retries a failed stack read after the summary becomes terminal", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + let attempts = 0; + const nativeStack: PullRequestStack = { + id: "stack", + number: 7, + url: "https://github.com/owner/repository/stacks/7", + base: "main", + layers: [{ number: 7, headBranch: "feature", state: "merged" }], + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7)] })]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), + stack: () => + ++attempts === 1 + ? Effect.fail( + new PullRequestOperationError({ + operation: "stack", + detail: "temporary failure", + }), + ) + : Effect.succeed(nativeStack), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const commands = yield* Ref.get(fixture.syncCommands); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); + yield* sweepAgain(fixture, reactor); + assert.strictEqual(attempts, 2); + assert.deepStrictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.stack, { + kind: "native", + ...nativeStack, + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("explicit refresh reads a changed stack even when its PR summary is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7, {})] })]), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 0); + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 7, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); it.effect("snapshots an unsynced link once and writes it to the thread", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index ed715df6a06..0db15a39c29 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -126,6 +126,7 @@ function siblingPullRequestUrl(url: string, number: number): string | null { return match === null ? null : `${match[1]}${number}`; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -134,9 +135,10 @@ export const make = Effect.gen(function* () { const lastSyncedAt = new Map(); const requested = new Set(); + const retryStacks = new Set(); const isDue = (key: string, entries: ReadonlyArray, nowMs: number): boolean => { - if (requested.has(key)) return true; + if (requested.has(key) || retryStacks.has(key)) return true; if (entries.some((entry) => entry.link.snapshot === null)) return true; if (!entries.some((entry) => entry.link.snapshot?.state === "open")) return false; if (entries.some((entry) => isUnsettled(entry.thread))) return true; @@ -168,6 +170,10 @@ export const make = Effect.gen(function* () { } } + for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key); + for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key); + for (const key of requested) if (!groups.has(key)) requested.delete(key); + // Layers auto-linked this sweep, so two links of one thread that share a // stack do not both try to add the same sibling. const linkedThisSweep = new Set(); @@ -244,10 +250,13 @@ export const make = Effect.gen(function* () { }; const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); const fields = snapshotFieldsOf(summary); - const needsStack = entries.some( - (entry) => - entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), - ); + const needsStack = + requested.has(key) || + retryStacks.has(key) || + entries.some( + (entry) => + entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), + ); const fetchedStack = needsStack ? yield* pullRequests.stack(ref).pipe( Effect.map((stack) => ({ @@ -263,6 +272,10 @@ export const make = Effect.gen(function* () { ), ) : null; + if (needsStack) { + if (fetchedStack === null) retryStacks.add(key); + else retryStacks.delete(key); + } // The host answered, so the cadence clock ticks even if a dispatch below is rejected. lastSyncedAt.set(key, nowMs); requested.delete(key); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index a1db3cfbace..252b9943940 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -5,6 +5,7 @@ import { ProjectId, TurnId, type OrchestrationThreadShell, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; @@ -214,3 +215,75 @@ describe("resolveAutoSettlementAt", () => { ).toBe(true); }); }); + +function linkedRequest( + number: number, + snapshot: ThreadPullRequestLink["snapshot"], +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "org/repo", + number, + url: `https://github.com/org/repo/pull/${number}`, + source: "manual", + linkedAt: NOW, + stack: null, + snapshot, + }; +} + +const terminalSnapshot = ( + state: "closed" | "merged", + terminalAt: string, + updatedAt = terminalAt, +) => ({ + state, + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + closedAt: terminalAt, + mergedAt: state === "merged" ? terminalAt : null, + updatedAt, + syncedAt: NOW, +}); + +describe("linked request settlement", () => { + it.each(["closed", "merged"] as const)( + "uses the latest actual %s transition despite later comments on another PR", + (state) => { + const old = linkedRequest(1, terminalSnapshot(state, "2026-08-19T00:00:00.000Z", NOW)); + const recent = linkedRequest(2, terminalSnapshot(state, "2026-08-21T00:00:00.000Z")); + expect(decide(makeThread({ pullRequests: [old, recent] }), null, { days: null })).toBe(true); + expect(decide(makeThread({ pullRequests: [recent, old] }), null, { days: null })).toBe(true); + expect(decide(makeThread({ pullRequests: [old] }), null, { days: null })).toBe(false); + }, + ); + + it("keeps unknown and open links active even after the inactivity window", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + const unknown = linkedRequest(2, null); + const open = linkedRequest(3, { + ...terminalSnapshot("closed", NOW), + state: "open", + closedAt: null, + }); + expect(decide(makeThread({ pullRequests: [merged, unknown] }))).toBe(false); + expect(decide(makeThread({ pullRequests: [merged, open] }))).toBe(false); + expect( + decide(makeThread({ pullRequests: [merged, { ...unknown, source: "stack-dismissed" }] })), + ).toBe(true); + }); + + it("honors merge settings and ignores missing terminal timestamps", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null, merge: false })).toBe( + false, + ); + const missing = linkedRequest(2, { ...terminalSnapshot("merged", NOW), mergedAt: null }); + expect(decide(makeThread({ pullRequests: [missing] }), null, { days: null })).toBe(false); + expect(decide(makeThread({ pullRequests: [missing, merged] }), null, { days: null })).toBe( + true, + ); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 735be88d34f..92063745eff 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -78,20 +78,22 @@ export function resolveAutoSettlementAt(input: { const links = visibleThreadPullRequests(thread.pullRequests); if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; if (links.length > 0) { + const terminalTimestamp = (link: (typeof links)[number]) => { + const snapshot = link.snapshot; + const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; + const timestamp = Date.parse(value ?? ""); + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; + }; const latest = links.reduce((current, candidate) => - Date.parse(candidate.snapshot?.updatedAt ?? "") > - Date.parse(current.snapshot?.updatedAt ?? "") - ? candidate - : current, + terminalTimestamp(candidate) > terminalTimestamp(current) ? candidate : current, ); pullRequest = latest.snapshot === null ? null : { state: latest.snapshot.state, - updatedAt: latest.snapshot.updatedAt, - closedAt: latest.snapshot.closedAt ?? null, mergedAt: latest.snapshot.mergedAt ?? null, + closedAt: latest.snapshot.closedAt ?? null, }; } if (!isAutoSettlementCandidate(thread, input.now)) return null; diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index c43451cb1bc..2941b364363 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -338,11 +338,10 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reads a host that refuses the stacks preview as not stacked", () => Effect.gen(function* () { - // A GitHub Enterprise install without the preview, or a repository it is off for, answers - // 404 — which `gh api` reports as a plain failed command. + // The CLI classifies a missing preview endpoint as not found. mockedExecute.mockReturnValueOnce( Effect.fail( - new GitHubCli.GitHubCliCommandError({ + new GitHubCli.GitHubPullRequestNotFoundError({ command: "gh", cwd: "/w", cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), @@ -388,6 +387,27 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("preserves transient stack failures instead of reporting no stack", () => + Effect.gen(function* () { + const failure = new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 503"), + }); + mockedExecute.mockReturnValueOnce(Effect.fail(failure)); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + assert.strictEqual(error, failure); + }), + ); + it.effect("reports a stacks answer it cannot read against the stack read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index e38ea03e7c9..136f87e6a05 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1709,13 +1709,9 @@ export const make = Effect.gen(function* () { }), ); }), - // Stacks are a preview: a host without it, or a repository it is switched off for, - // answers 404, which is "not stacked" rather than a failure worth showing. `gh` - // reports no status code, so the narrowing is to a command that ran and was refused - // — a missing `gh`, a signed-out one, or a rate limit still fail the same way for - // every request and are not swallowed here. + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. Effect.catchTags({ - GitHubCliCommandError: () => Effect.succeed(null), GitHubPullRequestNotFoundError: () => Effect.succeed(null), }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 6311ada229b..fd0277c3822 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1593,6 +1593,18 @@ it.effect("reads a host-native stack through the provider and null where it has stack?.layers.map((layer) => layer.number), [7, 8], ); + + const withoutStacks = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [fakeProvider("github")], + }); + assert.isNull( + yield* withoutStacks.stack({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + }), + ); }), ); @@ -1624,6 +1636,81 @@ it.effect("routes a hosted reference to another repository through a project on }), ); +it.effect("routes Azure reads and writes through the requested organization's checkout", () => + Effect.gen(function* () { + const seen: string[] = []; + const service = yield* makeService({ + projects: ["org-a", "org-b"].map((organization) => + project({ + id: organization, + title: organization, + workspaceRoot: `/${organization}`, + repository: `${organization}/project/_git/web`, + provider: "azure-devops", + host: "dev.azure.com", + }), + ), + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), + }), + ], + }); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); + }), +); + +it.effect("refuses Azure cross-organization reads and writes without its checkout", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "org-a", + title: "org-a", + workspaceRoot: "/org-a", + repository: "org-a/project/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), + runAction: () => Effect.die("must not modify the wrong organization"), + }), + ], + }); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + const readError = yield* Effect.flip( + service.summary(reference, { recoverTransientFailure: false }), + ); + const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); + assert.strictEqual(readError._tag, "PullRequestUnavailableError"); + assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); + }), +); + it.effect("refuses a hosted reference when nothing is checked out from that host", () => Effect.gen(function* () { const service = yield* makeService({ @@ -3463,6 +3550,48 @@ it.effect("does not ask the host again for a linked summary it already holds", ( }), ); +it.effect( + "opening detail preserves enriched linked summaries and updates draft and diff fields", + () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + isDraft: true, + reviewDecision: "approved", + checksState: "passing", + }), + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("body", 14), + deletions: 3, + changedFiles: 5, + mergeability: "conflicting", + }), + }), + ], + }); + yield* service.summary(reference); + const detail = yield* service.detail(reference); + const summary = yield* service.summary(reference); + assert.strictEqual(summary.isDraft, false); + assert.deepStrictEqual(summary.author, detail.author); + assert.strictEqual(summary.additions, 14); + assert.strictEqual(summary.deletions, 3); + assert.strictEqual(summary.changedFiles, 5); + assert.strictEqual(summary.mergeability, "conflicting"); + assert.strictEqual(summary.reviewDecision, "approved"); + assert.strictEqual(summary.checksState, "passing"); + }), +); + it.effect("reuses an observed merged state for strict settlement reads", () => Effect.gen(function* () { const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 4a2a36a1a65..998a1541a23 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -461,7 +461,7 @@ function withRateLimitBackoff( call: (...args: Args) => Effect.Effect, ) => wrap(operation, call, true); - return { + const wrapped = { kind: api.kind, capabilities: api.capabilities, getViewer: wrap("getViewer", api.getViewer), @@ -517,6 +517,9 @@ function withRateLimitBackoff( setReaction: interactive("setReaction", api.setReaction), setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), }; + // Optional provider methods must be forwarded too; returning the interface alone permits omissions. + return wrapped satisfies PullRequestProviderApi & + Record, never>; } /** @@ -658,7 +661,10 @@ export const make = Effect.gen(function* () { if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); } - const key = listCursorKey(host, repository); + const key = listCursorKey( + host, + kind === "azure-devops" ? identity.canonicalKey : repository, + ); if (seen.has(key)) continue; seen.add(key); if (api === null) { @@ -681,9 +687,9 @@ export const make = Effect.gen(function* () { /** * The project whose checkout and credentials serve a reference. The project's own * repository is the default; a reference that names a `host` may instead point at any - * repository on that host, served through the first project living there, so a thread in - * one repository can link a pull request from another. The returned `repository` is the - * reference's, since that is what every provider call after this addresses. + * repository on that host. Prefer its own checkout; providers with explicit repository + * targeting can fall back to another checkout on the host. Azure derives its organization + * from the checkout, so it requires a matching repository. */ const requireProject = (ref: PullRequestRef): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( @@ -711,13 +717,21 @@ export const make = Effect.gen(function* () { } return listWorkspaceProjects({ host }).pipe( Effect.flatMap(({ supported: onHost }) => { - const route = onHost[0]; + const route = + onHost.find((candidate) => + candidate.api.kind === "azure-devops" + ? candidate.project.repositoryIdentity?.displayName?.toLowerCase() === + repository.toLowerCase() + : candidate.repository.toLowerCase() === repository.toLowerCase(), + ) ?? onHost.find((candidate) => candidate.api.kind !== "azure-devops"); if (route === undefined) { return Effect.fail( new PullRequestUnavailableError({ reason: "provider-unsupported" }), ); } - return Effect.succeed({ ...route, repository }); + return Effect.succeed( + route.api.kind === "azure-devops" ? route : { ...route, repository }, + ); }), ); }), @@ -1592,7 +1606,11 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError(toPullRequestError("runAction")), - Effect.as(project.repository), + Effect.as( + project.api.kind === "azure-devops" + ? input.repository.trim() + : project.repository, + ), ); }), ); @@ -2421,7 +2439,12 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({ + const summaryFromDetail = ( + detail: PullRequestDetail, + previous: PullRequestSummary | undefined, + ): PullRequestSummary => ({ + // Detail does not carry review/check summaries. Keep the last summary observation. + ...previous, provider: detail.provider, projectId: detail.projectId, repository: detail.repository, @@ -2429,7 +2452,12 @@ export const make = Effect.gen(function* () { title: detail.title, url: detail.url, state: detail.state, - ...(detail.isDraft === true ? { isDraft: true } : {}), + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + mergeability: detail.mergeability, headBranch: detail.headBranch, baseBranch: detail.baseBranch, closedAt: detail.closedAt, @@ -2452,7 +2480,7 @@ export const make = Effect.gen(function* () { key, Cache.get(detailCache, key).pipe( Effect.tap((value) => { - const summary = summaryFromDetail(value); + const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); return shouldReplaceHeldSummary(key, summary) ? lastGoodSummary.record(key, summary) : Effect.void; diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts new file mode 100644 index 00000000000..69ef993c5e2 --- /dev/null +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -0,0 +1,96 @@ +import { assert, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; + +it.effect( + "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-09-01T00:00:00.000Z"; + const archivedAt = "2026-09-03T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt}) + `; + const fixtures = [ + { id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "archived", + host: "github.com", + repository: "acme/web", + number: 7, + source: "created", + }, + { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "dismissed", + host: "github.com", + repository: "acme/web", + number: 7, + source: "stack-dismissed", + }, + { + id: "other-host", + host: "github.example.com", + repository: "acme/web", + number: 7, + source: "manual", + }, + { + id: "other-repository", + host: "github.com", + repository: "acme/api", + number: 7, + source: "manual", + }, + { + id: "other-number", + host: "github.com", + repository: "acme/web", + number: 8, + source: "manual", + }, + ]; + for (const fixture of fixtures) { + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, created_at, updated_at, archived_at, deleted_at + ) VALUES ( + ${fixture.id}, 'project-1', ${fixture.id}, '{"instanceId":"codex","model":"gpt-5.4"}', + ${createdAt}, ${fixture.id === "archived" ? archivedAt : createdAt}, + ${fixture.id === "archived" ? archivedAt : null}, + ${fixture.id === "deleted" ? archivedAt : null} + ) + `; + yield* sql` + INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number}, + 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt}) + `; + } + + const result = yield* listLinkedPullRequestThreads({ + host: "GitHub.Com", + repository: "ACME/WEB", + number: 7, + }); + expect(result).toEqual({ + threads: [ + { id: "archived", projectId: "project-1", title: "archived", archivedAt }, + { id: "active", projectId: "project-1", title: "active", archivedAt: null }, + ], + }); + assert.deepStrictEqual( + yield* listLinkedPullRequestThreads({ + host: "github.com", + repository: "acme/web", + number: 99, + }), + { threads: [] }, + ); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts new file mode 100644 index 00000000000..d8eeab6d797 --- /dev/null +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -0,0 +1,35 @@ +import { + PullRequestLinkedThreadsResult, + PullRequestOperationError, + type ThreadPullRequestKey, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")( + function* (key: ThreadPullRequestKey) { + const sql = yield* SqlClient.SqlClient; + const threads = yield* sql` + SELECT t.thread_id AS id, t.project_id AS "projectId", t.title, + t.archived_at AS "archivedAt" + FROM projection_thread_pull_requests AS link + JOIN projection_threads AS t ON t.thread_id = link.thread_id + WHERE link.host = ${key.host.toLowerCase()} + AND link.repository = ${key.repository.toLowerCase()} + AND link.number = ${key.number} + AND link.source != 'stack-dismissed' + AND t.deleted_at IS NULL + ORDER BY t.updated_at DESC, t.thread_id ASC + `; + return yield* Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult)({ threads }); + }, + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "linkedThreads", + detail: "Could not load linked threads.", + cause, + }), + ), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 740e3d48469..01f2dd96b18 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -387,7 +387,7 @@ const browserOtlpTracingLayer = Layer.mergeAll( const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), Layer.provide( Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3626904aee3..d541c060b11 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -146,6 +146,8 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -481,6 +483,7 @@ const makeWsRpcLayer = ( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; + const sql = yield* SqlClient.SqlClient; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; /** A reference's host-level link key; the project's own host where the ref names none. */ const resolvePullRequestSyncKey = (reference: PullRequestRef) => @@ -2163,6 +2166,20 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsLinkedThreads]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsLinkedThreads, + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null + ? Effect.succeed({ threads: [] }) + : listLinkedPullRequestThreads(key).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", @@ -2993,6 +3010,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), }); const pullRequests = yield* PullRequestService.PullRequestService; + const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", "/ws", @@ -3027,6 +3045,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( previewAutomationBroker, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 648092a2b6f..dd23a4ea987 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8045,11 +8045,12 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. ) => { - if (!prStatus) return; + const url = prStatus?.url ?? currentLinkedPr?.url; + if (!url) return; const openedInRightPanel = openPrLink( event, - prStatus.url, + url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { navigateToThread(threadRef); } }, - [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef], + [ + isActive, + navigateToThread, + openPrLink, + openPullRequestsInRightPanel, + prStatus, + currentLinkedPr, + threadRef, + ], ); const handleRenameInputRef = useCallback( (element: HTMLInputElement | null) => { @@ -730,6 +747,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} + {!pr && currentLinkedPr ? ( + event.stopPropagation()} + onClick={handlePrClick} + className="text-muted-foreground" + aria-label={`PR #${currentLinkedPr.number}, status pending`} + > + + + ) : null} + {pr && visibleThreadPullRequests(thread.pullRequests).length === 0 ? ( + + ) : null} {threadStatus && } {renamingThreadKey === threadKey ? ( { }); }); }); + +describe("pull request tab snapshots", () => { + const environmentId = EnvironmentId.make("local"); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "acme/api", + number: 7, + url: "https://github.com/acme/api/pull/7", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: null, + }; + it("keeps unknown linked state authoritative and scopes matches to environment and host", () => { + const threads = [{ environmentId, pullRequests: [link] }]; + expect(resolvePullRequestTabLink(threads, environmentId, "github.com", link)).toBe(link); + expect( + resolvePullRequestTabLink(threads, EnvironmentId.make("remote"), "github.com", link), + ).toBeUndefined(); + expect( + resolvePullRequestTabLink(threads, environmentId, "github.enterprise.test", link), + ).toBeUndefined(); + }); + it("uses the newest snapshot when several threads link the same PR", () => { + const snapshot = { + state: "merged" as const, + title: "API", + headBranch: "api", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-02-01T00:00:00Z", + }; + const newer = { ...link, snapshot }; + expect( + resolvePullRequestTabLink( + [{ environmentId, pullRequests: [link, newer] }], + environmentId, + "github.com", + link, + ), + ).toBe(newer); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index fdea00e1d58..fbf3500bb84 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,3 +1,10 @@ +import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { useProjects, useThreadShells } from "~/state/entities"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import type { ContextMenuItem, EnvironmentId, @@ -709,6 +716,35 @@ function SurfaceIcon({ } } +export function resolvePullRequestTabLink( + threads: readonly Pick[], + environmentId: EnvironmentId | null, + host: string | null, + reference: { repository: string; number: number }, +) { + if (environmentId === null || host === null) return undefined; + let newest: EnvironmentThreadShell["pullRequests"][number] | undefined; + for (const thread of threads) { + if (thread.environmentId !== environmentId) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + if ( + !threadPullRequestKeysEqual(link, { + host, + repository: reference.repository, + number: reference.number, + }) + ) + continue; + if ( + newest === undefined || + (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "") + ) + newest = link; + } + } + return newest; +} + function PullRequestSurfaceIcon({ surface, environmentId, @@ -720,13 +756,26 @@ function PullRequestSurfaceIcon({ }) { const resolvedEnvironmentId = (surface.environmentId as EnvironmentId | undefined) ?? environmentId; + const projects = useProjects(); + const threads = useThreadShells(); + const project = projects.find( + (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, + ); + const identity = project?.repositoryIdentity; + const host = + surface.host ?? + (identity?.provider + ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) + : null); + const linked = resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface); const detail = useEnvironmentQuery( - resolvedEnvironmentId === null + resolvedEnvironmentId === null || linked !== undefined ? null : pullRequestEnvironment.detail({ environmentId: resolvedEnvironmentId, input: { projectId: surface.projectId as ProjectId, + ...(surface.host === undefined ? {} : { host: surface.host }), repository: surface.repository, number: surface.number, }, @@ -735,11 +784,15 @@ function PullRequestSurfaceIcon({ // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the // full detail would flip an open tab to the conflict glyph the moment its read lands. const status = - detail === null ? (seed ?? null) : { state: detail.state, isDraft: detail.isDraft }; + linked !== undefined + ? linked.snapshot + : detail === null + ? (seed ?? null) + : { state: detail.state, isDraft: detail.isDraft }; if (status === null) { return ; } - const presentation = resolvePullRequestState(status); + const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft }); return ; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 15c8c517437..e0f834f1113 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,8 @@ +import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; +import { + resolveThreadCurrentPullRequestLink, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { @@ -1057,9 +1062,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, leaseLiveStatus, thread.pullRequests, + thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null @@ -1074,6 +1080,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus.data, ); const pr = linkedPullRequestStatus?.pr ?? null; + const currentLinkedPr = resolveThreadCurrentPullRequestLink(thread.pullRequests); // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. @@ -1350,17 +1357,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { - if (!pr?.url) return; + const url = pr?.url ?? currentLinkedPr?.url; + if (!url) return; const openedInRightPanel = openPrLink( event, - pr.url, + url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) { onThreadActivate(threadRef); } }, - [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef], + [ + onThreadActivate, + openPrLink, + openPullRequestsInRightPanel, + pr, + currentLinkedPr, + props.isActive, + threadRef, + ], ); // All sidebar rows share one surface model. Live threads used to look @@ -1528,6 +1544,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { +{prBadgeShape.others} ) : null} + ) : currentLinkedPr ? ( + event.stopPropagation()} + onClick={handlePrClick} + className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline" + aria-label={`PR #${currentLinkedPr.number}, status pending`} + > + + {currentLinkedPr.number} + {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? ( + +{prBadgeShape.others} + ) : null} + ) : null; const terminalStatusIcon = terminalStatus ? ( + ) : null} {sortable?.isDragging ? ( dragDestination ) : ( @@ -1939,6 +1974,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} {prBadge} + {prBadge && pr && visibleThreadPullRequests(thread.pullRequests).length === 0 ? ( + + ) : null} {diff ? ( +{diff.insertions}{" "} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 868bd2cd99c..2ad3ef64448 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -1,8 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators"; +import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators"; describe("ThreadWorktreeIndicator", () => { it("renders the worktree folder and branch in an accessible label", () => { @@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => { expect(markup).toBe(""); }); }); + +describe("linked pull request snapshots", () => { + const link: ThreadPullRequestLink = { + host: "gitlab.example.com", + repository: "acme/web", + number: 42, + url: "https://gitlab.example.com/acme/web/-/merge_requests/42", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: null, + }; + it("keeps unsynced links unknown", () => { + expect(linkedPullRequestSnapshotStatus(link)).toBeNull(); + }); + it("uses the snapshot state and branches with the linked identity", () => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + snapshot: { + state: "merged", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-02T00:00:00Z", + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result).toEqual({ + pr: { + number: 42, + url: link.url, + title: "Change", + state: "merged", + isDraft: false, + headRef: "feature", + baseRef: "main", + updatedAt: "2026-01-02T00:00:00Z", + }, + sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, + }); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 2922ff53225..38803428a19 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -8,23 +8,17 @@ import { type VcsStatusResult, } from "@t3tools/contracts"; import { + resolveThreadCurrentPullRequestLink, resolveThreadPullRequestChains, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; -import { Atom } from "effect/unstable/reactivity"; -import { - CloudIcon, - FolderGit2Icon, - GitPullRequestArrowIcon, - GitPullRequestIcon, - LayersIcon, - TerminalIcon, -} from "lucide-react"; +import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { cn } from "../lib/utils"; -import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { useServerConfigs } from "../state/entities"; +import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -34,7 +28,7 @@ import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic" import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { pullRequestListLines, type PullRequestListLine } from "./pullRequest/pullRequestListLines"; +import { pullRequestListLines } from "./pullRequest/pullRequestListLines"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; export interface PrStatusIndicator { @@ -59,95 +53,79 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } -/** Keep cached summaries visible when an offscreen row stops live queries. */ +/** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, enabled = true, pullRequests?: ReadonlyArray, + branchPullRequest?: ThreadLinkedPullRequest | null, ): LinkedThreadPullRequestStatus | null { - const host = - linkedPullRequest == null - ? undefined - : pullRequests?.find( - (link) => - link.number === linkedPullRequest.number && - link.repository.toLowerCase() === linkedPullRequest.repository.toLowerCase(), - )?.host; + const configs = useServerConfigs(); + const supportsLinks = + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.threadPullRequests === true; + const current = useMemo( + () => resolveThreadCurrentPullRequestLink(pullRequests ?? []), + [pullRequests], + ); + const fallback = + current === null + ? ((!supportsLinks && (pullRequests?.length ?? 0) === 0 ? linkedPullRequest : null) ?? + branchPullRequest) + : null; + const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host; + const reference = + fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) }; const queried = useEnvironmentQuery( - !enabled || environmentId === null || linkedPullRequest == null + !enabled || environmentId === null || reference === null ? null - : linkedPullRequestDetailAtom({ - environmentId, - input: { - projectId: linkedPullRequest.projectId, - ...(host === undefined ? {} : { host }), - repository: linkedPullRequest.repository, - number: linkedPullRequest.number, - }, - }), + : linkedPullRequestDetailAtom({ environmentId, input: reference }), ).data; - const detail = useSharedPullRequestSummary( - environmentId, - linkedPullRequest == null - ? null - : { ...linkedPullRequest, ...(host === undefined ? {} : { host }) }, - queried, - ); + const detail = useSharedPullRequestSummary(environmentId, reference, queried); - return useMemo( - () => - detail === null - ? null - : { - pr: pullRequestDetailToVcsStatus(detail), - sourceControlProvider: { - kind: detail.provider, - name: detail.provider, - baseUrl: "", - }, - }, - [detail], - ); + return useMemo(() => { + if (current !== null) return linkedPullRequestSnapshotStatus(current); + return detail === null + ? null + : { + pr: pullRequestDetailToVcsStatus(detail), + sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" }, + }; + }, [current, detail]); } -/** A single stack is when every visible link sits in one chain of two or more. */ -export function isSingleStack(lines: ReadonlyArray): boolean { - return lines.length > 1 && new Set(lines.map((line) => line.chainKey)).size === 1; +export function linkedPullRequestSnapshotStatus( + link: ThreadPullRequestLink, +): LinkedThreadPullRequestStatus | null { + const snapshot = link.snapshot; + if (snapshot === null) return null; + const kind = link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : "github"; + return { + pr: { + number: link.number, + url: link.url, + title: snapshot.title, + state: snapshot.state, + isDraft: snapshot.isDraft, + headRef: snapshot.headBranch, + baseRef: snapshot.baseBranch, + ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), + }, + sourceControlProvider: { kind, name: kind, baseUrl: "" }, + }; } -/** - * How a row's pull-request badge reads. A thread whose links are one stack shows the layers - * glyph and the layer count, coloured by where the stack stands as a whole; any other set of - * links shows the pull-request glyph, the current number, and how many others sit behind it. - * Null when the thread has no links, so the badge falls back to whatever the branch reports. - */ -export type ThreadPullRequestBadge = - | { - readonly kind: "stack"; - readonly layers: number; - readonly state: NonNullable["state"]; - } - | { readonly kind: "pull-request"; readonly others: number }; - -export function resolveThreadPullRequestBadge( - pullRequests: ReadonlyArray | undefined, -): ThreadPullRequestBadge | null { - const visible = visibleThreadPullRequests(pullRequests ?? []); - if (visible.length === 0) return null; - const lines = pullRequestListLines(resolveThreadPullRequestChains(visible)); - if (isSingleStack(lines)) { - const states = lines.map((line) => line.link.snapshot?.state ?? "open"); - // Open while any layer is; merged once every layer merged; closed otherwise. - const state = states.includes("open") - ? "open" - : states.every((entry) => entry === "merged") - ? "merged" - : "closed"; - return { kind: "stack", layers: lines.length, state }; - } - return { kind: "pull-request", others: visible.length - 1 }; -} +export { + resolveThreadPullRequestBadge, + type ThreadPullRequestBadge, +} from "@t3tools/shared/threadPullRequests"; /** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */ export function ThreadPullRequestBadgeIcon({ @@ -429,9 +407,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ); const pullRequest = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, true, thread.pullRequests, + thread.branchPullRequest, ); const pr = pullRequest?.pr ?? null; const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); @@ -442,7 +421,8 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar }, }); - if (!prStatus && !threadStatus) { + const pendingLink = pr === null ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; + if (!prStatus && !threadStatus && !pendingLink) { return null; } @@ -465,6 +445,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ) : null} + {pendingLink ? ( + + ) : null} {threadStatus ? : null} ); diff --git a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx new file mode 100644 index 00000000000..dfa2ffa495d --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx @@ -0,0 +1,69 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { Link2 } from "lucide-react"; +import { useState } from "react"; +import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { useServerConfigs } from "~/state/entities"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** Adopts a branch discovery as a durable link, even after the thread changes branches. */ +export function LinkBranchPullRequestButton({ + threadRef, + url, +}: { + threadRef: ScopedThreadRef; + url: string; +}) { + const configs = useServerConfigs(); + const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false }); + const [pending, setPending] = useState(false); + const reference = parseChangeRequestUrl(url); + if ( + !reference || + configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true + ) + return null; + return ( + + event.stopPropagation()} + onClick={async (event) => { + event.preventDefault(); + event.stopPropagation(); + setPending(true); + const result = await link({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, ...reference, url, source: "manual" }, + }).finally(() => setPending(false)); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not link pull request", + description: error instanceof Error ? error.message : String(error), + }); + } + }} + > + + + } + /> + Link this PR to keep it with this thread + + ); +} diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx index 6d263c98bce..4c457a0d1a6 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -34,7 +34,7 @@ import { Input } from "../ui/input"; * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives * a palette that closes the moment its command runs. */ -export const linkPullRequestDialogThreadAtom = Atom.make(null).pipe( +const linkPullRequestDialogThreadAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("pull-requests:link-dialog-thread"), ); @@ -135,7 +135,7 @@ export function changeRequestWebUrl( } } -export function LinkPullRequestDialog({ +function LinkPullRequestDialog({ open, threadRef, projectId, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 0318a6ecd3a..4f4279960d5 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -71,16 +71,10 @@ import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; -import { useThreadShell } from "~/state/entities"; -import { threadEnvironment } from "~/state/threads"; import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; -import { - threadPullRequestKeysEqual, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; import { PullRequestStackMap } from "./PullRequestStackMap"; +import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { useUiStateStore } from "~/uiStateStore"; @@ -508,7 +502,7 @@ export function PullRequestDetailPanel({ */ onBack?: (() => void) | undefined; }) { - const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const matchingListEntry = listEntry?.projectId === reference.projectId && listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && @@ -726,44 +720,6 @@ export function PullRequestDetailPanel({ ? null : pullRequestStackAtom({ environmentId, input: reference }), ).data; - // Beside a thread, the panel can attach the pull request it shows to that thread. The thread - // ref is the composer target when it is one; a draft has no thread to link to yet. - const linkableThreadRef = - context === "thread" && - composerDraftTarget !== undefined && - typeof composerDraftTarget !== "string" - ? composerDraftTarget - : null; - const linkableThread = useThreadShell(linkableThreadRef); - const linkedHere = - detail !== null && - linkableThread !== null && - (() => { - const parsed = parseChangeRequestUrl(detail.url); - return ( - parsed !== null && - visibleThreadPullRequests(linkableThread.pullRequests).some((link) => - threadPullRequestKeysEqual(link, parsed), - ) - ); - })(); - const linkToThread = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: true }); - const linkThisPullRequest = useCallback(() => { - if (detail === null || linkableThreadRef === null) return; - const parsed = parseChangeRequestUrl(detail.url); - if (parsed === null) return; - void linkToThread({ - environmentId: linkableThreadRef.environmentId, - input: { - threadId: linkableThreadRef.threadId, - host: parsed.host, - repository: parsed.repository, - number: parsed.number, - url: detail.url, - source: "manual", - }, - }); - }, [detail, linkToThread, linkableThreadRef]); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1585,17 +1541,20 @@ export function PullRequestDetailPanel({
{detail ? ( <> + {/* Checking a pull request out is the reason to open one here at all, so it is a button of its own rather than a side effect of asking an agent for something. It asks where, because the two answers are not interchangeable: one leaves your work where it is, the other moves the repository you are standing in. Only on the page: beside a thread the branch is already checked out right there. */} - {linkableThreadRef !== null && !linkedHere ? ( - - ) : null} {context === "page" ? ( ; +} + +function EnabledPullRequestThreadLinks({ + environmentId, + reference, + url, + threadRef, +}: PullRequestThreadLinksProps) { + const parsed = parseChangeRequestUrl(url); + const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null; + const thread = useThreadShell(currentThreadRef); + const linkedHere = + parsed !== null && + visibleThreadPullRequests(thread?.pullRequests ?? []).some((link) => + threadPullRequestKeysEqual(link, parsed), + ); + const relations = useEnvironmentQuery( + pullRequestEnvironment.linkedThreads({ + environmentId, + input: parsed === null ? reference : { ...reference, ...parsed }, + }), + ); + // Refreshes can briefly clear the query value. Keep the last response so polling + // does not unmount an open menu or move its highlighted thread. + const [lastRelations, setLastRelations] = useState(relations.data); + if (relations.data !== null && relations.data !== lastRelations) { + setLastRelations(relations.data); + } + const link = useAtomCommand(threadEnvironment.linkPullRequest); + const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest); + const [pickerOpen, setPickerOpen] = useState(false); + const [pending, setPending] = useState(false); + + const changeLink = async (threadId: ThreadId, remove: boolean) => { + if (parsed === null || pending) return; + setPending(true); + const input = { + threadId, + host: parsed.host, + repository: parsed.repository, + number: parsed.number, + }; + const result = await ( + remove + ? unlink({ environmentId, input }) + : link({ environmentId, input: { ...input, url, source: "manual" } }) + ).finally(() => setPending(false)); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: remove ? "Could not unlink the pull request" : "Could not link the pull request", + }); + return; + } + relations.refresh(); + setPickerOpen(false); + }; + + if (parsed === null) return null; + const linkedThreads = (relations.data ?? lastRelations)?.threads ?? []; + const linkedThreadsLabel = + linkedThreads.length > 0 + ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}` + : "Linked threads"; + return ( + <> + {linkedThreads.length > 0 || relations.error !== null ? ( + + + } + > + + {linkedThreadsLabel} + + {linkedThreads.length || "?"} + + + + {relations.error !== null ? ( + Could not load linked threads. Retry + ) : null} + {linkedThreads.map((linkedThread) => ( + + } + > + + {linkedThread.title || "Untitled thread"} + + {linkedThread.archivedAt !== null ? ( + Archived + ) : null} + + ))} + + + ) : null} + {currentThreadRef !== null ? ( + + ) : ( + + )} + + + Link pull request to a thread + {pickerOpen ? ( + void changeLink(threadId, false)} + /> + ) : null} + + + + ); +} + +function ThreadPicker({ + environmentId, + url, + pending, + onSelect, +}: { + environmentId: EnvironmentId; + url: string; + pending: boolean; + onSelect: (threadId: ThreadId) => void; +}) { + const threads = useThreadShells(); + const projects = useProjects(); + const [query, setQuery] = useState(""); + const parsed = parseChangeRequestUrl(url); + const projectNames = new Map( + projects + .filter((project) => project.environmentId === environmentId) + .map((project) => [project.id, project.title]), + ); + const search = query.trim().toLocaleLowerCase(); + const candidates = threads + .filter( + (thread) => + thread.environmentId === environmentId && + thread.archivedAt === null && + `${thread.title} ${projectNames.get(thread.projectId) ?? ""}` + .toLocaleLowerCase() + .includes(search), + ) + .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return ( + + + + {candidates.length === 0 ? ( +
+ No active threads found. +
+ ) : ( + candidates.map((thread) => { + const linked = + parsed !== null && + visibleThreadPullRequests(thread.pullRequests).some((link) => + threadPullRequestKeysEqual(link, parsed), + ); + return ( + onSelect(thread.id)} + > + + + {thread.title || "Untitled thread"} + + {projectNames.get(thread.projectId)} + + + {linked ? ( + <> + + Linked + + ) : null} + + ); + }) + )} +
+
+ ); +} diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 7da0dcd9f37..254c7869dd8 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -241,7 +241,9 @@ export function useOpenChangeRequestLink( Number(right.environmentId === primaryEnvironmentId) - Number(left.environmentId === primaryEnvironmentId), ); - const project = findProjectForChangeRequest(projects, parsed); + const project = resolvedPanelRef + ? findProjectOnChangeRequestHost(projects, parsed) + : findProjectForChangeRequest(projects, parsed); if (project === undefined || !reads(project.environmentId)) return false; event.preventDefault(); event.stopPropagation(); @@ -252,9 +254,9 @@ export function useOpenChangeRequestLink( ? {} : { environmentId: project.environmentId }), projectId: project.id, - // The identity's own spelling, not the one read out of the URL: the panel asks the - // provider for this repository, while matching a link only ever compares lower case. - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + host: parsed.host, + repository: parsed.repository, + url: targetUrl, number: parsed.number, }); if (!resolvedThreadRef) { @@ -264,7 +266,7 @@ export function useOpenChangeRequestLink( ...previous, involvement: previous.involvement ?? "all", state: previous.state ?? "all", - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + repository: parsed.repository, number: parsed.number, selectedProjectId: project.id, selectedEnvironmentId: project.environmentId, diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index dd205adc1e4..ad1788f4cfe 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -605,6 +605,18 @@ describe("rightPanelStore", () => { expect(state.surfaces[1]).not.toHaveProperty("url"); }); + it("keeps matching repository and number on different hosts as separate tabs", () => { + const first = { projectId: "project-a", repository: "acme/api", number: 7, host: "github.com" }; + const second = { ...first, host: "github.example.com" }; + useRightPanelStore.getState().openPullRequest(refA, first); + useRightPanelStore.getState().openPullRequest(refA, second); + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces).toEqual([pullRequestSurface(first), pullRequestSurface(second)]); + expect(pullRequestSurfaceId({ ...first, host: "GITHUB.COM" })).toBe( + pullRequestSurfaceId(first), + ); + }); + it("keeps one pull request read from two servers as two tabs", () => { const local = { environmentId: "local", diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 20d0a67eef7..1719ae77725 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -69,6 +69,7 @@ export type RightPanelSurface = */ environmentId?: string; projectId: string; + host?: string; repository: string; number: number; url?: string; @@ -127,6 +128,7 @@ interface RightPanelStoreState { target: { environmentId?: string; projectId: string; + host?: string; repository: string; number: number; url?: string; @@ -218,6 +220,7 @@ export type PullRequestSurface = Extract
- {thread.pullRequests.length > 0 ? ( + {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? (
@@ -1080,7 +1082,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus.data, ); const pr = linkedPullRequestStatus?.pr ?? null; - const currentLinkedPr = resolveThreadCurrentPullRequestLink(thread.pullRequests); + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. @@ -1486,7 +1491,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // One badge shape for every thread: the glyph says stack or not, the number is the current // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and // middle-click open the host in the browser; a plain click opens T3's pull request view. - const prBadgeShape = resolveThreadPullRequestBadge(thread.pullRequests); + const prBadgeShape = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(thread.pullRequests) + : null; const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) => cn( // Sidebar chrome follows the interface font; tabular digits keep the number from @@ -1527,7 +1534,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className={cn( // Sidebar chrome follows the interface font; tabular digits keep the // number from reflowing as PR states stream in. - "shrink-0 text-xs tabular-nums hover:underline", + "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? cn("text-secondary-label transition-colors", settledPrHoverClass) : prStatus.colorClass, @@ -1671,7 +1678,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} - {prBadge && pr && visibleThreadPullRequests(thread.pullRequests).length === 0 ? ( + {prBadge && + pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( ) : null} {sortable?.isDragging ? ( @@ -1974,7 +1985,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} {prBadge} - {prBadge && pr && visibleThreadPullRequests(thread.pullRequests).length === 0 ? ( + {prBadge && + pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( ) : null} {diff ? ( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 38803428a19..62ea831343e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -53,6 +53,14 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } +export function useSupportsMultiplePullRequests(environmentId: EnvironmentId | null): boolean { + const configs = useServerConfigs(); + return ( + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.threadPullRequests === true + ); +} + /** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, @@ -61,19 +69,13 @@ export function useLinkedThreadPullRequest( pullRequests?: ReadonlyArray, branchPullRequest?: ThreadLinkedPullRequest | null, ): LinkedThreadPullRequestStatus | null { - const configs = useServerConfigs(); - const supportsLinks = - environmentId !== null && - configs.get(environmentId)?.environment.capabilities.threadPullRequests === true; + const supportsLinks = useSupportsMultiplePullRequests(environmentId); const current = useMemo( - () => resolveThreadCurrentPullRequestLink(pullRequests ?? []), - [pullRequests], + () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null), + [pullRequests, supportsLinks], ); const fallback = - current === null - ? ((!supportsLinks && (pullRequests?.length ?? 0) === 0 ? linkedPullRequest : null) ?? - branchPullRequest) - : null; + current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null; const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host; const reference = fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) }; @@ -421,7 +423,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar }, }); - const pendingLink = pr === null ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const pendingLink = + pr === null && supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; if (!prStatus && !threadStatus && !pendingLink) { return null; } diff --git a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx index dfa2ffa495d..babf281841d 100644 --- a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx +++ b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx @@ -1,14 +1,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { Link2 } from "lucide-react"; import { useState } from "react"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; -import { useServerConfigs } from "~/state/entities"; -import { threadEnvironment } from "~/state/threads"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { Button } from "../ui/button"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -21,15 +14,9 @@ export function LinkBranchPullRequestButton({ threadRef: ScopedThreadRef; url: string; }) { - const configs = useServerConfigs(); - const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false }); + const linking = usePullRequestLinking(threadRef.environmentId); const [pending, setPending] = useState(false); - const reference = parseChangeRequestUrl(url); - if ( - !reference || - configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true - ) - return null; + if (!linking.canLink(url)) return null; return ( setPending(false)); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + try { + await linking.changeLink(threadRef, url, true); + } catch (error) { toastManager.add({ type: "error", title: "Could not link pull request", description: error instanceof Error ? error.message : String(error), }); + } finally { + setPending(false); } }} > diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx index 4c457a0d1a6..8950d28e38e 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -3,18 +3,13 @@ import { type ScopedThreadRef, type SourceControlProviderKind, } from "@t3tools/contracts"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; import { parsePullRequestReference } from "~/pullRequestReference"; import { useProjects, useThreadShell } from "~/state/entities"; -import { threadEnvironment } from "~/state/threads"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { Atom } from "effect/unstable/reactivity"; import { Button } from "../ui/button"; @@ -55,7 +50,8 @@ interface LinkPullRequestDialogProps { export function LinkPullRequestDialogHost() { const threadRef = useAtomValue(linkPullRequestDialogThreadAtom); const thread = useThreadShell(threadRef); - if (threadRef === null) return null; + const linking = usePullRequestLinking(threadRef?.environmentId); + if (threadRef === null || linking.mode === "unsupported") return null; return ( changeRequestWebUrl(kind, host, repository, number), }; }, [environmentProjects, projectId]); - const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false }); + const linking = usePullRequestLinking(threadRef.environmentId); const [pending, setPending] = useState(false); useEffect(() => { @@ -198,19 +194,16 @@ function LinkPullRequestDialog({ if (resolved === null || "error" in resolved) return; setSubmitError(null); setPending(true); - const result = await link({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, ...resolved.link, source: "manual" }, - }).finally(() => setPending(false)); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const cause = squashAtomCommandFailure(result); - setSubmitError(cause instanceof Error ? cause.message : "Could not link the pull request."); - } + try { + await linking.changeLink(threadRef, resolved.link.url, true); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Could not link the pull request."); return; + } finally { + setPending(false); } onOpenChange(false); - }, [link, onOpenChange, resolved, threadRef]); + }, [linking, onOpenChange, resolved, threadRef]); const validation = !dirty ? null diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4f4279960d5..4213f0bea3e 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -66,7 +66,7 @@ import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; -import { useProjects } from "~/state/entities"; +import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; @@ -451,7 +451,7 @@ function PullRequestBaseFreshnessWarning({ export function PullRequestDetailPanel({ environmentId, threadRef = null, - reference, + reference: requestedReference, listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, @@ -502,6 +502,20 @@ export function PullRequestDetailPanel({ */ onBack?: (() => void) | undefined; }) { + const environmentConfigs = useServerConfigs(); + const supportsThreadPullRequests = + environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; + const reference = useMemo( + () => + supportsThreadPullRequests + ? requestedReference + : { + projectId: requestedReference.projectId, + repository: requestedReference.repository, + number: requestedReference.number, + }, + [requestedReference, supportsThreadPullRequests], + ); const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const matchingListEntry = listEntry?.projectId === reference.projectId && @@ -716,7 +730,7 @@ export function PullRequestDetailPanel({ // The host's own stack, where it keeps one. Only asked for once the detail has landed so a // pull request nobody can read costs one request rather than two. const nativeStack = useEnvironmentQuery( - detail === null || detail.capabilities.stacks !== true + detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests ? null : pullRequestStackAtom({ environmentId, input: reference }), ).data; diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index 3a4f7293183..dc27b243599 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -1,19 +1,15 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { Link } from "@tanstack/react-router"; import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; -import { - threadPullRequestKeysEqual, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react"; import { useState } from "react"; +import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; -import { threadEnvironment } from "~/state/threads"; -import { useAtomCommand } from "~/state/use-atom-command"; import { buildThreadRouteParams } from "~/threadRoutes"; import { Button } from "../ui/button"; import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; @@ -31,7 +27,10 @@ interface PullRequestThreadLinksProps { /** Thread relations belong to the detail environment, including when another environment is active. */ export function PullRequestThreadLinks(props: PullRequestThreadLinksProps) { const configs = useServerConfigs(); - if (configs.get(props.environmentId)?.environment.capabilities.threadPullRequests !== true) { + if ( + threadPullRequestLinkMode(configs.get(props.environmentId)?.environment.capabilities) === + "unsupported" + ) { return null; } return ; @@ -46,16 +45,15 @@ function EnabledPullRequestThreadLinks({ const parsed = parseChangeRequestUrl(url); const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null; const thread = useThreadShell(currentThreadRef); - const linkedHere = - parsed !== null && - visibleThreadPullRequests(thread?.pullRequests ?? []).some((link) => - threadPullRequestKeysEqual(link, parsed), - ); + const linking = usePullRequestLinking(environmentId); + const linkedHere = linking.isLinked(thread, url); const relations = useEnvironmentQuery( - pullRequestEnvironment.linkedThreads({ - environmentId, - input: parsed === null ? reference : { ...reference, ...parsed }, - }), + linking.mode === "multiple" + ? pullRequestEnvironment.linkedThreads({ + environmentId, + input: parsed === null ? reference : { ...reference, ...parsed }, + }) + : null, ); // Refreshes can briefly clear the query value. Keep the last response so polling // does not unmount an open menu or move its highlighted thread. @@ -63,38 +61,31 @@ function EnabledPullRequestThreadLinks({ if (relations.data !== null && relations.data !== lastRelations) { setLastRelations(relations.data); } - const link = useAtomCommand(threadEnvironment.linkPullRequest); - const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest); const [pickerOpen, setPickerOpen] = useState(false); const [pending, setPending] = useState(false); const changeLink = async (threadId: ThreadId, remove: boolean) => { if (parsed === null || pending) return; setPending(true); - const input = { - threadId, - host: parsed.host, - repository: parsed.repository, - number: parsed.number, - }; - const result = await ( - remove - ? unlink({ environmentId, input }) - : link({ environmentId, input: { ...input, url, source: "manual" } }) - ).finally(() => setPending(false)); - if (result._tag === "Failure") { + try { + await linking.changeLink(scopeThreadRef(environmentId, threadId), url, !remove); + } catch (error) { toastManager.add({ type: "error", title: remove ? "Could not unlink the pull request" : "Could not link the pull request", + description: error instanceof Error ? error.message : String(error), }); return; + } finally { + setPending(false); } relations.refresh(); setPickerOpen(false); }; - if (parsed === null) return null; - const linkedThreads = (relations.data ?? lastRelations)?.threads ?? []; + if (parsed === null || (!linkedHere && !linking.canLink(url))) return null; + const linkedThreads = + linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : []; const linkedThreadsLabel = linkedThreads.length > 0 ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}` @@ -201,9 +192,9 @@ function ThreadPicker({ onSelect: (threadId: ThreadId) => void; }) { const threads = useThreadShells(); + const linking = usePullRequestLinking(environmentId); const projects = useProjects(); const [query, setQuery] = useState(""); - const parsed = parseChangeRequestUrl(url); const projectNames = new Map( projects .filter((project) => project.environmentId === environmentId) @@ -230,11 +221,7 @@ function ThreadPicker({ ) : ( candidates.map((thread) => { - const linked = - parsed !== null && - visibleThreadPullRequests(thread.pullRequests).some((link) => - threadPullRequestKeysEqual(link, parsed), - ); + const linked = linking.isLinked(thread, url); return ( + ); + } + return ; +} + +function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { const thread = useThreadShell(threadRef); const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts new file mode 100644 index 00000000000..7c6197d16ff --- /dev/null +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -0,0 +1,100 @@ +import { useMemo } from "react"; +import type { + EnvironmentId, + ScopedThreadRef, + ThreadLinkedPullRequest, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + planThreadPullRequestMutation, + threadPullRequestLinkMode, +} from "@t3tools/client-runtime/thread-pull-request-compatibility"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { + findProjectForChangeRequest, + findProjectOnChangeRequestHost, + matchesLinkedPullRequestUrl, + parseChangeRequestUrl, +} from "~/lib/openPullRequestLink"; +import { useProjects, useServerConfigs } from "~/state/entities"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; + +/** Routes link actions through the command advertised by this environment. */ +export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) { + const configs = useServerConfigs(); + const projects = useProjects(); + const capabilities = + environmentId == null ? undefined : configs.get(environmentId)?.environment.capabilities; + const mode = threadPullRequestLinkMode(capabilities); + const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false }); + const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: false }); + const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); + return useMemo(() => { + const environmentProjects = projects.filter( + (project) => project.environmentId === environmentId, + ); + const canLink = (url: string) => { + const parsed = parseChangeRequestUrl(url); + if (parsed === null || mode === "unsupported") return false; + return ( + (mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)( + environmentProjects, + parsed, + ) !== undefined + ); + }; + const isLinked = ( + thread: { + readonly pullRequests?: readonly ThreadPullRequestLink[]; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + } | null, + url: string, + ) => { + if (thread === null || mode === "unsupported") return false; + if (mode !== "multiple") + return ( + thread.linkedPullRequest != null && + matchesLinkedPullRequestUrl(thread.linkedPullRequest, url) + ); + const parsed = parseChangeRequestUrl(url); + return ( + parsed !== null && + visibleThreadPullRequests(thread.pullRequests ?? []).some((entry) => + threadPullRequestKeysEqual(entry, parsed), + ) + ); + }; + const changeLink = async (threadRef: ScopedThreadRef, url: string, linked: boolean) => { + const parsed = parseChangeRequestUrl(url); + if (parsed === null || threadRef.environmentId !== environmentId || (linked && !canLink(url))) + throw new Error("The pull request is not available in this environment."); + const mutation = planThreadPullRequestMutation({ + capabilities, + threadId: threadRef.threadId, + reference: { ...parsed, url }, + legacyProjectId: findProjectForChangeRequest(environmentProjects, parsed)?.id ?? null, + linked, + }); + if (mutation === null) + throw new Error("This environment does not support linking this pull request."); + const result = await (mutation.type === "thread.meta.update" + ? updateMetadata({ environmentId: threadRef.environmentId, input: mutation.input }) + : mutation.type === "thread.pull-request.link" + ? link({ environmentId: threadRef.environmentId, input: mutation.input }) + : unlink({ environmentId: threadRef.environmentId, input: mutation.input })); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) throw new Error("Link update interrupted."); + throw squashAtomCommandFailure(result); + } + }; + return { mode, canLink, isLinked, changeLink }; + }, [capabilities, environmentId, link, mode, projects, unlink, updateMetadata]); +} diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 254c7869dd8..9d7086a3395 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -241,9 +241,19 @@ export function useOpenChangeRequestLink( Number(right.environmentId === primaryEnvironmentId) - Number(left.environmentId === primaryEnvironmentId), ); - const project = resolvedPanelRef - ? findProjectOnChangeRequestHost(projects, parsed) - : findProjectForChangeRequest(projects, parsed); + const exactProject = findProjectForChangeRequest(projects, parsed); + const project = + exactProject ?? + (resolvedPanelRef + ? findProjectOnChangeRequestHost( + projects.filter( + (candidate) => + serverConfigs.get(candidate.environmentId)?.environment.capabilities + .threadPullRequests === true, + ), + parsed, + ) + : undefined); if (project === undefined || !reads(project.environmentId)) return false; event.preventDefault(); event.stopPropagation(); @@ -254,7 +264,10 @@ export function useOpenChangeRequestLink( ? {} : { environmentId: project.environmentId }), projectId: project.id, - host: parsed.host, + ...(serverConfigs.get(project.environmentId)?.environment.capabilities + .threadPullRequests === true + ? { host: parsed.host } + : {}), repository: parsed.repository, url: targetUrl, number: parsed.number, diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 3025d3b0b3e..d064c8e1504 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,6 +18,23 @@ versioned clients and servers. Subscriptions send the state a client needs, so a thread does not pay for every thread's history. Authentication of a socket does not authorize every method on it. See [environment auth](./environment-auth.md). +### Pull request linking compatibility + +Web, desktop, mobile, and environments upgrade independently. Negotiate linking through the +environment descriptor, never through a client version or an assumed coordinated release: + +| Environment capability | Client behavior | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `threadPullRequests: true` | Use persisted `pullRequests[]`, multi-link commands, stack UI, and reverse thread lookup. | +| Only `threadPullRequestLinking: true` | Use `linkedPullRequest` and the existing `thread.meta.update` single-link operation. Do not call multi-link RPCs. | +| Neither flag | Hide linking actions; existing branch-discovered PR display remains available. | + +New environments continue advertising the legacy flag, accepting legacy metadata commands, and +emitting the derived `linkedPullRequest` field for older clients. New clients accept snapshots that +omit `pullRequests`. Retain the legacy wire fields, projection column, and replay support; this feature +does not schedule their removal. Missing new capabilities must also override cached multi-link data +after an environment downgrade. + Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands and events, so adding a provider should not require branches throughout the domain or clients. See [provider constraints](./providers.md). diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 368585556cf..2bacdfcb228 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -230,6 +230,10 @@ "./work-log/scroll-anchor": { "types": "./src/work-log/scrollAnchor.ts", "default": "./src/work-log/scrollAnchor.ts" + }, + "./thread-pull-request-compatibility": { + "types": "./src/threadPullRequestCompatibility.ts", + "default": "./src/threadPullRequestCompatibility.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.test.ts b/packages/client-runtime/src/threadPullRequestCompatibility.test.ts new file mode 100644 index 00000000000..d9c5c99fe82 --- /dev/null +++ b/packages/client-runtime/src/threadPullRequestCompatibility.test.ts @@ -0,0 +1,84 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import { + planThreadPullRequestMutation, + threadPullRequestLinkMode, +} from "./threadPullRequestCompatibility.ts"; + +const reference = { + host: "github.example", + repository: "team/repo", + number: 7, + url: "https://github.example/team/repo/pull/7", +}; +const input = { + threadId: ThreadId.make("thread"), + reference, + legacyProjectId: ProjectId.make("exact-checkout"), + linked: true, +}; + +describe("thread pull request capability negotiation", () => { + it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])( + "does not dispatch when linking is unadvertised: %j", + (capabilities) => { + expect(threadPullRequestLinkMode(capabilities)).toBe("unsupported"); + expect(planThreadPullRequestMutation({ ...input, capabilities })).toBeNull(); + }, + ); + it("uses metadata updates for old single-link servers, including unlink", () => { + const capabilities = { threadPullRequestLinking: true }; + expect(planThreadPullRequestMutation({ ...input, capabilities })).toEqual({ + type: "thread.meta.update", + input: { + threadId: input.threadId, + linkedPullRequest: { + projectId: input.legacyProjectId, + repository: reference.repository, + number: 7, + url: reference.url, + }, + }, + }); + expect( + planThreadPullRequestMutation({ + ...input, + capabilities, + linked: false, + legacyProjectId: null, + }), + ).toEqual({ + type: "thread.meta.update", + input: { threadId: input.threadId, linkedPullRequest: null }, + }); + }); + it("never sends a same-host route as an exact repository to an old server", () => { + expect( + planThreadPullRequestMutation({ + ...input, + capabilities: { threadPullRequestLinking: true }, + legacyProjectId: null, + }), + ).toBeNull(); + }); + it.each([ + { threadPullRequests: true }, + { threadPullRequests: true, threadPullRequestLinking: true }, + ])("prefers multi-link commands when available: %j", (capabilities) => { + expect( + planThreadPullRequestMutation({ ...input, capabilities, legacyProjectId: null }), + ).toEqual({ + type: "thread.pull-request.link", + input: { threadId: input.threadId, ...reference, source: "manual" }, + }); + expect(planThreadPullRequestMutation({ ...input, capabilities, linked: false })).toEqual({ + type: "thread.pull-request.unlink", + input: { + threadId: input.threadId, + host: reference.host, + repository: reference.repository, + number: reference.number, + }, + }); + }); +}); diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.ts b/packages/client-runtime/src/threadPullRequestCompatibility.ts new file mode 100644 index 00000000000..cea1b78d4cc --- /dev/null +++ b/packages/client-runtime/src/threadPullRequestCompatibility.ts @@ -0,0 +1,72 @@ +import type { + ExecutionEnvironmentCapabilities, + ProjectId, + ThreadId, + ThreadPullRequestKey, +} from "@t3tools/contracts"; + +type LinkingCapabilities = Pick< + ExecutionEnvironmentCapabilities, + "threadPullRequests" | "threadPullRequestLinking" +>; + +/** Capability negotiation keeps both single-link and multi-link environments usable. */ +export function threadPullRequestLinkMode(capabilities: LinkingCapabilities | null | undefined) { + return capabilities?.threadPullRequests === true + ? "multiple" + : capabilities?.threadPullRequestLinking === true + ? "single" + : "unsupported"; +} + +export function planThreadPullRequestMutation({ + capabilities, + threadId, + reference, + legacyProjectId, + linked, +}: { + capabilities: LinkingCapabilities | null | undefined; + threadId: ThreadId; + reference: ThreadPullRequestKey & { readonly url: string }; + /** Older servers need the exact repository checkout; same-host routing is not available. */ + legacyProjectId: ProjectId | null; + linked: boolean; +}) { + switch (threadPullRequestLinkMode(capabilities)) { + case "multiple": + return linked + ? { + type: "thread.pull-request.link" as const, + input: { threadId, ...reference, source: "manual" as const }, + } + : { + type: "thread.pull-request.unlink" as const, + input: { + threadId, + host: reference.host, + repository: reference.repository, + number: reference.number, + }, + }; + case "single": + if (linked && legacyProjectId === null) return null; + return { + type: "thread.meta.update" as const, + input: { + threadId, + linkedPullRequest: + linked && legacyProjectId !== null + ? { + projectId: legacyProjectId, + repository: reference.repository, + number: reference.number, + url: reference.url, + } + : null, + }, + }; + case "unsupported": + return null; + } +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 088c09ce575..865afd410fa 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -123,8 +123,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), - /** Server persists a pull request reference on thread.meta.update. Superseded by - threadPullRequests; servers that set the new flag no longer set this one. */ + /** Server supports legacy linkedPullRequest updates through thread.meta.update. + Independent of threadPullRequests; servers supporting both advertise both. */ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pull-request.link / .unlink, exposes `pullRequests` on threads, and routes PullRequestRef.host across projects on the same host. Same diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 48ae353914d..b8c8358813d 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -26,6 +26,7 @@ import { OrchestrationMessage, ThreadMessageSentPayload, ThreadMetaUpdatedPayload, + ThreadLinkedPullRequest, ThreadTurnStartCommand, ThreadCreatedPayload, ThreadTurnDiff, @@ -685,6 +686,46 @@ it.effect("defaults settled fields when decoding historical thread data", () => // Pre-link servers omit the array entirely. assert.deepStrictEqual(thread.pullRequests, []); assert.deepStrictEqual(shell.pullRequests, []); + + const legacyLink = { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 42, + url: "https://github.com/acme/web/pull/42", + }; + const oldServerShell = yield* decodeOrchestrationThreadShell({ + ...common, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + linkedPullRequest: legacyLink, + }); + assert.deepStrictEqual(oldServerShell.pullRequests, []); + assert.deepStrictEqual(oldServerShell.linkedPullRequest, legacyLink); + + // A decoder from before the array must still read its single-link field + // after a new server encodes the expanded snapshot. + const oldLinkFields = Schema.Struct({ + linkedPullRequest: Schema.optional(ThreadLinkedPullRequest), + }); + const newServerWire = yield* Schema.encodeEffect(OrchestrationThreadShell)({ + ...oldServerShell, + pullRequests: [ + { + host: "github.com", + repository: legacyLink.repository, + number: legacyLink.number, + url: legacyLink.url, + source: "agent", + linkedAt: common.createdAt, + snapshot: null, + stack: null, + }, + ], + }); + const oldClientFields = yield* Schema.decodeUnknownEffect(oldLinkFields)(newServerWire); + assert.deepStrictEqual(oldClientFields.linkedPullRequest, legacyLink); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3e061b10cc3..56a29b8f65f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -609,7 +609,7 @@ export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; /** * Legacy single-PR link. Still emitted as the thread's derived current pull * request (see `@t3tools/shared/threadPullRequests`) so clients from before - * `pullRequests` keep working; removed once mobile has shipped on the array. + * `pullRequests` keep working independently of their release schedule. */ export const ThreadLinkedPullRequest = Schema.Struct({ projectId: ProjectId, From 37e0b08e4d8598ec90d4e59a79fe480cdc152a5f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 16:46:13 -0700 Subject: [PATCH 05/13] refactor(pull-requests): share provider URL construction --- .../src/mcp/toolkits/pullRequests/handlers.ts | 23 +------------------ packages/shared/src/changeRequestUrl.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 92f09d70084..8951c30caff 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -7,7 +7,7 @@ import { type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { resolveThreadPullRequestChains, threadPullRequestKeyOf, @@ -58,27 +58,6 @@ function projectHostAndRepository(project: OrchestrationProjectShell | undefined }; } -/** The web URL a host writes for a change request; null when the host shape is unknown. */ -function changeRequestUrlFor( - kind: SourceControlProviderKind | null, - host: string, - repository: string, - number: number, -): string | null { - switch (kind) { - case "github": - return `https://${host}/${repository}/pull/${number}`; - case "gitlab": - return `https://${host}/${repository}/-/merge_requests/${number}`; - case "bitbucket": - return `https://${host}/${repository}/pull-requests/${number}`; - case "azure-devops": - return `https://${host}/${repository}/pullrequest/${number}`; - default: - return null; - } -} - /** * Turns whichever shape the agent passed into one host-level identity. A URL * wins outright; otherwise the repository and number are completed with the diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 3ef0324d007..142f53daf84 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -1,3 +1,5 @@ +import type { SourceControlProviderKind } from "@t3tools/contracts"; + /** * A change request named the way a thread link names one: the host below which the repository * is addressed, the repository path as that host writes it, and the number. @@ -73,3 +75,24 @@ function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | ? { host, repository: repository.toLowerCase(), number } : null; } + +/** The web URL a host writes for a change request; null when the host shape is unknown. */ +export function changeRequestUrlFor( + kind: SourceControlProviderKind | null, + host: string, + repository: string, + number: number, +): string | null { + switch (kind) { + case "github": + return `https://${host}/${repository}/pull/${number}`; + case "gitlab": + return `https://${host}/${repository}/-/merge_requests/${number}`; + case "bitbucket": + return `https://${host}/${repository}/pull-requests/${number}`; + case "azure-devops": + return `https://${host}/${repository}/pullrequest/${number}`; + default: + return null; + } +} From 2e508b4ed04ab742b8da995d055e85e6c04f7bc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 16:52:59 -0700 Subject: [PATCH 06/13] refactor(pull-requests): centralize source control utilities --- apps/server/src/git/linkCreatedPullRequest.ts | 4 +- .../src/mcp/toolkits/pullRequests/handlers.ts | 14 +-- .../orchestration/PullRequestSyncReactor.ts | 6 +- .../orchestration/ThreadPullRequestReactor.ts | 18 +-- .../pullRequest/PullRequestService.test.ts | 37 ------ .../src/pullRequest/PullRequestService.ts | 33 ++--- apps/web/src/components/LegacySidebar.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 2 +- .../src/components/ThreadStatusIndicators.tsx | 10 +- .../pullRequest/LinkPullRequestDialog.tsx | 23 +--- .../hooks/useSupportsMultiplePullRequests.ts | 10 ++ apps/web/src/lib/openPullRequestLink.ts | 119 ++---------------- packages/shared/src/changeRequestUrl.ts | 106 +++++++++++++++- packages/shared/src/sourceControl.test.ts | 32 +++++ packages/shared/src/sourceControl.ts | 46 ++++++- 15 files changed, 224 insertions(+), 238 deletions(-) create mode 100644 apps/web/src/hooks/useSupportsMultiplePullRequests.ts diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index b6eec85e31c..c0a5e863f8a 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -1,3 +1,4 @@ +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { type CommandId, pullRequestHostOf, @@ -13,7 +14,6 @@ import * as Option from "effect/Option"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { repositoryIdentityOf } from "../pullRequest/PullRequestService.ts"; export interface CreatedPullRequestKey { readonly host: string; @@ -41,7 +41,7 @@ export function createdPullRequestKey( if (parsed !== null) return { ...parsed, url }; const identity = project?.repositoryIdentity; const kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = project === undefined ? null : repositoryIdentityOf(project); + const repository = sourceControlRepositorySelector(identity); if (!identity || kind === undefined || repository === null) return null; return { host: pullRequestHostOf(identity, kind), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 8951c30caff..e8d6554ed6c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -20,7 +20,6 @@ import * as Option from "effect/Option"; import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { repositoryIdentityOf } from "../../../pullRequest/PullRequestService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { type ListThreadPullRequestsResult, @@ -39,21 +38,16 @@ interface ResolvedTarget { readonly url: string; } -/** - * The host and repository a thread's project is checked out from, so a bare - * repository+number can be completed and a URL rebuilt for it. - */ -function projectHostAndRepository(project: OrchestrationProjectShell | undefined): { +/** The project's host and provider supply defaults for a repository-and-number input. */ +function projectHostAndProvider(project: OrchestrationProjectShell | undefined): { readonly host: string | null; - readonly repository: string | null; readonly kind: SourceControlProviderKind | null; } { const identity = project?.repositoryIdentity; const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; - if (!identity || kind === null) return { host: null, repository: null, kind: null }; + if (!identity || kind === null) return { host: null, kind: null }; return { host: pullRequestHostOf(identity, kind), - repository: project ? repositoryIdentityOf(project) : null, kind, }; } @@ -82,7 +76,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( detail: "Pass either url, or both repository and number.", }); } - const projectHost = projectHostAndRepository(project); + const projectHost = projectHostAndProvider(project); const host = (input.host ?? projectHost.host)?.toLowerCase(); if (host === undefined) { return yield* new PullRequestTargetError({ diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 0db15a39c29..39b0b16a64b 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -1,3 +1,4 @@ +import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, type OrchestrationThreadShell, @@ -121,11 +122,6 @@ function isUnsettled(thread: OrchestrationThreadShell): boolean { } /** `.../pull/42` → `.../pull/43`; null when the linked url carries no trailing number. */ -function siblingPullRequestUrl(url: string, number: number): string | null { - const match = /^(.*\/)\d+\/?$/.exec(url); - return match === null ? null : `${match[1]}${number}`; -} - /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index bf0ee5fdb32..e6ee699ec4d 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -1,3 +1,7 @@ +import { + canonicalRepositoryKey, + sourceControlRepositorySelector, +} from "@t3tools/shared/sourceControl"; import { CommandId, type OrchestrationEvent, @@ -53,18 +57,6 @@ interface RefreshRequest { readonly backfill?: boolean; } -function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} - export function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: OrchestrationProjectShell, @@ -138,7 +130,7 @@ export const make = Effect.gen(function* () { const first = group[0]!; const project = projects.get(first.projectId); if (project === undefined) return finishBackfill(group); - const repository = PullRequestService.repositoryIdentityOf(project); + const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (first.branch !== null && repository === null) return finishBackfill(group); const worktreeExists = first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index fd0277c3822..e3fe166ca4f 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3802,43 +3802,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil }), ); -it("names an Azure DevOps repository by its own name, not its project path", () => { - // `az repos pr list --repository` takes a name and detects the organisation and project from - // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then - // reads as unavailable on the page. - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - owner: "contoso", - name: "checkout", - }, - } as never); - assert.strictEqual(selector, "checkout"); -}); - -it("falls back to the path's last segment where an Azure identity has no name", () => { - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - }, - } as never); - assert.strictEqual(selector, "checkout"); -}); - -it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "gitlab", - displayName: "group/subgroup/service", - owner: "group", - name: "service", - }, - } as never); - assert.strictEqual(selector, "group/subgroup/service"); -}); - it.effect("narrows the rows of a host that ignored the filters it was handed", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 998a1541a23..f4e342ede6b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,3 +1,4 @@ +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -522,30 +523,6 @@ function withRateLimitBackoff( Record, never>; } -/** - * The provider-native repository selector. `displayName` is the full path below the host, which - * is what nested GitLab groups need; owner/name is the two-segment fallback for identities - * recorded before that field existed. - * - * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and - * takes the organisation and project from the checkout it detects — so the recorded - * `org/project/_git/repo` path is refused outright and the whole repository reads as - * unavailable. Its name is the last segment, which is what this hands over. - * - * One function because everything downstream is keyed by what it answers: the rows' own - * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. - */ -export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { - const identity = project.repositoryIdentity; - if (!identity) return null; - if (identity.provider === "azure-devops") { - const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); - return identity.name || segments.at(-1) || null; - } - if (identity.displayName) return identity.displayName; - return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; -} - export const make = Effect.gen(function* () { const mergedPullRequests = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); @@ -568,7 +545,11 @@ export const make = Effect.gen(function* () { for (const project of projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; - if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue; + if ( + identity?.provider !== "unknown" || + sourceControlRepositorySelector(project.repositoryIdentity) === null + ) + continue; const host = pullRequestHostOf(identity, "unknown"); // A legacy identity has no canonical host until its provider is refined, so it must reach // the refinement before a host filter can decide whether it belongs in the result. @@ -642,7 +623,7 @@ export const make = Effect.gen(function* () { if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; let kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = repositoryIdentityOf(project); + const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (!identity || kind === undefined || repository === null) continue; // Worktrees of one repository are separate projects; reading the remote once keeps // the page from repeating every change request per local checkout. The host is part diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 0cfb9c1eccc..3a393662327 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,4 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { GitPullRequestIcon } from "lucide-react"; import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; import { @@ -24,7 +25,6 @@ import { ThreadStatusLabel, ThreadWorktreeIndicator, useLinkedThreadPullRequest, - useSupportsMultiplePullRequests, } from "./ThreadStatusIndicators"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3f2a3689ad8..9f13f780a34 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; import { resolveThreadCurrentPullRequestLink, @@ -201,7 +202,6 @@ import { terminalStatusFromRunningIds, type TerminalStatusIndicator, useLinkedThreadPullRequest, - useSupportsMultiplePullRequests, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 62ea831343e..41a72a40e4c 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,3 +1,4 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; import { @@ -17,7 +18,6 @@ import { useMemo } from "react"; import { cn } from "../lib/utils"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useServerConfigs } from "../state/entities"; import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; @@ -53,14 +53,6 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } -export function useSupportsMultiplePullRequests(environmentId: EnvironmentId | null): boolean { - const configs = useServerConfigs(); - return ( - environmentId !== null && - configs.get(environmentId)?.environment.capabilities.threadPullRequests === true - ); -} - /** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx index 8950d28e38e..daf06599032 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -1,3 +1,5 @@ +import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; +export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; import { pullRequestHostOf, type ScopedThreadRef, @@ -110,27 +112,6 @@ export function resolveLinkPullRequestInput(input: { }; } -/** The pull request page for a number on the hosts whose URL shape is known. */ -export function changeRequestWebUrl( - provider: string | undefined, - host: string, - repository: string, - number: number, -): string | null { - switch (provider) { - case "github": - return `https://${host}/${repository}/pull/${number}`; - case "gitlab": - return `https://${host}/${repository}/-/merge_requests/${number}`; - case "bitbucket": - return `https://${host}/${repository}/pull-requests/${number}`; - case "azure-devops": - return `https://${host}/${repository}/pullrequest/${number}`; - default: - return null; - } -} - function LinkPullRequestDialog({ open, threadRef, diff --git a/apps/web/src/hooks/useSupportsMultiplePullRequests.ts b/apps/web/src/hooks/useSupportsMultiplePullRequests.ts new file mode 100644 index 00000000000..6ffae19d521 --- /dev/null +++ b/apps/web/src/hooks/useSupportsMultiplePullRequests.ts @@ -0,0 +1,10 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useServerConfigs } from "~/state/entities"; + +export function useSupportsMultiplePullRequests(environmentId: EnvironmentId | null): boolean { + const configs = useServerConfigs(); + return ( + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.threadPullRequests === true + ); +} diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 9d7086a3395..e71920eee7d 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,9 +1,4 @@ -import type { - EnvironmentId, - RepositoryIdentity, - ScopedThreadRef, - ThreadLinkedPullRequest, -} from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent, useCallback } from "react"; @@ -18,110 +13,14 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; -/** Builds a GitHub URL that remains available when the pull request API cannot be read. */ -export function gitHubPullRequestBrowserUrl( - identity: RepositoryIdentity | null | undefined, - repository: string, - number: number, -): string | null { - if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; - const repositoryPath = repository.split("/"); - if ( - repositoryPath.length !== 2 || - repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - return null; - } - - let origin: string | null = null; - try { - const remoteUrl = new URL(identity.locator.remoteUrl.trim()); - if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { - origin = remoteUrl.origin; - } - } catch { - // SCP-style remotes are read from their normalized identity below. - } - const hostname = identity.canonicalKey.split("/")[0]; - if (origin === null && !hostname) return null; - - try { - const url = new URL(origin ?? `https://${hostname}`); - url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; - return url.toString(); - } catch { - return null; - } -} - -/** - * The parser is shared with the server (the `create_pr` auto-link and the MCP link tool read the - * same URLs), so there is exactly one opinion on which links are change requests. On the page a - * null result means the system browser: a doubtful match is worse than no match, since it takes - * the reader out of their browser and into a page that cannot find the change request. - */ -export { parseChangeRequestUrl, type ChangeRequestLink }; - -/** - * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare - * reference through `/issues/`, including pull requests, so this only builds a candidate: the - * caller must successfully read it as a pull request before treating it as one. - */ -export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if ( - (url.protocol !== "https:" && url.protocol !== "http:") || - !( - url.hostname.toLowerCase() === "github.com" || - url.hostname.toLowerCase().endsWith(".github.com") || - url.hostname.toLowerCase().startsWith("github.") - ) - ) { - return null; - } - const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match?.[1] === undefined || match[2] === undefined) return null; - url.pathname = `/${match[1]}/pull/${match[2]}`; - return url.toString(); -} - -/** Match a stored PR without requiring its project to remain available. */ -export function matchesLinkedPullRequestUrl( - linkedPullRequest: ThreadLinkedPullRequest, - targetUrl: string, -): boolean { - const linked = parseChangeRequestUrl(linkedPullRequest.url); - const target = parseChangeRequestUrl(targetUrl); - return ( - linked !== null && - target !== null && - linked.host === target.host && - linked.repository === target.repository && - linked.number === target.number - ); -} - -/** The repository root behind a recognised change-request URL, without PR-specific state. */ -export function changeRequestRepositoryUrl(targetUrl: string): string | null { - const changeRequest = parseChangeRequestUrl(targetUrl); - if (changeRequest === null) return null; - const url = new URL(targetUrl); - const repositoryPath = - /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? - /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( - url.pathname, - )?.[1]; - if (!repositoryPath) return null; - url.pathname = repositoryPath; - url.search = ""; - url.hash = ""; - return url.toString(); -} +export { + parseChangeRequestUrl, + type ChangeRequestLink, + gitHubPullRequestBrowserUrl, + pullRequestCandidateUrlFromReferenceAutolink, + matchesLinkedPullRequestUrl, + changeRequestRepositoryUrl, +} from "@t3tools/shared/changeRequestUrl"; /** * Returns a click handler that opens a pull request URL in the system browser. diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 142f53daf84..fd4a34aced3 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -1,4 +1,4 @@ -import type { SourceControlProviderKind } from "@t3tools/contracts"; +import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; /** * A change request named the way a thread link names one: the host below which the repository @@ -78,7 +78,7 @@ function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | /** The web URL a host writes for a change request; null when the host shape is unknown. */ export function changeRequestUrlFor( - kind: SourceControlProviderKind | null, + kind: string | null | undefined, host: string, repository: string, number: number, @@ -96,3 +96,105 @@ export function changeRequestUrlFor( return null; } } + +/** Builds a GitHub URL that remains available when the pull request API cannot be read. */ +export function gitHubPullRequestBrowserUrl( + identity: RepositoryIdentity | null | undefined, + repository: string, + number: number, +): string | null { + if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; + const repositoryPath = repository.split("/"); + if ( + repositoryPath.length !== 2 || + repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") + ) { + return null; + } + + let origin: string | null = null; + try { + const remoteUrl = new URL(identity.locator.remoteUrl.trim()); + if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { + origin = remoteUrl.origin; + } + } catch { + // SCP-style remotes are read from their normalized identity below. + } + const hostname = identity.canonicalKey.split("/")[0]; + if (origin === null && !hostname) return null; + + try { + const url = new URL(origin ?? `https://${hostname}`); + url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; + return url.toString(); + } catch { + return null; + } +} + +/** + * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare + * reference through `/issues/`, including pull requests, so this only builds a candidate: the + * caller must successfully read it as a pull request before treating it as one. + */ +export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + !( + url.hostname.toLowerCase() === "github.com" || + url.hostname.toLowerCase().endsWith(".github.com") || + url.hostname.toLowerCase().startsWith("github.") + ) + ) { + return null; + } + const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match?.[1] === undefined || match[2] === undefined) return null; + url.pathname = `/${match[1]}/pull/${match[2]}`; + return url.toString(); +} + +/** Match a stored PR without requiring its project to remain available. */ +export function matchesLinkedPullRequestUrl( + linkedPullRequest: ThreadLinkedPullRequest, + targetUrl: string, +): boolean { + const linked = parseChangeRequestUrl(linkedPullRequest.url); + const target = parseChangeRequestUrl(targetUrl); + return ( + linked !== null && + target !== null && + linked.host === target.host && + linked.repository === target.repository && + linked.number === target.number + ); +} + +/** The repository root behind a recognised change-request URL, without PR-specific state. */ +export function changeRequestRepositoryUrl(targetUrl: string): string | null { + const changeRequest = parseChangeRequestUrl(targetUrl); + if (changeRequest === null) return null; + const url = new URL(targetUrl); + const repositoryPath = + /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? + /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( + url.pathname, + )?.[1]; + if (!repositoryPath) return null; + url.pathname = repositoryPath; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export function siblingPullRequestUrl(url: string, number: number): string | null { + const match = /^(.*\/)\d+\/?$/.exec(url); + return match === null ? null : `${match[1]}${number}`; +} diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 86b1ba5912b..6a60d6f9b38 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + sourceControlRepositorySelector, detectSourceControlProviderFromRemoteUrl, getChangeRequestTerminologyForKind, isSshRemoteUrl, @@ -160,3 +161,34 @@ describe("isSshRemoteUrl", () => { expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false); }); }); + +it("names an Azure DevOps repository by its own name, not its project path", () => { + // `az repos pr list --repository` takes a name and detects the organisation and project from + // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then + // reads as unavailable on the page. + const selector = sourceControlRepositorySelector({ + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + owner: "contoso", + name: "checkout", + }); + expect(selector).toBe("checkout"); +}); + +it("falls back to the path's last segment where an Azure identity has no name", () => { + const selector = sourceControlRepositorySelector({ + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + }); + expect(selector).toBe("checkout"); +}); + +it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { + const selector = sourceControlRepositorySelector({ + provider: "gitlab", + displayName: "group/subgroup/service", + owner: "group", + name: "service", + }); + expect(selector).toBe("group/subgroup/service"); +}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 93b7c41ad44..9f1dc384e74 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,4 +1,8 @@ -import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; +import type { + RepositoryIdentity, + SourceControlProviderInfo, + SourceControlProviderKind, +} from "@t3tools/contracts"; export interface ChangeRequestPresentation { readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; @@ -234,3 +238,43 @@ export function detectSourceControlProviderFromRemoteUrl( baseUrl: toBaseUrl(host), }; } + +/** + * The provider-native repository selector. `displayName` is the full path below the host, which + * is what nested GitLab groups need; owner/name is the two-segment fallback for identities + * recorded before that field existed. + * + * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and + * takes the organisation and project from the checkout it detects — so the recorded + * `org/project/_git/repo` path is refused outright and the whole repository reads as + * unavailable. Its name is the last segment, which is what this hands over. + * + * One function because everything downstream is keyed by what it answers: the rows' own + * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. + */ +export function sourceControlRepositorySelector( + identity: + | Pick + | null + | undefined, +): string | null { + if (!identity) return null; + if (identity.provider === "azure-devops") { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); + return identity.name || segments.at(-1) || null; + } + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + +export function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} From 253d8434027a59c091cea382062b448560def79e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 16:59:24 -0700 Subject: [PATCH 07/13] test(web): provide capabilities in markdown fixtures --- apps/web/src/components/ChatMarkdown.test.tsx | 1 + apps/web/src/components/ChatMarkdown.workspace-images.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index c6a48b5c9e6..d21960a28e3 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -43,6 +43,7 @@ vi.mock("../state/session", async (importOriginal) => ({ vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], + useServerConfigs: () => new Map(), })); vi.mock("../remoteOpen", () => ({ useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 034dd8bfc34..344eca250ce 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({ vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], + useServerConfigs: () => new Map(), })); vi.mock("../remoteOpen", () => ({ useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), From 6275a3bce74f4b190499e101ee5e5acb32ae2dc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 17:04:51 -0700 Subject: [PATCH 08/13] fix(pull-requests): address service and control review findings --- apps/server/src/git/linkCreatedPullRequest.ts | 2 +- .../toolkits/pullRequests/handlers.test.ts | 9 +++- .../src/mcp/toolkits/pullRequests/handlers.ts | 16 +++---- .../src/mcp/toolkits/pullRequests/tools.ts | 4 +- .../Layers/ProjectionThreadPullRequests.ts | 17 ++++---- .../Services/ProjectionThreadPullRequests.ts | 42 +++++++++---------- .../LinkBranchPullRequestButton.tsx | 5 +-- .../pullRequest/PullRequestDetailPanel.tsx | 18 ++++---- apps/web/src/components/ui/button.tsx | 1 + 9 files changed, 58 insertions(+), 56 deletions(-) diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index c0a5e863f8a..8ae353934cc 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -85,7 +85,7 @@ export const linkCreatedPullRequest = (input: { ...key, source: "created", }) - .pipe(Effect.catchTag("OrchestrationCommandInvariantError", () => Effect.void)); + .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void })); }).pipe( Effect.withSpan("linkCreatedPullRequest"), Effect.catchCause((cause) => diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index fb30e04d524..010d4138b33 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -25,7 +25,7 @@ import { import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts"; -import { PullRequestsToolkit } from "./tools.ts"; +import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; const PROJECT_ID = ProjectId.make("project-1"); const THREAD_ID = ThreadId.make("thread-1"); @@ -406,3 +406,10 @@ describe("listThreadPullRequests", () => { expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]); }); }); + +it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => { + const cause = new Error("database internals"); + const failure = new PullRequestLinkFailedError({ operation: "link", cause }); + expect(failure.message).toBe("Could not link the pull request."); + expect(failure.cause).toBe(cause); +}); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index e8d6554ed6c..74d4dfbfc2c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -158,11 +158,7 @@ const make = Effect.gen(function* () { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); const thread = yield* snapshots .getThreadShellById(scope.threadId) - .pipe( - Effect.mapError( - (cause) => new PullRequestLinkFailedError({ operation, detail: cause.message }), - ), - ); + .pipe(Effect.mapError((cause) => new PullRequestLinkFailedError({ operation, cause }))); if (Option.isNone(thread)) { return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); } @@ -172,9 +168,7 @@ const make = Effect.gen(function* () { const projectOf = (thread: OrchestrationThreadShell, operation: "link" | "unlink") => snapshots.getProjectShellById(thread.projectId).pipe( Effect.map(Option.getOrUndefined), - Effect.mapError( - (cause) => new PullRequestLinkFailedError({ operation, detail: cause.message }), - ), + Effect.mapError((cause) => new PullRequestLinkFailedError({ operation, cause })), ); const dispatchFailure = @@ -182,7 +176,7 @@ const make = Effect.gen(function* () { (cause: Cause.Cause): Effect.Effect => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause) - : Effect.fail(new PullRequestLinkFailedError({ operation, detail: Cause.pretty(cause) })); + : Effect.fail(new PullRequestLinkFailedError({ operation, cause })); return PullRequestsToolkit.of({ link_pull_request: (input) => @@ -205,7 +199,7 @@ const make = Effect.gen(function* () { Effect.as(false), // The decider rejects a second link of the same PR; for the agent that is // the outcome it asked for, not an error. - Effect.catchTag("OrchestrationCommandInvariantError", () => Effect.succeed(true)), + Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(true) }), Effect.catchCause(dispatchFailure("link")), ); return { ...target, alreadyLinked }; @@ -226,7 +220,7 @@ const make = Effect.gen(function* () { }) .pipe( Effect.as(true), - Effect.catchTag("OrchestrationCommandInvariantError", () => Effect.succeed(false)), + Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(false) }), Effect.catchCause(dispatchFailure("unlink")), ); return { diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 2321fdeb591..9622d2d42c6 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -73,10 +73,10 @@ export class PullRequestThreadNotFoundError extends Schema.TaggedError()( "PullRequestLinkFailedError", - { operation: Schema.Literals(["link", "unlink", "list"]), detail: Schema.String }, + { operation: Schema.Literals(["link", "unlink", "list"]), cause: Schema.Defect() }, ) { override get message(): string { - return `Could not ${this.operation} the pull request: ${this.detail}`; + return `Could not ${this.operation} the pull request.`; } } diff --git a/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts index 216942f085a..df5f017f36f 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts @@ -15,7 +15,6 @@ import { ListProjectionThreadPullRequestsInput, ProjectionThreadPullRequest, ProjectionThreadPullRequestRepository, - type ProjectionThreadPullRequestRepositoryShape, } from "../Services/ProjectionThreadPullRequests.ts"; const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields( @@ -133,19 +132,21 @@ const makeProjectionThreadPullRequestRepository = Effect.gen(function* () { `, }); - const upsert: ProjectionThreadPullRequestRepositoryShape["upsert"] = (row) => + const upsert: ProjectionThreadPullRequestRepository["Service"]["upsert"] = (row) => upsertProjectionThreadPullRequestRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query")), ); - const listByThreadId: ProjectionThreadPullRequestRepositoryShape["listByThreadId"] = (input) => + const listByThreadId: ProjectionThreadPullRequestRepository["Service"]["listByThreadId"] = ( + input, + ) => listProjectionThreadPullRequestRows(input).pipe( Effect.mapError( toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"), ), ); - const listByPullRequest: ProjectionThreadPullRequestRepositoryShape["listByPullRequest"] = ( + const listByPullRequest: ProjectionThreadPullRequestRepository["Service"]["listByPullRequest"] = ( input, ) => listProjectionThreadPullRequestRowsByPullRequest(input).pipe( @@ -154,12 +155,12 @@ const makeProjectionThreadPullRequestRepository = Effect.gen(function* () { ), ); - const deleteLink: ProjectionThreadPullRequestRepositoryShape["delete"] = (input) => + const deleteLink: ProjectionThreadPullRequestRepository["Service"]["delete"] = (input) => deleteProjectionThreadPullRequestRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query")), ); - const deleteByThreadId: ProjectionThreadPullRequestRepositoryShape["deleteByThreadId"] = ( + const deleteByThreadId: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadId"] = ( input, ) => deleteProjectionThreadPullRequestRows(input).pipe( @@ -168,7 +169,7 @@ const makeProjectionThreadPullRequestRepository = Effect.gen(function* () { ), ); - const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepositoryShape["deleteByThreadIdAndSource"] = + const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadIdAndSource"] = (input) => deleteProjectionThreadPullRequestRowsBySource(input).pipe( Effect.mapError( @@ -185,7 +186,7 @@ const makeProjectionThreadPullRequestRepository = Effect.gen(function* () { delete: deleteLink, deleteByThreadId, deleteByThreadIdAndSource, - } satisfies ProjectionThreadPullRequestRepositoryShape; + } satisfies ProjectionThreadPullRequestRepository["Service"]; }); export const ProjectionThreadPullRequestRepositoryLive = Layer.effect( diff --git a/apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts index 2a1b37a0a15..8f3eb7ac009 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadPullRequests.ts @@ -57,28 +57,26 @@ export const DeleteProjectionThreadPullRequestsBySourceInput = Schema.Struct({ export type DeleteProjectionThreadPullRequestsBySourceInput = typeof DeleteProjectionThreadPullRequestsBySourceInput.Type; -export interface ProjectionThreadPullRequestRepositoryShape { - readonly upsert: ( - row: ProjectionThreadPullRequest, - ) => Effect.Effect; - readonly listByThreadId: ( - input: ListProjectionThreadPullRequestsInput, - ) => Effect.Effect, ProjectionRepositoryError>; - readonly listByPullRequest: ( - input: ListProjectionThreadPullRequestsByPullRequestInput, - ) => Effect.Effect, ProjectionRepositoryError>; - readonly delete: ( - input: DeleteProjectionThreadPullRequestInput, - ) => Effect.Effect; - readonly deleteByThreadId: ( - input: DeleteProjectionThreadPullRequestsInput, - ) => Effect.Effect; - readonly deleteByThreadIdAndSource: ( - input: DeleteProjectionThreadPullRequestsBySourceInput, - ) => Effect.Effect; -} - export class ProjectionThreadPullRequestRepository extends Context.Service< ProjectionThreadPullRequestRepository, - ProjectionThreadPullRequestRepositoryShape + { + readonly upsert: ( + row: ProjectionThreadPullRequest, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadPullRequestsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByPullRequest: ( + input: ListProjectionThreadPullRequestsByPullRequestInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly delete: ( + input: DeleteProjectionThreadPullRequestInput, + ) => Effect.Effect; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadPullRequestsInput, + ) => Effect.Effect; + readonly deleteByThreadIdAndSource: ( + input: DeleteProjectionThreadPullRequestsBySourceInput, + ) => Effect.Effect; + } >()("t3/persistence/Services/ProjectionThreadPullRequests/ProjectionThreadPullRequestRepository") {} diff --git a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx index babf281841d..bbcb9e14419 100644 --- a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx +++ b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx @@ -22,9 +22,8 @@ export function LinkBranchPullRequestButton({ event.stopPropagation()} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4213f0bea3e..eea16cba862 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1429,14 +1429,15 @@ export function PullRequestDetailPanel({ - + } /> Back to pull requests @@ -1503,15 +1504,16 @@ export function PullRequestDetailPanel({ - + } /> Back to pull requests diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0e688db8376..6f3b1f5b09c 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -23,6 +23,7 @@ const buttonVariants = cva( "icon-lg": "size-10 sm:size-9", "icon-micro": "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", + "icon-tiny": "size-4 p-0 [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 sm:size-7", "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", From 61ace313b6b119fe10d97f210e740d0315b30e67 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 17:09:46 -0700 Subject: [PATCH 09/13] refactor(pull-requests): share inline actions and qualify service imports --- .../Layers/ProjectionPipeline.ts | 9 +- .../Layers/ProjectionThreadPullRequests.ts | 85 ++++++++++--------- apps/web/src/components/Sidebar.tsx | 10 +-- .../pullRequest/PullRequestStackMap.tsx | 3 +- apps/web/src/components/ui/button.tsx | 20 +++++ 5 files changed, 76 insertions(+), 51 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cbd3fc51905..e82b939e8ce 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -34,7 +34,7 @@ import { type ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; -import { ProjectionThreadPullRequestRepository } from "../../persistence/Services/ProjectionThreadPullRequests.ts"; +import * as ProjectionThreadPullRequests from "../../persistence/Services/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { type ProjectionTurn, @@ -47,7 +47,7 @@ import { ProjectionStateRepositoryLive } from "../../persistence/Layers/Projecti import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; -import { ProjectionThreadPullRequestRepositoryLive } from "../../persistence/Layers/ProjectionThreadPullRequests.ts"; +import * as ProjectionThreadPullRequestsLive from "../../persistence/Layers/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; @@ -498,7 +498,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; - const projectionThreadPullRequestRepository = yield* ProjectionThreadPullRequestRepository; + const projectionThreadPullRequestRepository = + yield* ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -2185,7 +2186,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), - Layer.provideMerge(ProjectionThreadPullRequestRepositoryLive), + Layer.provideMerge(ProjectionThreadPullRequestsLive.ProjectionThreadPullRequestRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), diff --git a/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts index df5f017f36f..96f4cffc06e 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadPullRequests.ts @@ -1,3 +1,4 @@ +import * as ProjectionThreadPullRequests from "../Services/ProjectionThreadPullRequests.ts"; import { ThreadPullRequestSnapshot, ThreadPullRequestStack } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -14,7 +15,6 @@ import { ListProjectionThreadPullRequestsByPullRequestInput, ListProjectionThreadPullRequestsInput, ProjectionThreadPullRequest, - ProjectionThreadPullRequestRepository, } from "../Services/ProjectionThreadPullRequests.ts"; const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields( @@ -132,44 +132,47 @@ const makeProjectionThreadPullRequestRepository = Effect.gen(function* () { `, }); - const upsert: ProjectionThreadPullRequestRepository["Service"]["upsert"] = (row) => - upsertProjectionThreadPullRequestRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query")), - ); - - const listByThreadId: ProjectionThreadPullRequestRepository["Service"]["listByThreadId"] = ( - input, - ) => - listProjectionThreadPullRequestRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"), - ), - ); - - const listByPullRequest: ProjectionThreadPullRequestRepository["Service"]["listByPullRequest"] = ( - input, - ) => - listProjectionThreadPullRequestRowsByPullRequest(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByPullRequest:query"), - ), - ); - - const deleteLink: ProjectionThreadPullRequestRepository["Service"]["delete"] = (input) => - deleteProjectionThreadPullRequestRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query")), - ); - - const deleteByThreadId: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadId"] = ( - input, - ) => - deleteProjectionThreadPullRequestRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadPullRequestRepository.deleteByThreadId:query"), - ), - ); - - const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadIdAndSource"] = + const upsert: ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository["Service"]["upsert"] = + (row) => + upsertProjectionThreadPullRequestRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query"), + ), + ); + + const listByThreadId: ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository["Service"]["listByThreadId"] = + (input) => + listProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"), + ), + ); + + const listByPullRequest: ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository["Service"]["listByPullRequest"] = + (input) => + listProjectionThreadPullRequestRowsByPullRequest(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByPullRequest:query"), + ), + ); + + const deleteLink: ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository["Service"]["delete"] = + (input) => + deleteProjectionThreadPullRequestRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query"), + ), + ); + + const deleteByThreadId: ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository["Service"]["deleteByThreadId"] = + (input) => + deleteProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.deleteByThreadId:query"), + ), + ); + + const deleteByThreadIdAndSource: ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository["Service"]["deleteByThreadIdAndSource"] = (input) => deleteProjectionThreadPullRequestRowsBySource(input).pipe( Effect.mapError( @@ -186,10 +189,10 @@ const makeProjectionThreadPullRequestRepository = Effect.gen(function* () { delete: deleteLink, deleteByThreadId, deleteByThreadIdAndSource, - } satisfies ProjectionThreadPullRequestRepository["Service"]; + } satisfies ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository["Service"]; }); export const ProjectionThreadPullRequestRepositoryLive = Layer.effect( - ProjectionThreadPullRequestRepository, + ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository, makeProjectionThreadPullRequestRepository, ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9f13f780a34..31730da523b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -220,7 +220,7 @@ import { } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button } from "./ui/button"; +import { Button, InlineButton } from "./ui/button"; import { Input } from "./ui/input"; import { Combobox, @@ -1499,7 +1499,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Sidebar chrome follows the interface font; tabular digits keep the number from // reflowing as PR states stream in. A border rather than text-decoration, so the line // runs under the glyph as well as the number. - "inline-flex shrink-0 cursor-pointer items-center gap-0.5 border-b border-transparent text-xs tabular-nums hover:border-current", + "text-xs tabular-nums", variant === "slim" && variantAction === "unsettle" ? props.isActive ? "text-secondary-label" @@ -1514,8 +1514,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { prBadgeShape?.kind === "stack" ? ( // A stack is one thing with N layers; naming one of them would misrepresent it, so the // badge counts layers and opens the thread's pull-requests surface. - + ) : prStatus && pr ? ( onSelect(layer.number)} /> + onSelect(layer.number)} /> ) : ( ) diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 6f3b1f5b09c..4657a0b9a97 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -86,3 +86,23 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) { } export { Button, buttonVariants }; + +/** An inline action that keeps the geometry of surrounding text or a graph node. */ +export function InlineButton({ + className, + underline = false, + ...props +}: React.ComponentProps<"button"> & { underline?: boolean }) { + return ( +