From 3ab8402d3db4645647a187fd218bbe385cc6a123 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 13 Oct 2026 16:16:28 -0700 Subject: [PATCH] feat(at-work): integrate live Mergebot PRs and queue Clover wants work PRs, queue status, and lifecycle decisions to share Mergebot's live data. Read repository scope and origins from private host configuration; keep workplace identifiers and credentials outside the patch. Exchange the environment's GitHub credential for a Mergebot session and share one authenticated WebSocket. Accept verified snapshots, invalidate disconnected or superseded heads, distinguish check reruns, and release unused detail subscriptions. Configured work reads never fall back to GitHub or its cached PR data. Stream queue badges to connected clients. Open Mergebot's own PR and queue interface inside T3's Browser on web, desktop, and mobile, preserving chat ownership, tab reuse, and normal close behavior. Standalone views do not create conversations. Native operations Mergebot does not expose direct the reader to its integrated interface. Assisted-by: gpt-6.1-sol --- .../src/native/useOpenPullRequest.test.ts | 126 ++++ apps/mobile/src/native/useOpenPullRequest.ts | 111 ++- apps/server/src/auth/RpcAuthorization.test.ts | 6 + apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/git/GitManager.test.ts | 79 +++ apps/server/src/git/GitManager.ts | 130 +++- .../server/src/mergebot/MergebotQueue.test.ts | 667 ++++++++++++++++++ apps/server/src/mergebot/MergebotQueue.ts | 582 +++++++++++++++ apps/server/src/mergebot/pullRequestRead.ts | 60 ++ .../src/observability/RpcInstrumentation.ts | 2 + .../PullRequestSyncReactor.test.ts | 35 + .../PullRequestSyncReactor.ts | 25 +- .../src/orchestration-v2/runtimeLayer.test.ts | 353 ++++----- .../pullRequest/PullRequestService.test.ts | 101 +++ .../src/pullRequest/PullRequestService.ts | 237 ++++++- apps/server/src/server.ts | 4 + apps/server/src/ws.ts | 5 + apps/web/src/components/ChatView.tsx | 6 + .../pullRequest/MergebotBrowserPanel.test.tsx | 255 +++++++ .../pullRequest/MergebotBrowserPanel.tsx | 177 +++++ .../pullRequest/MergebotQueueBadge.test.tsx | 106 +++ .../pullRequest/MergebotQueueBadge.tsx | 66 ++ .../PullRequestDetailPanel.test.tsx | 102 ++- .../pullRequest/PullRequestDetailPanel.tsx | 80 ++- .../pullRequest/ThreadPullRequestsPanel.tsx | 8 + .../src/components/sidebar/SidebarChrome.tsx | 52 +- .../sidebar/SidebarMergebotButton.test.tsx | 134 ++++ .../sidebar/SidebarMergebotButton.tsx | 94 +++ .../src/hooks/useOpenPanelPullRequestUrl.ts | 26 +- .../lib/openMergebotPullRequestLink.test.ts | 108 +++ apps/web/src/lib/openPullRequestLink.ts | 50 +- apps/web/src/rightPanelStore.test.ts | 51 ++ apps/web/src/rightPanelStore.ts | 29 + apps/web/src/routes/_chat.pull-requests.tsx | 147 +++- apps/web/src/state/mergebot.test.tsx | 102 +++ apps/web/src/state/mergebot.ts | 14 + packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/pullRequests.test.ts | 44 ++ .../client-runtime/src/state/pullRequests.ts | 9 + packages/contracts/src/index.ts | 3 +- packages/contracts/src/mergebot.test.ts | 158 +++++ packages/contracts/src/mergebot.ts | 156 ++++ packages/contracts/src/rpc.ts | 18 + packages/contracts/src/server.ts | 2 + 44 files changed, 4228 insertions(+), 295 deletions(-) create mode 100644 apps/mobile/src/native/useOpenPullRequest.test.ts create mode 100644 apps/server/src/mergebot/MergebotQueue.test.ts create mode 100644 apps/server/src/mergebot/MergebotQueue.ts create mode 100644 apps/server/src/mergebot/pullRequestRead.ts create mode 100644 apps/web/src/components/pullRequest/MergebotBrowserPanel.test.tsx create mode 100644 apps/web/src/components/pullRequest/MergebotBrowserPanel.tsx create mode 100644 apps/web/src/components/pullRequest/MergebotQueueBadge.test.tsx create mode 100644 apps/web/src/components/pullRequest/MergebotQueueBadge.tsx create mode 100644 apps/web/src/components/sidebar/SidebarMergebotButton.test.tsx create mode 100644 apps/web/src/components/sidebar/SidebarMergebotButton.tsx create mode 100644 apps/web/src/lib/openMergebotPullRequestLink.test.ts create mode 100644 apps/web/src/state/mergebot.test.tsx create mode 100644 apps/web/src/state/mergebot.ts create mode 100644 packages/contracts/src/mergebot.test.ts create mode 100644 packages/contracts/src/mergebot.ts diff --git a/apps/mobile/src/native/useOpenPullRequest.test.ts b/apps/mobile/src/native/useOpenPullRequest.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4fbb811e3041dca461c81f337f556034db21a3c --- /dev/null +++ b/apps/mobile/src/native/useOpenPullRequest.test.ts @@ -0,0 +1,126 @@ +import { AsyncResult } from "effect/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + navigate: vi.fn(), + dispatch: vi.fn(), + external: vi.fn(), + alert: vi.fn(), + open: vi.fn(), + list: vi.fn(), + serverBrowser: true, + allowed: true, + params: { environmentId: "environment", projectId: "project", threadId: "thread" } as Record< + string, + string + >, +})); +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useRef: (current: unknown) => ({ current }), +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ navigate: state.navigate, dispatch: state.dispatch }), + useRoute: () => ({ params: state.params }), + StackActions: {}, + CommonActions: {}, +})); +vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Alert: { alert: state.alert } })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => + new Map([ + [ + "environment", + { + mergebot: { origin: "https://mergebot.example", repositories: ["Work/App"] }, + environment: { capabilities: { serverBrowser: state.serverBrowser } }, + }, + ], + ]), +})); +vi.mock("../state/entities", () => ({ useThreadShell: () => null })); +vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); +vi.mock("../state/session", () => ({ readEnvironmentScope: () => state.allowed })); +vi.mock("../state/preview", () => ({ previewEnvironment: { open: {}, list: {} } })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.open })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.list })); +vi.mock("../lib/openExternalUrl", () => ({ tryOpenExternalUrl: state.external })); +import { useOpenPullRequest } from "./useOpenPullRequest"; + +beforeEach(() => { + state.params = { environmentId: "environment", projectId: "project", threadId: "thread" }; + state.serverBrowser = true; + state.allowed = true; + state.navigate.mockReset(); + state.dispatch.mockReset(); + state.alert.mockReset(); + state.external.mockReset(); + state.open.mockReset().mockResolvedValue(AsyncResult.success({ tabId: "opened" })); + state.list.mockReset().mockResolvedValue(AsyncResult.success({ sessions: [] })); +}); +describe("Mergebot PR navigation", () => { + it.each(["https://github.com/work/app/pull/12", "https://mergebot.example/work/app/pull/12"])( + "opens %s in the existing in-app browser with chat ownership", + async (url) => { + await expect(useOpenPullRequest()(url)).resolves.toBe(true); + expect(state.open).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + threadId: "thread", + runtime: "server", + url: "https://mergebot.example/Work/App/pull/12", + }), + }), + ); + expect(state.navigate).toHaveBeenCalledExactlyOnceWith("ThreadBrowserPreview", { + environmentId: "environment", + threadId: "thread", + tabId: "opened", + }); + expect(state.external).not.toHaveBeenCalled(); + }, + ); + it("reuses a matching server tab and supplies a stable owner without a chat", async () => { + delete state.params.threadId; + state.list.mockResolvedValue( + AsyncResult.success({ + sessions: [ + { + tabId: "existing", + runtime: "server", + navStatus: { _tag: "Success", url: "https://mergebot.example/Work/App/pull/12" }, + }, + ], + }), + ); + await useOpenPullRequest()("https://github.com/work/app/pull/12"); + expect(state.open).not.toHaveBeenCalled(); + expect(state.navigate).toHaveBeenCalledWith("ThreadBrowserPreview", { + environmentId: "environment", + threadId: "mergebot-browser", + tabId: "existing", + }); + }); + it("shows unavailable browser access without silently opening native work details", async () => { + state.serverBrowser = false; + await expect(useOpenPullRequest()("https://github.com/work/app/pull/12")).resolves.toBe(true); + expect(state.alert).toHaveBeenCalledOnce(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(state.external).not.toHaveBeenCalled(); + }); + it("reports a recognized link failure once and marks it handled for callers", async () => { + state.open.mockRejectedValueOnce(new Error("offline")); + const handled = await useOpenPullRequest()("https://mergebot.example/work/app/pull/12"); + if (!handled) state.alert("Generic PR failure"); + expect(handled).toBe(true); + expect(state.alert).toHaveBeenCalledExactlyOnceWith("Could not open Mergebot", "offline"); + }); + it("leaves unrelated repositories on the existing native PR path", async () => { + await useOpenPullRequest()("https://github.com/other/repo/pull/12"); + expect(state.open).not.toHaveBeenCalled(); + expect(state.navigate).toHaveBeenCalledWith( + "PullRequest", + expect.objectContaining({ repository: "other/repo", host: "github.com", number: 12 }), + ); + }); +}); diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index fb34417c977409c9c2761afed7b591186494811c..126007166eab91b65ce6b6b73279dc0563d26cf4 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -1,11 +1,30 @@ import { useNavigation, useRoute, StackActions } from "@react-navigation/native"; import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; -import { useCallback } from "react"; -import { Platform } from "react-native"; +import { useCallback, useRef } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { Alert, Platform } from "react-native"; import { CommonActions } from "@react-navigation/native"; import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + AuthPreviewOperateScope, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + MERGEBOT_BROWSER_THREAD_ID, + ThreadId, + mergebotPullRequestUrl, + parseMergebotPullRequestUrl, +} from "@t3tools/contracts"; import { useThreadShell } from "../state/entities"; +import { previewEnvironment } from "../state/preview"; +import { environmentServerConfigsAtom } from "../state/server"; +import { readEnvironmentScope } from "../state/session"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; /** Use the thread's environment; the shared router resolves the repository's project there. */ @@ -36,8 +55,83 @@ export function useOpenPullRequest(options?: { const projectId = options?.context?.projectId ?? first(routeContext?.projectId) ?? thread?.projectId; const replace = options?.replace; + const configurations = useAtomValue(environmentServerConfigsAtom); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const listPreviews = useAtomQueryRunner(previewEnvironment.list, { + refresh: true, + reportFailure: false, + }); + const busy = useRef(false); return useCallback( async (url: string) => { + const id = environmentId ? EnvironmentId.make(environmentId) : null; + const configuration = id ? configurations.get(id) : undefined; + const parsed: ChangeRequestLink | null = + parseMergebotPullRequestUrl(configuration?.mergebot, url) ?? parseChangeRequestUrl(url); + const mergebotUrl = parsed + ? mergebotPullRequestUrl(configuration?.mergebot, { + host: parsed.authority ?? parsed.host, + repository: parsed.repository, + number: parsed.number, + }) + : null; + if (id && mergebotUrl) { + if (busy.current) return true; + if ( + !configuration?.environment.capabilities.serverBrowser || + !readEnvironmentScope(id, AuthPreviewOperateScope) + ) { + Alert.alert( + "Mergebot browser unavailable", + "Connect with browser access to an environment that can open Mergebot.", + ); + return true; + } + busy.current = true; + try { + const owner = ThreadId.make(threadId ?? MERGEBOT_BROWSER_THREAD_ID); + const listed = await listPreviews({ environmentId: id, input: { threadId: owner } }); + if (listed._tag === "Failure") { + if (isAtomCommandInterrupted(listed)) return true; + throw squashAtomCommandFailure(listed); + } + const matching = listed.value.sessions.find( + (session) => + session.runtime === "server" && + session.navStatus._tag !== "Idle" && + session.navStatus.url === mergebotUrl, + ); + const opened = matching + ? null + : await openPreview({ + environmentId: id, + input: { + threadId: owner, + url: mergebotUrl, + runtime: "server", + viewport: FILL_PREVIEW_VIEWPORT, + }, + }); + if (opened?._tag === "Failure") { + if (isAtomCommandInterrupted(opened)) return true; + throw squashAtomCommandFailure(opened); + } + navigation.navigate("ThreadBrowserPreview", { + environmentId: id, + threadId: owner, + tabId: matching?.tabId ?? (opened?._tag === "Success" ? opened.value.tabId : undefined), + }); + return true; + } catch (error) { + Alert.alert( + "Could not open Mergebot", + error instanceof Error ? error.message : "Try opening the pull request again.", + ); + return true; + } finally { + busy.current = false; + } + } const params = nativePullRequestDestination( url, environmentId && projectId @@ -55,6 +149,15 @@ export function useOpenPullRequest(options?: { else navigation.navigate("PullRequest", params); return true; }, - [environmentId, projectId, navigation, replace], + [ + configurations, + environmentId, + listPreviews, + navigation, + openPreview, + projectId, + replace, + threadId, + ], ); } diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 6c852ffb9a7a9d2337df9520cd38d977cf66c2ba..92d1ec94d98b15a786c1d139f7d667199790fd48 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -165,6 +165,12 @@ describe("RPC authorization scopes", () => { ); }); + it("requires orchestration reads for Mergebot snapshots and live updates", () => { + for (const method of [WS_METHODS.mergebotQueue, WS_METHODS.mergebotSubscribe]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + }); + it("separates preview control from observation", () => { for (const method of [ WS_METHODS.previewOpen, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 41e065d51503f324c6ed3cb5c45b01ebd3de21a2..ec3fc4bc531b21168461510cc75b40387d1361b7 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -123,6 +123,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.mergebotQueue]: AuthOrchestrationReadScope, + [WS_METHODS.mergebotSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index df6a742a54a47f8264d159e5eb1cacd90c17cc0c..87a95fa8eb047c32bbaa969c2b9555f2bf024bf2 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -27,6 +27,7 @@ import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { ChangeRequest, + MergebotPullRequestDetails, GitActionProgressEvent, GitManagerServiceError, GitPreparePullRequestThreadInput, @@ -73,6 +74,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; import * as GitManager from "./GitManager.ts"; const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -723,6 +725,7 @@ function preparePullRequestThread( } function makeManager(input?: { + mergebot?: MergebotQueue.MergebotQueue["Service"]; ghScenario?: FakeGhScenario; sourceControlProvider?: SourceControlProvider["Service"]; textGeneration?: Partial; @@ -837,6 +840,9 @@ function makeManager(input?: { ); const layerManager = Layer.mergeAll( + input?.mergebot + ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot) + : Layer.mock(MergebotQueue.MergebotQueue)({ configuration: null }), Layer.succeed(TextGeneration.TextGeneration, textGeneration), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), @@ -880,6 +886,79 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( ); it.layer(layerGitManagerTest)("GitManager", (it) => { + it.effect( + "work branch lookups and PR resolution use live Mergebot without GitHub cache fallback", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-work-pr-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["remote", "add", "origin", "https://github.com/work/app.git"]); + yield* runGit(repoDir, ["checkout", "-b", "feature"]); + yield* runGit(repoDir, ["update-ref", "refs/remotes/origin/feature", "HEAD"]); + yield* runGit(repoDir, ["config", "branch.feature.remote", "origin"]); + yield* runGit(repoDir, ["config", "branch.feature.merge", "refs/heads/feature"]); + let value: MergebotPullRequestDetails | null = { + number: 42, + headSha: "head", + lifecycle: "open", + queueStatus: "waiting_ci", + position: 1, + mergeEtaAt: null, + title: "Ship change", + body: null, + url: "https://mergebot.example/work/app/pull/42", + createdAt: "2026-10-14T10:00:00Z", + updatedAt: "2026-10-14T10:01:00Z", + author: null, + additions: 3, + deletions: 1, + changedFilesCount: 1, + labels: [], + comments: [], + observedAt: 1791972060000, + draft: false, + baseBranch: "main", + headBranch: "feature", + ciStatus: "pending", + hasConflicts: null, + checks: [], + }; + const { manager, ghCalls } = yield* makeManager({ + mergebot: { + configuration: { origin: "https://mergebot.example", repositories: ["work/app"] }, + read: () => Effect.succeed(null), + watchQueue: () => Stream.empty, + streamChanges: Stream.empty, + readPullRequest: () => Effect.sync(() => value), + findPullRequest: () => Effect.sync(() => value), + }, + }); + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject( + { number: 42, title: "Ship change", repositoryKey: "github.com/work/app" }, + ); + value = { + ...value!, + title: "Live update", + lifecycle: "merged", + mergedAt: "2026-10-14T10:03:00Z", + }; + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject( + { title: "Live update", state: "merged" }, + ); + expect( + (yield* manager.resolvePullRequest({ cwd: repoDir, reference: "42" })).pullRequest.title, + ).toBe("Live update"); + expect( + (yield* manager + .preparePullRequestThread({ cwd: repoDir, reference: "42", mode: "local" }) + .pipe(Effect.flip)).message, + ).toContain("Mergebot"); + value = null; + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + it.effect("passive worktree status streams do not start remote refreshes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-passive-vcs-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c4ab7bb9f4b1452955f8b07a6771b14e2df1b5da..e31cc1b8befe3ed017d5d9c16b6e7495024e4d5e 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,4 +1,4 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { SourceControlProviderKind, type MergebotPullRequestDetails } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; @@ -68,6 +68,7 @@ import { } from "@t3tools/shared/threadPullRequests"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { conventionalCommitsTextGenerationPolicy, @@ -722,6 +723,7 @@ function toPullRequestHeadRemoteInfo(pr: { export const make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; + const mergebot = yield* MergebotQueue.MergebotQueue; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const textGeneration = yield* TextGeneration.TextGeneration; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -730,6 +732,28 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const mergebotRepository = (target: string | null) => + target === null + ? undefined + : mergebot.configuration?.repositories.find( + (repository) => + canonicalRepositoryKey(`github.com/${repository}`) === canonicalRepositoryKey(target), + ); + + const mergebotPrInfo = (value: MergebotPullRequestDetails): PullRequestInfo => ({ + number: value.number, + title: value.title, + url: value.url, + state: value.lifecycle, + baseRefName: value.baseBranch, + headRefName: value.headBranch, + headSha: value.headSha, + isDraft: value.draft, + updatedAt: DateTime.make(value.updatedAt), + ...(value.mergedAt === undefined ? {} : { mergedAt: value.mergedAt }), + ...(value.closedAt === undefined ? {} : { closedAt: value.closedAt }), + }); + const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; const threads = yield* ProjectionStore.ProjectionStoreV2; @@ -1179,9 +1203,9 @@ export const make = Effect.gen(function* () { // this lookup, so a merge it sees must reach the thread's link and settlement right away. const pullRequestStateChanges = yield* PubSub.sliding(64); const lookupStates = new Map(); - const noteLookupState = (pr: PullRequestInfo) => + const noteLookupState = (pr: PullRequestInfo, reference?: ThreadPullRequestKey) => Effect.suspend(() => { - const parsed = parseChangeRequestUrl(pr.url); + const parsed = reference ?? parseChangeRequestUrl(pr.url); if (parsed === null || parsed.number !== pr.number) return Effect.void; const key = normalizeThreadPullRequestKey(parsed); const id = threadPullRequestKeyOf(key); @@ -1244,7 +1268,27 @@ export const make = Effect.gen(function* () { }, }, ); - const getPrLookup = (key: string) => detachStackFrame(Cache.get(prLookupCache, key)); + const getPrLookup = Effect.fnUntraced(function* (key: string) { + if (mergebot.configuration !== null) { + const [cwd = "", branch = "", upstreamRef = "", defaultBranch = "", , remoteName = ""] = + key.split("\u0000"); + const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, { + branch, + upstreamRef: upstreamRef || null, + defaultBranch: defaultBranch || null, + ...(remoteName ? { remoteName } : {}), + }); + const repository = mergebotRepository(headContext.targetRemoteUrlKey); + if (repository !== undefined) { + const latest = lookup ? yield* findLatestPrForHeadContext(cwd, headContext) : null; + if (latest !== null) + yield* noteLookupState(latest, { host: "github.com", repository, number: latest.number }); + return { latest, headContext }; + } + if (headContext.targetRemoteUrlKey === null) return { latest: null, headContext }; + } + return yield* detachStackFrame(Cache.get(prLookupCache, key)); + }); // A transient lookup failure (rate limit, network blip) must not clear an // already-known PR badge, so the last successful answer per branch sticks // around as the fallback. Keep the resolved head context with it so a @@ -1827,6 +1871,18 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { + if (mergebot.configuration !== null) { + const origin = yield* resolveRemoteRepositoryContext(cwd, "origin"); + const repository = mergebotRepository(origin.remoteUrlKey); + if (repository !== undefined) { + if (headContext.isCrossRepository) return null; + const value = yield* mergebot.findPullRequest({ + repository, + headBranch: headContext.headBranch, + }); + return value === null || value.lifecycle !== "open" ? null : mergebotPrInfo(value); + } + } const provider = yield* sourceControlProvider(cwd); const probe = headBranchProbe(provider, headContext.headSelectors, "open"); for (const headSelector of probe.headSelectors) { @@ -1857,6 +1913,15 @@ export const make = Effect.gen(function* () { cwd: string, headContext: BranchHeadContext, ) { + const repository = mergebotRepository(headContext.targetRemoteUrlKey); + if (repository !== undefined) { + if (headContext.isCrossRepository) return null; + const value = yield* mergebot.findPullRequest({ + repository, + headBranch: headContext.headBranch, + }); + return value === null ? null : mergebotPrInfo(value); + } const parsedByNumber = new Map(); const provider = yield* sourceControlProvider(cwd); @@ -2604,6 +2669,7 @@ export const make = Effect.gen(function* () { } } const { latest } = cached; + const workRepository = mergebotRepository(cached.headContext.targetRemoteUrlKey); if (latest === null) return null; if ( (branch === defaultBranch || @@ -2619,7 +2685,10 @@ export const make = Effect.gen(function* () { headSha: latest.headSha ?? null, // Hosting CLIs can select an upstream repository instead of origin. // The returned PR URL names the repository that actually owns it. - repositoryKey: pullRequestRepositoryKey(latest.url), + repositoryKey: + workRepository === undefined + ? pullRequestRepositoryKey(latest.url) + : canonicalRepositoryKey(`github.com/${workRepository}`), }; }); const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( @@ -2663,6 +2732,48 @@ export const make = Effect.gen(function* () { const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( "resolvePullRequest", )(function* (input) { + if (mergebot.configuration !== null) { + const origin = yield* resolveRemoteRepositoryContext(input.cwd, "origin"); + const repository = mergebotRepository(origin.remoteUrlKey); + if (repository !== undefined) { + const normalized = normalizePullRequestReference(input.reference); + let number = /^\d+$/.test(normalized) ? Number(normalized) : null; + if (number === null) { + const parsed = parseChangeRequestUrl(normalized); + if ( + parsed?.host === "github.com" && + parsed.repository.toLowerCase() === repository.toLowerCase() + ) + number = parsed.number; + else { + const parsedUrl = URL.canParse(normalized) ? new URL(normalized) : null; + const parts = parsedUrl?.pathname.split("/").filter(Boolean); + if ( + parsedUrl?.origin === mergebot.configuration.origin && + parts?.length === 4 && + `${parts[0]}/${parts[1]}`.toLowerCase() === repository.toLowerCase() && + parts[2] === "pull" && + /^\d+$/.test(parts[3]!) + ) + number = Number(parts[3]); + } + } + if (number === null || !Number.isSafeInteger(number) || number < 1) + return yield* new GitManagerError({ + operation: "resolvePullRequest", + cwd: input.cwd, + detail: "Choose a pull request from this repository in Mergebot.", + }); + const value = yield* mergebot.readPullRequest({ repository, number }); + if (value === null) + return yield* new GitManagerError({ + operation: "resolvePullRequest", + cwd: input.cwd, + detail: "Mergebot is unavailable. Try again after it reconnects.", + }); + return { pullRequest: toResolvedPullRequest(mergebotPrInfo(value)) }; + } + } const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) .getChangeRequest({ cwd: input.cwd, @@ -2676,6 +2787,15 @@ export const make = Effect.gen(function* () { const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { + if (mergebot.configuration !== null) { + const origin = yield* resolveRemoteRepositoryContext(input.cwd, "origin"); + if (mergebotRepository(origin.remoteUrlKey) !== undefined) + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: "Open this pull request in Mergebot to choose its workspace.", + }); + } const maybeRunSetupScript = (worktreePath: string) => { if (!input.threadId) { return Effect.void; diff --git a/apps/server/src/mergebot/MergebotQueue.test.ts b/apps/server/src/mergebot/MergebotQueue.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac7e688749ab3754779ce3e75981a9086e9d2f99 --- /dev/null +++ b/apps/server/src/mergebot/MergebotQueue.test.ts @@ -0,0 +1,667 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Stream from "effect/Stream"; +import * as Fiber from "effect/Fiber"; +import * as Deferred from "effect/Deferred"; +import * as TestClock from "effect/testing/TestClock"; +import * as Socket from "effect/socket/Socket"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as MergebotQueue from "./MergebotQueue.ts"; + +const repository = "work/app"; +const url = `https://mergebot.example/${repository}`; +const configuration = { origin: "https://mergebot.example", repositories: [repository] }; +const pr = { + id: `${repository}#123`, + repoId: repository, + number: 123, + authorId: null, + createdAt: "2026-10-14T10:00:00Z", + additions: 3, + deletions: 1, + changedFilesCount: 2, + labels: [], + title: "Ship change", + updatedAt: "2026-10-14T10:02:00Z", + body: null, + draft: false, + headBranch: "feature", + baseBranch: "main", + headSha: "current-head", + lifecycle: "open", + queueStatus: "waiting_ci", + mergeEtaAt: null, + ciStatus: "pending", + hasConflicts: false, + url: "https://github.com/work/app/pull/123", +}; +const check = { + id: "check:1", + name: "Tests", + workflowName: "CI", + status: "completed", + conclusion: "failure", + url: null, + startedAt: "2026-10-14T10:00:00Z", + completedAt: "2026-10-14T10:01:00Z", +}; +const repoChannel = { kind: "repo_overview", repoId: repository }; +const detailChannel = { kind: "pull_request_detail", repoId: repository, prNumber: 123 }; +const repoSnapshot = { + observedAt: "2026-10-14T10:02:00Z", + type: "snapshot", + channel: repoChannel, + channelKey: `repo_overview:${repository}`, + payload: { + entities: { repos: { [repository]: { processing: true } }, pullRequests: { [pr.id]: pr } }, + view: { repoId: repository, queuePrIds: [pr.id] }, + }, +}; +const detailSnapshot = { + type: "snapshot", + channel: detailChannel, + channelKey: `pull_request_detail:${pr.id}`, + observedAt: "2026-10-14T10:02:00Z", + payload: { + entities: { pullRequests: { [pr.id]: pr }, users: {} }, + view: { + repoId: repository, + prId: pr.id, + comments: [], + checks: [check], + isLoadingDetails: false, + }, + }, +}; + +class TestSocket implements Socket.WebSocketLike { + readyState = 1; + readonly sent: unknown[] = []; + readonly listeners = new Map void>>(); + readonly onSubscribe: (socket: TestSocket, channel: { kind: string }) => void; + constructor(onSubscribe: (socket: TestSocket, channel: { kind: string }) => void) { + this.onSubscribe = onSubscribe; + } + addEventListener(type: string, listener: (event: Socket.WebSocketEvent) => void) { + const set = this.listeners.get(type) ?? new Set(); + set.add(listener); + this.listeners.set(type, set); + } + removeEventListener(type: string, listener: (event: Socket.WebSocketEvent) => void) { + this.listeners.get(type)?.delete(listener); + } + emit(type: string, event: Socket.WebSocketEvent) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + close() { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close", { code: 1000, reason: "Test close" }); + } + send(data: string | Uint8Array) { + const message = JSON.parse(String(data)); + this.sent.push(message); + if (message.type === "subscribe") this.onSubscribe(this, message.channel); + } + snapshot(value: unknown) { + this.emit("message", { data: JSON.stringify(value) }); + } +} + +function harness(configurationText: string | null = JSON.stringify(configuration)) { + let token: string | null = "test-github-token"; + let onSubscribe = (socket: TestSocket, channel: { kind: string }) => { + socket.snapshot(channel.kind === "repo_overview" ? repoSnapshot : detailSnapshot); + }; + const sockets: TestSocket[] = []; + const exchanges: Array<{ url: string; authorization: string | undefined }> = []; + let exchangeStatus = 200; + const requests: Array<{ url: string; options?: Socket.WebSocketConstructorOptions }> = []; + return { + sockets, + requests, + exchanges, + setExchangeStatus: (status: number) => { + exchangeStatus = status; + }, + setToken: (value: string | null) => { + token = value; + }, + subscribeWith: (handler: typeof onSubscribe) => { + onSubscribe = handler; + }, + layer: MergebotQueue.layerWithSocketConstructor.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + exchanges.push({ url: request.url, authorization: request.headers.authorization }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ token: "sealed-mergebot-session" }, { status: exchangeStatus }), + ), + ); + }), + ), + Layer.succeed(HostProcess.HomeDirectory, "/test"), + Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop( + configurationText === null + ? {} + : { + readFileString: () => Effect.succeed(configurationText), + }, + ), + ), + Layer.succeed(GitHubCredentials.GitHubCredentials, { + get: (host: string) => + token === null + ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) + : Effect.succeed({ + host, + token: Redacted.make(token), + fingerprint: token, + source: "gh" as const, + }), + invalidate: () => Effect.void, + }), + Layer.succeed(Socket.WebSocketConstructor, (url, options) => { + requests.push({ url, ...(options ? { options } : {}) }); + const socket = new TestSocket((...args) => onSubscribe(...args)); + sockets.push(socket); + return socket; + }), + ), + ), + ), + }; +} + +const nextChange = (service: MergebotQueue.MergebotQueue["Service"]) => + Stream.runHead(service.streamChanges).pipe(Effect.forkScoped({ startImmediately: true })); + +describe("MergebotQueue", () => { + it.effect.each([ + null, + "not JSON", + JSON.stringify({ ...configuration, origin: "http://mergebot.example" }), + JSON.stringify({ ...configuration, origin: "https://user:password@mergebot.example" }), + JSON.stringify({ ...configuration, repositories: ["work/app/extra"] }), + ])("disables malformed private configuration: %s", (text) => { + const h = harness(text); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + expect(service.configuration).toBeNull(); + expect(yield* service.read({ repository })).toBeNull(); + expect(h.requests).toHaveLength(0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect( + "shares one authenticated stream, derives queue position and never exposes the credential", + () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + const [a, b] = yield* Effect.all( + [service.read({ repository }), service.read({ repository: "WORK/APP" })], + { concurrency: "unbounded" }, + ); + expect(a).toEqual(b); + expect(a?.pullRequests?.[0]).toMatchObject({ + number: 123, + position: 1, + url: `${url}/pull/123`, + }); + expect(h.requests).toEqual([ + { + url: "wss://mergebot.example/api/state/ws", + options: { headers: { Authorization: "Bearer sealed-mergebot-session" } }, + }, + ]); + expect(JSON.stringify(a)).not.toContain("test-github-token"); + expect(h.sockets[0]?.sent).toHaveLength(1); + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + headSha: "current-head", + checks: [check], + }); + expect(h.requests).toHaveLength(1); + expect(h.exchanges).toEqual([ + { + url: "https://mergebot.example/api/auth/github/token", + authorization: "Bearer test-github-token", + }, + ]); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("missing credentials and unapproved repositories never open a socket", () => { + const h = harness(); + h.setToken(null); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + expect(yield* service.read({ repository: "other/app" })).toBeNull(); + expect(yield* service.read({ repository })).toBeNull(); + expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); + expect(h.requests).toHaveLength(0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("cached, loading and unverified detail snapshots cannot satisfy a fresh read", () => { + const h = harness(); + h.subscribeWith((socket, channel) => { + if (channel.kind === "repo_overview") socket.snapshot(repoSnapshot); + else { + socket.snapshot({ ...detailSnapshot, observedAt: null }); + socket.snapshot({ ...detailSnapshot, observedAt: null }); + socket.snapshot({ + ...detailSnapshot, + payload: { + ...detailSnapshot.payload, + view: { ...detailSnapshot.payload.view, isLoadingDetails: true }, + }, + }); + socket.snapshot({ + ...detailSnapshot, + payload: { + ...detailSnapshot.payload, + entities: { users: {}, pullRequests: { [pr.id]: { ...pr, headSha: "verified-head" } } }, + }, + }); + } + }); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + headSha: "verified-head", + position: null, + }); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("live head changes replace checks and reruns preserve a new failure identity", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + const changed = yield* nextChange(service); + const rerun = { ...check, id: "check:2", completedAt: "2026-10-14T10:05:00Z" }; + h.sockets[0]!.snapshot({ + ...detailSnapshot, + payload: { + ...detailSnapshot.payload, + entities: { + users: {}, + pullRequests: { [pr.id]: { ...pr, headSha: "new-head", ciStatus: "failed" } }, + }, + view: { ...detailSnapshot.payload.view, checks: [rerun] }, + }, + }); + yield* Fiber.join(changed); + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + headSha: "new-head", + ciStatus: "failed", + checks: [rerun], + }); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect( + "disconnect invalidates every snapshot and reconnect resubscribes before accepting fresh data", + () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.read({ repository }); + yield* service.readPullRequest({ repository, number: 123 }); + const changed = yield* nextChange(service); + h.sockets[0]!.close(); + yield* Fiber.join(changed); + expect(yield* service.read({ repository })).toBeNull(); + expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); + yield* TestClock.adjust("1 second"); + expect(h.requests).toHaveLength(2); + expect(h.sockets[1]?.sent).toHaveLength(2); + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + checks: [check], + }); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("failed initial reads publish unavailable once across authentication retries", () => { + const h = harness(); + h.setExchangeStatus(503); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + let changes = 0; + yield* service.streamChanges.pipe( + Stream.runForEach(() => Effect.sync(() => changes++)), + Effect.forkScoped({ startImmediately: true }), + ); + expect(yield* service.read({ repository })).toBeNull(); + yield* TestClock.adjust("3 seconds"); + expect(h.exchanges).toHaveLength(4); + expect(changes).toBe(1); + h.setExchangeStatus(200); + const recovered = yield* nextChange(service); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(recovered); + expect(yield* service.read({ repository })).toMatchObject({ status: "ready" }); + expect(changes).toBe(2); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("an outage invalidates ready channels once and publishes fresh recovery", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + const invalidated = yield* Deferred.make(); + let changes = 0; + yield* service.streamChanges.pipe( + Stream.runForEach(() => + Effect.gen(function* () { + changes++; + if (changes === 2) yield* Deferred.succeed(invalidated, undefined); + }), + ), + Effect.forkScoped({ startImmediately: true }), + ); + h.setExchangeStatus(503); + h.sockets[0]!.close(); + yield* Deferred.await(invalidated); + expect(yield* service.read({ repository })).toBeNull(); + expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); + yield* TestClock.adjust("3 seconds"); + expect(h.exchanges).toHaveLength(4); + expect(changes).toBe(2); + h.setExchangeStatus(200); + const recovered = yield* nextChange(service); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(recovered); + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + headSha: "current-head", + }); + expect(changes).toBe(4); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("an account change immediately invalidates the old account's snapshot", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + h.setToken("second-account-token"); + expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); + yield* TestClock.adjust("1 second"); + expect(h.exchanges[1]?.authorization).toBe("Bearer second-account-token"); + expect(h.requests[1]?.options).toEqual({ + headers: { Authorization: "Bearer sealed-mergebot-session" }, + }); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("channel errors and malformed frames invalidate previously fresh data", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + let changed = yield* nextChange(service); + h.sockets[0]!.snapshot({ + type: "error", + channelKey: detailSnapshot.channelKey, + message: "Refresh failed", + }); + yield* Fiber.join(changed); + expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); + changed = yield* nextChange(service); + h.sockets[0]!.snapshot({ malformed: true }); + yield* Fiber.join(changed); + expect(yield* service.read({ repository })).toBeNull(); + }).pipe(Effect.provide(h.layer)); + }); + it.effect.each([401, 403, 302, 503])( + "authentication status %s never opens a socket or reuses ready data", + (status) => { + const h = harness(); + h.setExchangeStatus(status); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + expect(yield* service.read({ repository })).toBeNull(); + expect(h.sockets).toHaveLength(0); + expect(h.exchanges).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("a missing verified snapshot has a bounded initial read", () => { + const h = harness(); + h.subscribeWith((socket, channel) => { + if (channel.kind === "repo_overview") socket.snapshot(repoSnapshot); + else socket.snapshot({ ...detailSnapshot, observedAt: null }); + }); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + const read = yield* service + .readPullRequest({ repository, number: 123 }) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(read)).toBeNull(); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("branch discovery uses observed Mergebot metadata and verified detail only", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + expect(yield* service.findPullRequest({ repository, headBranch: "feature" })).toMatchObject({ + number: 123, + headSha: "current-head", + }); + expect(yield* service.findPullRequest({ repository, headBranch: "other" })).toBeNull(); + expect(h.sockets[0]?.sent).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect( + "a new overview head invalidates prior detail until its own checks are refreshed", + () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + const changed = yield* nextChange(service); + h.sockets[0]!.snapshot({ + ...repoSnapshot, + observedAt: "2026-10-14T10:03:00Z", + payload: { + ...repoSnapshot.payload, + entities: { + ...repoSnapshot.payload.entities, + pullRequests: { [pr.id]: { ...pr, headSha: "next-head" } }, + }, + }, + }); + yield* Fiber.join(changed); + const read = yield* service + .readPullRequest({ repository, number: 123 }) + .pipe(Effect.forkScoped({ startImmediately: true })); + h.sockets[0]!.snapshot({ + ...detailSnapshot, + observedAt: "2026-10-14T10:03:00Z", + payload: { + ...detailSnapshot.payload, + entities: { users: {}, pullRequests: { [pr.id]: { ...pr, headSha: "next-head" } } }, + view: { ...detailSnapshot.payload.view, checks: [] }, + }, + }); + expect(yield* Fiber.join(read)).toMatchObject({ headSha: "next-head", checks: [] }); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("an older observation cannot replace a newer head or rerun", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + const changed = yield* nextChange(service); + h.sockets[0]!.snapshot({ + ...detailSnapshot, + observedAt: "2026-10-14T10:03:00Z", + payload: { + ...detailSnapshot.payload, + view: { ...detailSnapshot.payload.view, checks: [{ ...check, id: "check:2" }] }, + }, + }); + yield* Fiber.join(changed); + h.sockets[0]!.snapshot(detailSnapshot); + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + checks: [{ id: "check:2" }], + }); + }).pipe(Effect.provide(h.layer)); + }); + it.effect( + "queue subscribers receive initial state and live changes without a connection race", + () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + const initial = yield* Deferred.make(); + const values = yield* service.watchQueue({ repository }).pipe( + Stream.tap(() => Deferred.succeed(initial, undefined)), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ); + yield* Deferred.await(initial); + h.sockets[0]!.snapshot({ + ...repoSnapshot, + payload: { + ...repoSnapshot.payload, + entities: { + ...repoSnapshot.payload.entities, + repos: { [repository]: { processing: false } }, + }, + }, + }); + expect((yield* Fiber.join(values)).map((queue) => queue?.processing)).toEqual([ + true, + false, + ]); + expect(h.requests).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }, + ); + it.effect("later reads retry a failed channel without a reactive retry loop", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + const changed = yield* nextChange(service); + h.sockets[0]!.snapshot({ + type: "error", + channelKey: detailSnapshot.channelKey, + message: "Upstream unavailable", + }); + yield* Fiber.join(changed); + expect(yield* service.readPullRequest({ repository, number: 123 })).toBeNull(); + expect(h.sockets[0]?.sent).toHaveLength(2); + yield* TestClock.adjust("1 second"); + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + headSha: "current-head", + }); + expect(h.sockets[0]?.sent).toHaveLength(3); + expect(h.requests).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + it.effect( + "an account change during an initial read cannot return the previous account's result", + () => { + const h = harness(); + h.subscribeWith((socket, channel) => { + if (channel.kind === "repo_overview") socket.snapshot(repoSnapshot); + }); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.read({ repository }); + const read = yield* service + .readPullRequest({ repository, number: 123 }) + .pipe(Effect.forkScoped({ startImmediately: true })); + h.setToken("second-account-token"); + h.sockets[0]!.snapshot(detailSnapshot); + expect(yield* Fiber.join(read)).toBeNull(); + expect(h.sockets[0]?.readyState).toBe(3); + }).pipe(Effect.provide(h.layer)); + }, + ); + it.effect( + "idle detail channels release their source subscription and reacquire on demand", + () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + yield* TestClock.adjust("5 minutes"); + expect(h.sockets[0]?.sent.at(-1)).toEqual({ + type: "unsubscribe", + channelKey: detailSnapshot.channelKey, + }); + expect(yield* service.readPullRequest({ repository, number: 123 })).toMatchObject({ + headSha: "current-head", + }); + expect(h.sockets[0]?.sent.at(-1)).toEqual({ type: "subscribe", channel: detailChannel }); + expect(h.requests).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect("active detail reads renew the lease without replacing the shared stream", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.readPullRequest({ repository, number: 123 }); + yield* TestClock.adjust("4 minutes"); + yield* service.readPullRequest({ repository, number: 123 }); + yield* TestClock.adjust("2 minutes"); + expect(h.sockets[0]?.sent).toHaveLength(2); + expect(h.requests).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + it.effect( + "branch-only reads retry an unavailable overview channel without a UI subscriber", + () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* MergebotQueue.MergebotQueue; + yield* service.findPullRequest({ repository, headBranch: "feature" }); + const changed = yield* nextChange(service); + h.sockets[0]!.snapshot({ + type: "error", + channelKey: repoSnapshot.channelKey, + message: "Upstream unavailable", + }); + yield* Fiber.join(changed); + expect(yield* service.findPullRequest({ repository, headBranch: "feature" })).toBeNull(); + yield* TestClock.adjust("1 second"); + expect(yield* service.findPullRequest({ repository, headBranch: "feature" })).toMatchObject( + { number: 123 }, + ); + expect(h.sockets[0]?.sent.at(-1)).toEqual({ type: "subscribe", channel: repoChannel }); + expect(h.requests).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }, + ); +}); diff --git a/apps/server/src/mergebot/MergebotQueue.ts b/apps/server/src/mergebot/MergebotQueue.ts new file mode 100644 index 0000000000000000000000000000000000000000..386dc8d1ad84410a7967f744bc56eb27df54c876 --- /dev/null +++ b/apps/server/src/mergebot/MergebotQueue.ts @@ -0,0 +1,582 @@ +import { + MergebotConfiguration, + MergebotPullRequestCheck, + MergebotPullRequestDetails, + MergebotPullRequestStatus, + type MergebotPullRequestInput, + type MergebotQueueInput, + MergebotRepoQueue, + PullRequestActor, +} from "@t3tools/contracts"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import * as Struct from "effect/Struct"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Socket from "effect/socket/Socket"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; + +const PullRequestRecord = Schema.Struct({ + ...Struct.omit(MergebotPullRequestDetails.fields, [ + "position", + "checks", + "author", + "comments", + "observedAt", + ]), + authorId: Schema.NullOr(Schema.String), + id: Schema.String, + repoId: Schema.String, +}); +const Comment = Schema.Struct({ + id: Schema.String, + kind: Schema.Literals(["comment", "review", "review_comment"]), + authorId: Schema.NullOr(Schema.String), + body: Schema.String, + url: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + state: Schema.NullOr(Schema.String), + path: Schema.NullOr(Schema.String), +}); +const RepoChannel = Schema.Struct({ kind: Schema.Literal("repo_overview"), repoId: Schema.String }); +const DetailChannel = Schema.Struct({ + kind: Schema.Literal("pull_request_detail"), + repoId: Schema.String, + prNumber: Schema.Number, +}); +const RepoPayload = Schema.Struct({ + entities: Schema.Struct({ + repos: Schema.Record(Schema.String, Schema.Struct({ processing: Schema.Boolean })), + pullRequests: Schema.Record(Schema.String, PullRequestRecord), + }), + view: Schema.Struct({ repoId: Schema.String, queuePrIds: Schema.Array(Schema.String) }), +}); +const Message = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("snapshot"), + channelKey: Schema.String, + channel: RepoChannel, + observedAt: Schema.NullOr(Schema.String), + payload: RepoPayload, + }), + Schema.Struct({ + type: Schema.Literal("snapshot"), + channelKey: Schema.String, + channel: DetailChannel, + observedAt: Schema.NullOr(Schema.String), + payload: Schema.Struct({ + entities: Schema.Struct({ + pullRequests: Schema.Record(Schema.String, PullRequestRecord), + users: Schema.Record(Schema.String, PullRequestActor), + }), + view: Schema.Struct({ + repoId: Schema.String, + prId: Schema.String, + isLoadingDetails: Schema.Boolean, + checks: Schema.Array(MergebotPullRequestCheck), + comments: Schema.Array(Comment), + }), + }), + }), + Schema.Struct({ + type: Schema.Literal("error"), + channelKey: Schema.optionalKey(Schema.String), + message: Schema.String, + }), +]); +const decodeMessage = Schema.decodeEffect(Schema.fromJsonString(Message)); +const encodeStatus = Schema.encodeSync(MergebotPullRequestStatus); +const queueEquivalence = Schema.toEquivalence(Schema.NullOr(MergebotRepoQueue)); +const decodeSession = Schema.decodeUnknownEffect( + Schema.Struct({ + token: Schema.String.check(Schema.isMinLength(1)), + }), +); + +class MergebotAuthenticationError extends Schema.TaggedError()( + "MergebotAuthenticationError", + { status: Schema.Number }, +) {} + +type Channel = typeof RepoChannel.Type | typeof DetailChannel.Type; +type Change = { readonly repository: string; readonly number?: number }; + +export class MergebotQueue extends Context.Service< + MergebotQueue, + { + readonly configuration: MergebotConfiguration | null; + readonly read: (input: MergebotQueueInput) => Effect.Effect; + readonly watchQueue: (input: MergebotQueueInput) => Stream.Stream; + readonly readPullRequest: ( + input: MergebotPullRequestInput, + ) => Effect.Effect; + readonly findPullRequest: (input: { + readonly repository: string; + readonly headBranch: string; + }) => Effect.Effect; + readonly streamChanges: Stream.Stream; + } +>()("t3/mergebot/MergebotQueue") {} + +const encodeSubscribe = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + type: Schema.Literal("subscribe"), + channel: Schema.Union([RepoChannel, DetailChannel]), + }), + ), +); + +const encodeUnsubscribe = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + type: Schema.Literal("unsubscribe"), + channelKey: Schema.String, + }), + ), +); + +const channelKey = (channel: Channel) => + channel.kind === "repo_overview" + ? `repo_overview:${channel.repoId}` + : `pull_request_detail:${channel.repoId}#${channel.prNumber}`; + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const configuration = yield* fs.readFileString(path.join(home, ".config/mergebot.json")).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(MergebotConfiguration))), + Effect.orElseSucceed(() => null), + ); + const http = HttpClient.withScope(yield* HttpClient.HttpClient); + const credentials = yield* GitHubCredentials.GitHubCredentials; + const constructSocket = yield* Socket.WebSocketConstructor; + const scope = yield* Effect.scope; + const changes = yield* PubSub.unbounded(); + const repos = new Map(); + const details = new Map(); + const channels = new Map(); + const observations = new Map(); + const retryAt = new Map(); + const lastReadAt = new Map(); + let writer: Socket.Writer | undefined; + let started = false; + let account: string | undefined; + let activeSocket: Socket.WebSocketLike | undefined; + + const repositoryFor = (repository: string) => + configuration?.repositories.find((repo) => repo.toLowerCase() === repository.toLowerCase()); + + const invalidate = Effect.fnUntraced(function* (key?: string) { + const invalidated: Change[] = []; + for (const [currentKey, channel] of channels) { + if (key !== undefined && currentKey !== key) continue; + const previous = + channel.kind === "repo_overview" ? repos.get(channel.repoId) : details.get(currentKey); + const changed = previous !== null || observations.has(currentKey); + observations.delete(currentKey); + retryAt.delete(currentKey); + if (channel.kind === "repo_overview") { + repos.set(channel.repoId, null); + } else details.set(currentKey, null); + if (changed) + invalidated.push({ + repository: channel.repoId, + ...(channel.kind === "pull_request_detail" ? { number: channel.prNumber } : {}), + }); + } + yield* PubSub.publishAll(changes, invalidated); + }); + + const queueFor = (repository: string): MergebotRepoQueue | null | undefined => { + const payload = repos.get(repository); + if (payload === null || payload === undefined) return payload; + if (!configuration) return null; + const url = `${configuration.origin}/${repository}`; + return { + repo: repository, + url, + status: "ready", + processing: payload.entities.repos[repository]!.processing, + pullRequests: Object.values(payload.entities.pullRequests).map((pr) => { + const position = payload.view.queuePrIds.indexOf(pr.id); + return encodeStatus({ + ...pr, + position: position < 0 ? null : position + 1, + url: `${url}/pull/${pr.number}`, + }); + }), + }; + }; + + const consume = Effect.fnUntraced(function* (text: string) { + const message = yield* decodeMessage(text); + if (message.type === "error") { + yield* invalidate(message.channelKey); + const nextAttempt = (yield* Clock.currentTimeMillis) + 1_000; + for (const key of channels.keys()) { + if (message.channelKey === undefined || message.channelKey === key) + retryAt.set(key, nextAttempt); + } + return; + } + const channel = channels.get(message.channelKey); + if (!channel || channelKey(message.channel) !== message.channelKey) return; + const repository = channel.repoId; + if (!configuration) return; + const url = `${configuration.origin}/${repository}`; + const observedAt = message.observedAt === null ? NaN : Date.parse(message.observedAt); + if (Number.isFinite(observedAt)) { + const previous = observations.get(message.channelKey); + if (previous !== undefined && observedAt < previous) return; + observations.set(message.channelKey, observedAt); + } + const entities = message.payload.entities; + if ("repos" in entities) { + const payload = message.payload; + const view = payload.view; + if (!("queuePrIds" in view) || view.repoId !== repository) { + yield* invalidate(message.channelKey); + return; + } + if (!message.observedAt || !Number.isFinite(Date.parse(message.observedAt))) { + repos.delete(repository); + yield* PubSub.publish(changes, { repository }); + return; + } + const repo = entities.repos[repository]; + if (!repo) { + yield* invalidate(message.channelKey); + return; + } + const pullRequestRecords = Object.values(entities.pullRequests).filter( + (pr) => pr.repoId === repository && pr.id === `${repository}#${pr.number}`, + ); + if (pullRequestRecords.length !== Object.keys(entities.pullRequests).length) { + yield* invalidate(message.channelKey); + return; + } + repos.set(repository, { + entities: { repos: entities.repos, pullRequests: entities.pullRequests }, + view, + }); + for (const record of pullRequestRecords) { + const key = channelKey({ + kind: "pull_request_detail", + repoId: repository, + prNumber: record.number, + }); + const detail = details.get(key); + if ( + detail && + (detail.headSha !== record.headSha || detail.lifecycle !== record.lifecycle) && + (observations.get(key) ?? -Infinity) <= observedAt + ) { + details.delete(key); + } + } + yield* PubSub.publish(changes, { repository }); + return; + } + if (message.channel.kind !== "pull_request_detail" || !("users" in entities)) return; + const id = `${repository}#${message.channel.prNumber}`; + const pr = message.payload.entities.pullRequests[id]; + const view = message.payload.view; + if (!("prId" in view) || view.repoId !== repository || view.prId !== id) { + yield* invalidate(message.channelKey); + return; + } + if ( + !message.observedAt || + !Number.isFinite(Date.parse(message.observedAt)) || + view.isLoadingDetails + ) { + details.delete(message.channelKey); + } else if ( + pr?.id === id && + pr.repoId === repository && + pr.number === message.channel.prNumber + ) { + const known = repos.get(repository)?.entities.pullRequests[id]; + if ( + known && + (known.headSha !== pr.headSha || known.lifecycle !== pr.lifecycle) && + observedAt < (observations.get(`repo_overview:${repository}`) ?? -Infinity) + ) { + details.delete(message.channelKey); + yield* PubSub.publish(changes, { repository, number: pr.number }); + return; + } + const queue = queueFor(repository); + const queued = queue?.pullRequests?.find((entry) => entry.number === pr.number); + details.set(message.channelKey, { + ...Struct.omit(pr, ["id", "repoId", "authorId"]), + author: pr.authorId === null ? null : (entities.users[pr.authorId] ?? null), + observedAt, + comments: view.comments.map((comment) => ({ + id: comment.id, + kind: + comment.kind === "comment" + ? "issue-comment" + : comment.kind === "review_comment" + ? "review-comment" + : "review", + author: comment.authorId === null ? null : (entities.users[comment.authorId] ?? null), + body: comment.body, + createdAt: comment.createdAt, + editedAt: comment.updatedAt, + url: comment.url, + path: comment.path, + reviewState: comment.state, + })), + position: queued && queued.headSha === pr.headSha ? queued.position : null, + url: `${url}/pull/${pr.number}`, + checks: view.checks, + }); + } else { + details.set(message.channelKey, null); + } + yield* PubSub.publish(changes, { repository, number: message.channel.prNumber }); + }); + + const connect = Effect.gen(function* () { + if (!configuration) return; + const credential = yield* credentials.get("github.com"); + if (account !== undefined && account !== credential.fingerprint) { + yield* invalidate(); + } + account = credential.fingerprint; + const response = yield* http + .execute( + HttpClientRequest.post(`${configuration.origin}/api/auth/github/token`).pipe( + HttpClientRequest.setHeader( + "Authorization", + `Bearer ${Redacted.value(credential.token)}`, + ), + HttpClientRequest.setHeader("accept", "application/json"), + ), + ) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.timeout("10 seconds"), + ); + if (response.status !== 200) { + if (response.status === 401) yield* credentials.invalidate("github.com"); + return yield* new MergebotAuthenticationError({ status: response.status }); + } + const session = yield* response.json.pipe(Effect.flatMap(decodeSession)); + const token = Redacted.make(session.token); + const socket = yield* Socket.fromWebSocket( + Effect.acquireRelease( + Effect.sync(() => { + activeSocket = constructSocket( + `${configuration.origin.replace(/^https:/, "wss:")}/api/state/ws`, + { headers: { Authorization: `Bearer ${Redacted.value(token)}` } }, + ); + return activeSocket; + }), + (socket) => Effect.sync(() => socket.close(1000)), + ), + { openTimeout: "10 seconds" }, + ); + return yield* Effect.scoped( + Effect.gen(function* () { + const { pull } = yield* socket.reader; + const currentWriter = yield* socket.writer; + writer = currentWriter; + for (const channel of channels.values()) { + yield* currentWriter.write(encodeSubscribe({ type: "subscribe", channel })); + } + while (true) { + const frames = yield* pull; + for (const frame of frames) { + yield* consume(typeof frame === "string" ? frame : new TextDecoder().decode(frame)); + } + } + }), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + writer = undefined; + activeSocket = undefined; + }), + ), + ); + }); + + const authorize = Effect.fnUntraced(function* () { + if (activeSocket && activeSocket.readyState >= 2) { + yield* invalidate(); + return null; + } + const credential = yield* credentials.get("github.com").pipe(Effect.orElseSucceed(() => null)); + if (credential && (account === undefined || credential.fingerprint === account)) + return credential.fingerprint; + yield* invalidate(); + activeSocket?.close(1000); + return null; + }); + + const releaseIdleChannels = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + for (const [key, at] of lastReadAt) { + if (now - at < 5 * 60_000) continue; + lastReadAt.delete(key); + channels.delete(key); + details.delete(key); + observations.delete(key); + retryAt.delete(key); + if (writer) + yield* writer + .write(encodeUnsubscribe({ type: "unsubscribe", channelKey: key })) + .pipe(Effect.orElseSucceed(() => undefined)); + } + }); + + const start = Effect.fnUntraced(function* () { + if (started || !configuration) return; + started = true; + for (const repository of configuration.repositories) { + const channel = { kind: "repo_overview", repoId: repository } as const; + channels.set(channelKey(channel), channel); + } + yield* Effect.forkIn( + releaseIdleChannels.pipe(Effect.repeat(Schedule.spaced("1 minute"))), + scope, + ); + yield* Effect.forkIn( + connect.pipe( + Effect.scoped, + Effect.ensuring(invalidate()), + Effect.retry({ schedule: Schedule.spaced("1 second") }), + ), + scope, + ); + }); + + const retryChannel = Effect.fnUntraced(function* (key: string) { + const nextAttempt = retryAt.get(key); + if (nextAttempt === undefined || !writer || (yield* Clock.currentTimeMillis) < nextAttempt) + return; + const channel = channels.get(key); + if (!channel) return; + retryAt.delete(key); + if (channel.kind === "repo_overview") repos.delete(channel.repoId); + else details.delete(key); + yield* writer + .write(encodeSubscribe({ type: "subscribe", channel })) + .pipe(Effect.catch(() => Effect.sync(() => activeSocket?.close(1000)))); + }); + + const waitFor = (fingerprint: string, read: () => A | null | undefined) => + Effect.gen(function* () { + const value = yield* Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + let value = read(); + while (value === undefined) { + yield* PubSub.take(subscription); + value = read(); + } + return value; + }).pipe( + Effect.scoped, + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => null), + ); + return (yield* authorize()) === fingerprint ? value : null; + }); + + const readPullRequest = Effect.fn("MergebotQueue.readPullRequest")(function* ( + input: MergebotPullRequestInput, + ) { + const repository = repositoryFor(input.repository); + if (!repository) return null; + const fingerprint = yield* authorize(); + if (fingerprint === null) return null; + yield* start(); + const channel = { + kind: "pull_request_detail", + repoId: repository, + prNumber: input.number, + } as const; + const key = channelKey(channel); + lastReadAt.set(key, yield* Clock.currentTimeMillis); + if (!channels.has(key)) { + channels.set(key, channel); + if (writer) + yield* writer + .write(encodeSubscribe({ type: "subscribe", channel })) + .pipe(Effect.orElseSucceed(() => undefined)); + } + yield* retryChannel(key); + return yield* waitFor(fingerprint, () => details.get(key)); + }); + + const read = Effect.fn("MergebotQueue.read")(function* (input: MergebotQueueInput) { + const repository = repositoryFor(input.repository); + if (!repository) return null; + const fingerprint = yield* authorize(); + if (fingerprint === null) return null; + yield* start(); + yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository })); + return yield* waitFor(fingerprint, () => queueFor(repository)); + }); + + return MergebotQueue.of({ + configuration, + read, + watchQueue: (input) => + Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + return Stream.concat( + Stream.fromEffect(read(input)), + Stream.fromSubscription(subscription).pipe( + Stream.filter( + (change) => change.repository.toLowerCase() === input.repository.toLowerCase(), + ), + Stream.mapEffect(() => read(input)), + ), + ); + }), + ).pipe(Stream.changesWith(queueEquivalence)), + readPullRequest, + findPullRequest: Effect.fn("MergebotQueue.findPullRequest")(function* (input) { + const repository = repositoryFor(input.repository); + if (!repository) return null; + const fingerprint = yield* authorize(); + if (fingerprint === null) return null; + yield* start(); + yield* retryChannel(channelKey({ kind: "repo_overview", repoId: repository })); + const queue = yield* waitFor(fingerprint, () => queueFor(repository)); + if (!queue || queue.status !== "ready") return null; + const matches = Object.values(repos.get(repository)?.entities.pullRequests ?? {}) + .filter((pr) => pr.headBranch === input.headBranch) + .sort( + (a, b) => + Number(b.lifecycle === "open") - Number(a.lifecycle === "open") || + b.updatedAt.localeCompare(a.updatedAt), + ); + const match = matches?.[0]; + return match ? yield* readPullRequest({ repository, number: match.number }) : null; + }), + streamChanges: Stream.fromPubSub(changes), + }); +}); + +export const layerWithSocketConstructor = Layer.effect(MergebotQueue, make); +export const layer = layerWithSocketConstructor.pipe( + Layer.provide(NodeSocket.layerWebSocketConstructorWS), +); diff --git a/apps/server/src/mergebot/pullRequestRead.ts b/apps/server/src/mergebot/pullRequestRead.ts new file mode 100644 index 0000000000000000000000000000000000000000..153d8d32ff40866984f6f5c592bb587e5a450cd1 --- /dev/null +++ b/apps/server/src/mergebot/pullRequestRead.ts @@ -0,0 +1,60 @@ +import type { + MergebotPullRequestDetails, + PullRequestCheck, + PullRequestMergeability, +} from "@t3tools/contracts"; + +export function mergebotChecks( + details: MergebotPullRequestDetails, +): ReadonlyArray { + return details.checks.map((check) => { + const conclusion = check.conclusion; + const status = + check.status !== "completed" + ? "pending" + : conclusion === "success" || conclusion === "neutral" || conclusion === "skipped" + ? conclusion + : conclusion === "cancelled" + ? "cancelled" + : conclusion === "action_required" + ? "action-required" + : conclusion === "failure" || + conclusion === "timed_out" || + conclusion === "startup_failure" + ? "failure" + : "pending"; + return { + name: check.workflowName === null ? check.name : `${check.workflowName} / ${check.name}`, + status, + description: null, + url: check.url, + ...(check.completedAt === null ? {} : { completedAt: check.completedAt }), + }; + }); +} + +export function mergebotMergeability(details: MergebotPullRequestDetails): PullRequestMergeability { + return details.hasConflicts === null + ? "unknown" + : details.hasConflicts + ? "conflicting" + : "mergeable"; +} + +export function mergebotWatchFingerprint(details: MergebotPullRequestDetails) { + return { + status: [ + details.headSha, + details.lifecycle, + details.draft, + details.hasConflicts, + details.updatedAt, + ...details.checks.map((check) => + [check.id, check.status, check.conclusion, check.completedAt].join("\u0000"), + ), + ].join("\u0001"), + remarks: details.comments + .map((comment) => [comment.id, comment.editedAt ?? comment.createdAt].join("\u0000")) + .join("\u0001"), + }; +} diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2392898958d0098b2937dbb4817030731a6d42d7..ba4c9f38adbecd62ef55ad47e4a28aa99f9d445e 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -99,6 +99,8 @@ const RPC_AGGREGATES = { [WS_METHODS.secretsAnswerRequest]: "secrets", [WS_METHODS.cloudGetRelayClientStatus]: "cloud", [WS_METHODS.cloudInstallRelayClient]: "cloud", + [WS_METHODS.mergebotQueue]: "mergebot", + [WS_METHODS.mergebotSubscribe]: "mergebot", [WS_METHODS.pullRequestsList]: "pull-requests", [WS_METHODS.pullRequestsListStats]: "pull-requests", [WS_METHODS.pullRequestsSummary]: "pull-requests", diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 25a643a9655ca98cab38e31bbc290202489767f4..d6db6414b04e713468edf60e4a864f2b05381b7a 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -33,6 +33,7 @@ import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; import * as GitManager from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerActivation from "../serverActivation.ts"; @@ -164,6 +165,7 @@ function makeSummary( } interface HarnessOptions { + readonly mergebot?: MergebotQueue.MergebotQueue["Service"]["configuration"]; readonly invalidate?: PullRequestService.PullRequestService["Service"]["invalidate"]; readonly snapshot: TestShellSnapshot; readonly summary?: ( @@ -218,6 +220,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: }; const layerDependencies = Layer.mergeAll( + Layer.mock(MergebotQueue.MergebotQueue)({ configuration: options.mergebot ?? null }), Layer.mock(PullRequestService.PullRequestService)({ summary, stack, @@ -387,6 +390,38 @@ function applySync( } describe("PullRequestSyncReactor", () => { + it.effect("work summaries sync while unsupported stacks retain their existing links", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const stack = { + kind: "native" as const, + id: "stack", + number: 7, + url: "https://github.com/owner/repository/stacks/7", + base: "main", + layers: [{ number: 7, headBranch: "feature", state: "open" as const }], + }; + const fixture = yield* makeHarness({ + mergebot: { origin: "https://mergebot.example", repositories: ["owner/repository"] }, + snapshot: makeSnapshot([ + makeThread("work", { pullRequests: [makeLink(7, null, { stack })] }), + ]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), + stack: () => Effect.die("Work sync must not read unsupported GitHub stacks"), + }); + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + const commands = yield* Ref.get(fixture.syncCommands); + assert.strictEqual(commands[0]?.snapshot?.state, "merged"); + assert.deepStrictEqual(commands[0]?.stack, stack); + assert.deepStrictEqual(yield* Ref.get(fixture.stackCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("retries a failed stack read after the summary becomes terminal", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 70be6e0b08780b0d9e6fd3df53c62883093541c4..c8dc06875b202768b505841efdd216b620e14142 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,3 +1,5 @@ +import { isMergebotRepository } from "@t3tools/contracts"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, @@ -156,6 +158,7 @@ export const make = Effect.gen(function* () { const engine = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const pullRequests = yield* PullRequestService.PullRequestService; + const mergebot = yield* MergebotQueue.MergebotQueue; const git = yield* GitManager.GitManager; const crypto = yield* Crypto.Crypto; @@ -288,16 +291,20 @@ export const make = Effect.gen(function* () { if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref }); const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); const fields = snapshotFieldsOf(summary); + const work = + ref.host === "github.com" && + isMergebotRepository(mergebot.configuration ?? undefined, ref.repository); const needsStack = - generation !== undefined || - retryStacks.has(key) || - entries.some( - (entry) => - entry.link.snapshot === null || - !snapshotFieldsEqual(entry.link.snapshot, fields) || - (summary.stack !== undefined && - (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)), - ); + !work && + (generation !== undefined || + retryStacks.has(key) || + entries.some( + (entry) => + entry.link.snapshot === null || + !snapshotFieldsEqual(entry.link.snapshot, fields) || + (summary.stack !== undefined && + (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)), + )); // A summary that says the pull request is in no stack, for links that hold none, already // answers what the stack read would. const knownUnstacked = diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 688af8405ef5aa65356e76f90469379e63c7e15b..8133f72291521a4a3366a8127c917d2c4edd19c3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,4 +1,5 @@ import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; import { SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -2505,12 +2506,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); const reactor = yield* PullRequestWatchReactor.make.pipe( Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => Effect.die("host unreachable"), - activity: () => Effect.die("host unreachable"), + Layer.merge( + Layer.mock(MergebotQueue.MergebotQueue)({ + configuration: null, + streamChanges: Stream.empty, }), + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.die("host unreachable"), + activity: () => Effect.die("host unreachable"), + }), + ), ), ), ); @@ -2584,37 +2591,43 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let reads = 0; const reactor = yield* PullRequestWatchReactor.make.pipe( Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.suspend(() => { - reads += 1; - return host === "rate-limited" + Layer.merge( + Layer.mock(MergebotQueue.MergebotQueue)({ + configuration: null, + streamChanges: Stream.empty, + }), + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.suspend(() => { + reads += 1; + return host === "rate-limited" + ? Effect.fail(rateLimited) + : Effect.succeed({ + ...watchedPullRequestDetail({ + projectId, + number: 9, + at: "2026-10-02T12:00:00.000Z", + }), + state: host, + checks: [ + { name: "lint", status: "success", description: null, url: null }, + ], + }); + }), + activity: () => + host === "rate-limited" ? Effect.fail(rateLimited) : Effect.succeed({ - ...watchedPullRequestDetail({ - projectId, - number: 9, - at: "2026-10-02T12:00:00.000Z", - }), - state: host, - checks: [ - { name: "lint", status: "success", description: null, url: null }, - ], - }); - }), - activity: () => - host === "rate-limited" - ? Effect.fail(rateLimited) - : Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }), - }), + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), ), ), ); @@ -2720,24 +2733,34 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); const reactor = yield* PullRequestWatchReactor.make.pipe( Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => - Effect.sync(() => ({ - ...watchedPullRequestDetail({ projectId, number: key.number, at: "2026-10-02" }), - headSha, - checks: [], - })), - activity: () => - Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }), + Layer.merge( + Layer.mock(MergebotQueue.MergebotQueue)({ + configuration: null, + streamChanges: Stream.empty, }), + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: key.number, + at: "2026-10-02", + }), + headSha, + checks: [], + })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), ), ), ); @@ -2822,61 +2845,67 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let activityReads = 0; const reactor = yield* PullRequestWatchReactor.make.pipe( Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - watchFingerprint: () => - Effect.suspend(() => - fingerprint === "rate-limited" - ? Effect.fail( - new PullRequestOperationError({ - operation: "watchFingerprint", - detail: "paused", - cause: new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestWatchFingerprint", - reason: "rate-limited", + Layer.merge( + Layer.mock(MergebotQueue.MergebotQueue)({ + configuration: null, + streamChanges: Stream.empty, + }), + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => + Effect.suspend(() => + fingerprint === "rate-limited" + ? Effect.fail( + new PullRequestOperationError({ + operation: "watchFingerprint", detail: "paused", + cause: new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "getChangeRequestWatchFingerprint", + reason: "rate-limited", + detail: "paused", + }), }), - }), - ) - : Effect.succeed(fingerprint), - ), - invalidate: () => - Effect.sync(() => { - cachedChecks = null; - }), - detail: () => - Effect.sync(() => { - detailReads += 1; - const answer = cachedChecks ?? checks; - cachedChecks = null; - return { - ...watchedPullRequestDetail({ - projectId, - number: 11, - at: "2026-10-02T12:00:00.000Z", - }), - checks: Object.entries(answer).map(([name, status]) => ({ - name, - status, - description: null, - url: null, - })), - }; - }), - activity: () => - Effect.sync(() => { - activityReads += 1; - return { - comments, - commentCount: comments.length, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), + ) + : Effect.succeed(fingerprint), + ), + invalidate: () => + Effect.sync(() => { + cachedChecks = null; + }), + detail: () => + Effect.sync(() => { + detailReads += 1; + const answer = cachedChecks ?? checks; + cachedChecks = null; + return { + ...watchedPullRequestDetail({ + projectId, + number: 11, + at: "2026-10-02T12:00:00.000Z", + }), + checks: Object.entries(answer).map(([name, status]) => ({ + name, + status, + description: null, + url: null, + })), + }; + }), + activity: () => + Effect.sync(() => { + activityReads += 1; + return { + comments, + commentCount: comments.length, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }; + }), + }), + ), ), ), ); @@ -3031,62 +3060,68 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let pagesRead = 0; const reactor = yield* PullRequestWatchReactor.make.pipe( Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(PullRequestService.PullRequestService)({ - detail: () => Effect.succeed(detail), - activity: () => - Effect.succeed({ - comments: - mode === "single page" - ? [remark] - : [...firstTen, { ...remark, kind: "issue-comment" }], - commentCount: mode === "single page" ? 1 : 13, - commentsTruncated: mode !== "single page", - reviewThreadsTruncated: mode === "thread list truncated" && !recovering, - reviewThreads: - mode === "single page" - ? [] - : [ - { - id: "review-thread", - path: "src/index.ts", - line: 1, - side: "right", - isResolved: false, - isOutdated: false, - comments: firstTen, - commentCount: 12, - nextCommentsCursor: "after-10", - }, - ], - commits: [], - }), - threadComments: (input) => { - pagesRead += 1; - assert.equal(input.threadId, "review-thread"); - if (input.cursor === "after-10") { - return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); - } - assert.equal(input.cursor, "after-11"); - if (!recovering && mode === "page failure") { - return Effect.fail( - new PullRequestOperationError({ - operation: "threadComments", - detail: "Page unavailable", - }), - ); - } - if (!recovering && mode === "repeated cursor") { - return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); - } - if (!recovering && mode === "missing comments") { - return Effect.succeed({ comments: [], nextCursor: null }); - } - // Overlapping pages must not report the same reply twice. - return Effect.succeed({ comments: [eleventh, twelfth], nextCursor: null }); - }, + Layer.merge( + Layer.mock(MergebotQueue.MergebotQueue)({ + configuration: null, + streamChanges: Stream.empty, }), + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.succeed(detail), + activity: () => + Effect.succeed({ + comments: + mode === "single page" + ? [remark] + : [...firstTen, { ...remark, kind: "issue-comment" }], + commentCount: mode === "single page" ? 1 : 13, + commentsTruncated: mode !== "single page", + reviewThreadsTruncated: mode === "thread list truncated" && !recovering, + reviewThreads: + mode === "single page" + ? [] + : [ + { + id: "review-thread", + path: "src/index.ts", + line: 1, + side: "right", + isResolved: false, + isOutdated: false, + comments: firstTen, + commentCount: 12, + nextCommentsCursor: "after-10", + }, + ], + commits: [], + }), + threadComments: (input) => { + pagesRead += 1; + assert.equal(input.threadId, "review-thread"); + if (input.cursor === "after-10") { + return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); + } + assert.equal(input.cursor, "after-11"); + if (!recovering && mode === "page failure") { + return Effect.fail( + new PullRequestOperationError({ + operation: "threadComments", + detail: "Page unavailable", + }), + ); + } + if (!recovering && mode === "repeated cursor") { + return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); + } + if (!recovering && mode === "missing comments") { + return Effect.succeed({ comments: [], nextCursor: null }); + } + // Overlapping pages must not report the same reply twice. + return Effect.succeed({ comments: [eleventh, twelfth], nextCursor: null }); + }, + }), + ), ), ), ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 70865973854210438cc8dfaca98f62371c6ef822..dc7a0378fa0db91b679304055571ccf3276ad5bd 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -28,6 +28,7 @@ import type { ServerSettings } from "@t3tools/contracts"; import * as ServerSettingsService from "../serverSettings.ts"; import { PullRequestOperationError } from "@t3tools/contracts"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; @@ -534,6 +535,7 @@ function fakeProvider( } function makeService(input: { + readonly mergebot?: MergebotQueue.MergebotQueue["Service"]; readonly settings?: ServerSettings; readonly projects: ReadonlyArray; readonly providers: ReadonlyArray; @@ -545,6 +547,12 @@ function makeService(input: { return Effect.flatMap( Layer.build( Layer.mergeAll( + input.mergebot + ? Layer.succeed(MergebotQueue.MergebotQueue, input.mergebot) + : Layer.mock(MergebotQueue.MergebotQueue)({ + configuration: null, + streamChanges: Stream.empty, + }), Layer.mock(ServerSettingsService.ServerSettingsService)({ getSettings: Effect.succeed(input.settings ?? DEFAULT_SERVER_SETTINGS), }), @@ -7903,3 +7911,96 @@ it.effect.each([ assert.deepStrictEqual(calls, [expected]); }), ); + +it.effect( + "configured work PR reads use live Mergebot, and unsupported routes never query GitHub", + () => + Effect.gen(function* () { + let value: import("@t3tools/contracts").MergebotPullRequestDetails | null = { + number: 42, + headSha: "head", + lifecycle: "open", + queueStatus: "waiting_ci", + position: 1, + mergeEtaAt: null, + title: "Ship change", + body: null, + url: "https://mergebot.example/work/app/pull/42", + createdAt: "2026-10-14T10:00:00Z", + updatedAt: "2026-10-14T10:01:00Z", + author: { login: "clover", name: null, avatarUrl: null }, + additions: null, + deletions: null, + changedFilesCount: null, + labels: [], + comments: [], + observedAt: 1791972060000, + draft: false, + baseBranch: "main", + headBranch: "feature", + ciStatus: "pending", + hasConflicts: null, + checks: [], + }; + const service = yield* makeService({ + projects: [ + project({ id: "work", title: "Work", workspaceRoot: "/work", repository: "work/app" }), + ], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + getViewer: () => Effect.die("Work reads must not query GitHub"), + getChangeRequestSummary: () => Effect.die("Work reads must not query GitHub"), + getChangeRequestPreview: () => Effect.die("Work reads must not query GitHub"), + getChangeRequestChecks: () => Effect.die("Work reads must not query GitHub"), + getChangeRequestWatchFingerprint: () => Effect.die("Work reads must not query GitHub"), + listChangeRequests: () => Effect.die("Work reads must not query GitHub"), + }), + ], + mergebot: { + configuration: { origin: "https://mergebot.example", repositories: ["work/app"] }, + read: () => Effect.succeed(null), + watchQueue: () => Stream.empty, + readPullRequest: () => Effect.sync(() => value), + findPullRequest: () => Effect.succeed(null), + streamChanges: Stream.empty, + }, + }); + const reference = { projectId: "work" as ProjectId, repository: "work/app", number: 42 }; + const first = yield* service.summary(reference); + assert.strictEqual(first.title, "Ship change"); + assert.isUndefined(first.additions); + assert.isUndefined(first.mergedAt); + assert.strictEqual((yield* service.preview(reference)).createdAt, "2026-10-14T10:00:00Z"); + assert.deepStrictEqual((yield* service.listStats({ refs: [reference] })).stats, []); + assert.deepStrictEqual( + (yield* service.list({ state: "open" })).errors.map((error) => error.projectId), + [reference.projectId], + ); + assert.include((yield* Effect.flip(service.detail(reference))).message, "Mergebot"); + assert.include((yield* Effect.flip(service.activity(reference))).message, "Mergebot"); + assert.include((yield* Effect.flip(service.stack(reference))).message, "Mergebot"); + const fingerprint = yield* service.watchFingerprint(reference); + value = { + ...value!, + title: "Changed live", + ciStatus: "failed", + checks: [ + { + id: "check:2", + name: "Tests", + workflowName: "CI", + status: "completed", + conclusion: "failure", + url: null, + startedAt: null, + completedAt: "2026-10-14T10:02:00Z", + }, + ], + }; + assert.strictEqual((yield* service.summary(reference)).title, "Changed live"); + assert.strictEqual((yield* service.checks(reference))?.checks[0]?.status, "failure"); + assert.notDeepEqual(yield* service.watchFingerprint(reference), fingerprint); + value = null; + assert.include((yield* Effect.flip(service.summary(reference))).message, "unavailable"); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 659e0605f66ea1b3e3d98a0b8d048fbac5517cd0..ad1e35cb0234b90447c467ae47b034e263ce381c 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -21,6 +21,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, + isMergebotRepository, PullRequestUnavailableError, pullRequestHostOf, pullRequestProviderRequirement, @@ -78,6 +79,12 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; +import { + mergebotChecks, + mergebotMergeability, + mergebotWatchFingerprint, +} from "../mergebot/pullRequestRead.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; @@ -711,6 +718,7 @@ export const make = Effect.gen(function* () { const stateChanges = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; + const mergebot = yield* MergebotQueue.MergebotQueue; const serverSettings = yield* ServerSettings.ServerSettingsService; const projects = yield* ProjectService.ProjectService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; @@ -1002,6 +1010,11 @@ export const make = Effect.gen(function* () { }), ); + const isWorkProject = (project: SupportedProject) => + project.api.kind === "github" && + project.host === "github.com" && + isMergebotRepository(mergebot.configuration ?? undefined, project.repository); + const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* < I extends PullRequestRef, >(input: I) { @@ -1267,11 +1280,14 @@ export const make = Effect.gen(function* () { // issued, so one that does not read as one means the page is sending something it made up, // and reading part of the listing under that assumption would quietly lose rows. const continuation = yield* decodeCursors(input.cursors); - const { - supported: projects, - unimplemented, - viewerRoots, - } = yield* listWorkspaceProjects(input); + const { supported, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); + const workProjects = supported.filter(isWorkProject); + const projects = supported.filter((project) => !isWorkProject(project)); + const workErrors = workProjects.map(({ project }) => ({ + projectId: project.id, + projectTitle: project.title, + message: "Open this repository in Mergebot to browse its pull requests.", + })); const projectCounts = new Map(); for (const { host } of projects) { projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); @@ -1351,7 +1367,7 @@ export const make = Effect.gen(function* () { viewers: viewers as PullRequestListResult["viewers"], providers, entries: [], - errors: [], + errors: workErrors, truncated: false, nextCursors: {}, }; @@ -1585,7 +1601,7 @@ export const make = Effect.gen(function* () { entries: batches .flatMap((batch) => batch.entries) .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), - errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], + errors: [...workErrors, ...unreadable, ...batches.flatMap((batch) => batch.errors)], truncated: batches.some((batch) => batch.truncated), nextCursors, }; @@ -3575,17 +3591,139 @@ export const make = Effect.gen(function* () { ); }); + const withWorkRead = + , A>( + normal: (input: I, ...args: Args) => Effect.Effect, + work: (input: I) => Effect.Effect, + ) => + (input: I, ...args: Args) => + Effect.gen(function* () { + if (!isMergebotRepository(mergebot.configuration ?? undefined, input.repository)) + return yield* normal(input, ...args); + const project = yield* requireProject(input); + if (!isWorkProject(project)) return yield* normal(input, ...args); + return yield* work({ + ...input, + projectId: project.project.id, + repository: project.repository, + host: project.host, + }); + }); + + const workDetails = Effect.fn("PullRequestService.mergebotDetails")(function* ( + input: PullRequestRef, + ) { + const value = yield* mergebot.readPullRequest(input); + if (value === null) + return yield* new PullRequestOperationError({ + operation: "mergebot", + detail: "Mergebot is unavailable. Try again after it reconnects.", + }); + return value; + }); + + const unsupportedWorkRead = , A>( + operation: string, + normal: (input: I, ...args: Args) => Effect.Effect, + ) => + withWorkRead( + normal, + () => + new PullRequestOperationError({ + operation, + detail: "Open this pull request in Mergebot to continue.", + }), + ); + + const workSummary = Effect.fn("PullRequestService.mergebotSummary")(function* ( + input: PullRequestRef, + ): Effect.fn.Return { + const value = yield* workDetails(input); + return { + projectId: input.projectId, + repository: input.repository, + number: value.number, + provider: SourceControlProviderKind.make("github"), + title: value.title, + url: value.url, + state: value.lifecycle, + headBranch: value.headBranch, + baseBranch: value.baseBranch, + isDraft: value.draft, + updatedAt: value.updatedAt, + observedAt: value.observedAt, + author: value.author, + ...(value.mergedAt === undefined ? {} : { mergedAt: value.mergedAt }), + ...(value.closedAt === undefined ? {} : { closedAt: value.closedAt }), + ...(value.additions === null ? {} : { additions: value.additions }), + ...(value.deletions === null ? {} : { deletions: value.deletions }), + ...(value.changedFilesCount === null ? {} : { changedFiles: value.changedFilesCount }), + mergeability: mergebotMergeability(value), + ...(value.ciStatus === "passed" + ? { checksState: "passing" as const } + : value.ciStatus === "failed" + ? { checksState: "failing" as const } + : value.ciStatus === "pending" + ? { checksState: "pending" as const } + : {}), + }; + }); + + yield* mergebot.streamChanges.pipe( + Stream.runForEach((change) => + Effect.gen(function* () { + const numbers = + change.number === undefined + ? ((yield* mergebot.read(change))?.pullRequests?.map((pr) => pr.number) ?? []) + : [change.number]; + yield* PubSub.publishAll( + stateChanges, + numbers.map((number) => ({ host: "github.com", repository: change.repository, number })), + ); + }), + ), + Effect.forkScoped, + ); + return PullRequestService.of({ routing, routingIdentity, withRoutingCredential, - list, + list: (input) => + mergebot.configuration === null + ? list(input) + : listWorkspaceProjects(input).pipe( + Effect.flatMap(({ supported }) => + supported.some(isWorkProject) ? listUncached(input) : list(input), + ), + ), listStats: (input) => - Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe( - Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })), - ), - summary: credentialCached(summary), - stack: credentialCached(stack), + Effect.gen(function* () { + const refs = (yield* Effect.forEach(input.refs, (ref) => + canonicalRef(ref).pipe(Effect.option), + )).flatMap(Option.toArray); + const ordinary: PullRequestRef[] = []; + const workStats: PullRequestDiffStat[] = []; + for (const ref of refs) { + if (!isWorkProject(yield* requireProject(ref))) { + ordinary.push(ref); + continue; + } + const value = yield* workDetails(ref); + if (value.additions !== null && value.deletions !== null) + workStats.push({ + projectId: ref.projectId, + repository: ref.repository, + number: ref.number, + additions: value.additions, + deletions: value.deletions, + }); + } + const result = yield* listStats({ ...input, refs: ordinary }); + return { stats: [...workStats, ...result.stats] }; + }), + summary: withWorkRead(credentialCached(summary), workSummary), + stack: unsupportedWorkRead("stack", credentialCached(stack)), subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), @@ -3597,31 +3735,58 @@ export const make = Effect.gen(function* () { Stream.filter((revision) => revision > 0), ), refreshAfterTurn, - detail: credentialCached(detail), - checks: credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), - watchFingerprint: credentialCached((input) => - Cache.get(watchFingerprintCache, refCacheKey(input)), + detail: unsupportedWorkRead("detail", credentialCached(detail)), + checks: withWorkRead( + credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), + (input) => + workDetails(input).pipe( + Effect.map((value) => ({ state: value.lifecycle, checks: mergebotChecks(value) })), + ), ), - activity: credentialCached(activity), - preview: credentialCached(preview), - threadComments, - diff: credentialCached(diff), - diffFileContents, - filesViewed: credentialCached(filesViewed), - setFilesViewed, - runAction: runActionAndInvalidate, - update: invalidatedByMutation(update), - comment: invalidatedByMutation(comment), - updateComment: invalidatedByMutation(updateComment), - submitReview: invalidatedByMutation(submitReview), - replyToThread: invalidatedByMutation(replyToThread), - setThreadResolution: invalidatedByMutation(setThreadResolution), - setReaction: invalidatedByMutation(setReaction), + watchFingerprint: withWorkRead( + credentialCached((input) => Cache.get(watchFingerprintCache, refCacheKey(input))), + (input) => workDetails(input).pipe(Effect.map(mergebotWatchFingerprint)), + ), + activity: unsupportedWorkRead("activity", credentialCached(activity)), + preview: withWorkRead(credentialCached(preview), (input) => + workDetails(input).pipe( + Effect.map((value) => ({ + projectId: input.projectId, + repository: input.repository, + number: value.number, + title: value.title, + url: value.url, + author: value.author, + state: value.lifecycle, + isDraft: value.draft, + createdAt: value.createdAt, + })), + ), + ), + threadComments: unsupportedWorkRead("threadComments", threadComments), + diff: unsupportedWorkRead("diff", credentialCached(diff)), + diffFileContents: unsupportedWorkRead("diffFileContents", diffFileContents), + filesViewed: unsupportedWorkRead("filesViewed", credentialCached(filesViewed)), + setFilesViewed: unsupportedWorkRead("setFilesViewed", setFilesViewed), + runAction: unsupportedWorkRead("runAction", runActionAndInvalidate), + update: unsupportedWorkRead("update", invalidatedByMutation(update)), + comment: unsupportedWorkRead("comment", invalidatedByMutation(comment)), + updateComment: unsupportedWorkRead("updateComment", invalidatedByMutation(updateComment)), + submitReview: unsupportedWorkRead("submitReview", invalidatedByMutation(submitReview)), + replyToThread: unsupportedWorkRead("replyToThread", invalidatedByMutation(replyToThread)), + setThreadResolution: unsupportedWorkRead( + "setThreadResolution", + invalidatedByMutation(setThreadResolution), + ), + setReaction: unsupportedWorkRead("setReaction", invalidatedByMutation(setReaction)), // The candidate list is deliberately read fresh per menu-open, so it stays uncached. - reviewerCandidates, - requestReviewers: invalidatedByMutation(requestReviewers), - labelCandidates, - setLabels: invalidatedByMutation(setLabels), + reviewerCandidates: unsupportedWorkRead("reviewerCandidates", reviewerCandidates), + requestReviewers: unsupportedWorkRead( + "requestReviewers", + invalidatedByMutation(requestReviewers), + ), + labelCandidates: unsupportedWorkRead("labelCandidates", labelCandidates), + setLabels: unsupportedWorkRead("setLabels", invalidatedByMutation(setLabels)), invalidate, }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ce907c44111caa2f5189d37c44e4ce40414fb073..08dd58ddce40c9fdf2717d4e5a4ed8fe14175834 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -117,6 +117,7 @@ import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -363,11 +364,13 @@ const layerPullRequestService = PullRequestService.layer.pipe( // Where the viewed-file marks live for a host that keeps none of its own. Layer.provide(PullRequestFilesViewed.layer), Layer.provide(PullRequestReadCache.layer), + Layer.provide(MergebotQueue.layer), Layer.provide(layerSourceControlProviderRegistry), Layer.provide(SourceControlRateLimit.layer), ); const layerGitManager = GitManager.layer.pipe( + Layer.provide(MergebotQueue.layer), // Per-project git settings resolve the acting thread's project. Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner), @@ -646,6 +649,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(layerServerSettings), + Layer.provideMerge(MergebotQueue.layer), // The asset route uses the registry's GitHub credential for private PR media, which the // built-in drivers' layer provides alongside the registry. Layer.provideMerge(layerSourceControlProviderRegistry), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e27aa261c1476fa7e81f71cd98e42764d5d22a15..4f3b642c8c476129eb3daa98654289b38f1ffdc0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -189,6 +189,7 @@ import { parseBase64DataUrl } from "./imageMime.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -1169,6 +1170,7 @@ const layerWsRpc = ( const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; + const mergebotQueue = yield* MergebotQueue.MergebotQueue; const scriptProgress = yield* ScriptProgress.ScriptProgress; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; @@ -1673,6 +1675,7 @@ const layerWsRpc = ( otlpLogsEnabled: config.otlpLogsUrl !== undefined, }, settings, + ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, @@ -2409,6 +2412,8 @@ const layerWsRpc = ( Effect.forkScoped, ), ), + [WS_METHODS.mergebotQueue]: (input) => mergebotQueue.read(input), + [WS_METHODS.mergebotSubscribe]: (input) => mergebotQueue.watchQueue(input), [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8138e9e58074d2759e0c8673fc825d4a773354f7..f42c19bf42ba1bab46c9c401ad726129c34076ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11237,6 +11237,12 @@ export default function ChatView(props: ChatViewProps) { } key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} environmentId={activeThread.environmentId} + onMergebotReady={(tabId) => + activeThreadRef !== null && + useRightPanelStore + .getState() + .replacePullRequestWithBrowser(activeThreadRef, renderedRightPanelSurface.id, tabId) + } onSelectPullRequest={(reference) => { if (activeThreadRef) useRightPanelStore.getState().openPullRequest(activeThreadRef, { diff --git a/apps/web/src/components/pullRequest/MergebotBrowserPanel.test.tsx b/apps/web/src/components/pullRequest/MergebotBrowserPanel.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9447feb27f88d807f4c1e19a85b08e7fea2eafda --- /dev/null +++ b/apps/web/src/components/pullRequest/MergebotBrowserPanel.test.tsx @@ -0,0 +1,255 @@ +import { AsyncResult } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + listError: null as string | null, + refresh: vi.fn(), + available: true, + canOperate: true, + thread: null as { settledOverride: "settled" | null; archivedAt: string | null } | null, + preview: { + listLoaded: false, + sessions: {} as Record, + recentlySeenUrls: [] as string[], + }, + close: vi.fn(), + onReady: undefined as ((tabId: string) => boolean) | undefined, + open: vi.fn(), +})); +vi.mock("~/browser/previewRuntime", () => ({ usePreviewAvailable: () => state.available })); +vi.mock("~/previewStateStore", () => ({ + useThreadPreviewState: () => state.preview, + readThreadPreviewState: () => state.preview, +})); +vi.mock("~/state/entities", () => ({ useThreadShell: () => state.thread })); +vi.mock("~/state/session", () => ({ useEnvironmentScope: () => state.canOperate })); +vi.mock("~/state/preview", () => ({ + previewEnvironment: { open: {}, close: {}, list: () => ({}) }, +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: () => ({ error: state.listError, refresh: state.refresh }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../preview/usePreviewSession", () => ({ usePreviewSession: () => {} })); +vi.mock("../preview/closePreviewSession", () => ({ closePreviewSession: state.close })); +vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); +vi.mock("../preview/PreviewPanel", () => ({ + PreviewPanel: ({ tabId }: { tabId: string }) =>
, +})); +vi.mock("../ui/button", () => ({ Button: "button" })); +import { MergebotBrowserPanel } from "./MergebotBrowserPanel"; + +const threadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), +}; +const url = "https://mergebot.example/Work/App/pull/12"; +let renderer: ReactTestRenderer | null = null; +const render = async (owner = threadRef) => { + await act(() => { + if (renderer) + renderer.update(); + else + renderer = create( + , + ); + }); +}; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.onReady = undefined; + state.close.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.listError = null; + state.refresh.mockReset(); + state.available = true; + state.canOperate = true; + state.thread = { settledOverride: null, archivedAt: null }; + state.preview = { listLoaded: false, sessions: {}, recentlySeenUrls: [] }; + state.open.mockReset().mockImplementation(async () => { + state.preview.sessions.opened = { tabId: "opened", navStatus: { _tag: "Success", url } }; + state.preview.recentlySeenUrls.push(url); + return AsyncResult.success({ tabId: "opened" }); + }); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); +}); + +it("waits for the authoritative tab list before opening the owned Mergebot view", async () => { + await render(); + expect(state.open).not.toHaveBeenCalled(); + state.preview.listLoaded = true; + await render(); + expect(state.open).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ threadRef, url })); + expect(renderer!.root.findByProps({ "data-browser-tab": "opened" })).toBeDefined(); +}); +it("reuses an existing URL after remount without opening duplicate tabs", async () => { + state.preview.listLoaded = true; + state.preview.sessions.existing = { tabId: "existing", navStatus: { _tag: "Success", url } }; + await render(); + await act(() => renderer?.unmount()); + renderer = null; + await render(); + expect(state.open).not.toHaveBeenCalled(); + expect(renderer!.root.findByProps({ "data-browser-tab": "existing" })).toBeDefined(); +}); +it("does not reopen tabs closed by Settle, including after remount", async () => { + state.preview.listLoaded = true; + await render(); + state.preview.sessions = {}; + state.thread = { settledOverride: "settled", archivedAt: null }; + await render(); + await act(() => renderer?.unmount()); + renderer = null; + await render(); + expect(state.open).toHaveBeenCalledTimes(1); +}); +it("keeps a settled chat closed until the reader explicitly opens Mergebot", async () => { + state.preview.listLoaded = true; + state.thread = { settledOverride: "settled", archivedAt: null }; + await render(); + expect(state.open).not.toHaveBeenCalled(); + await act(() => renderer!.root.findByType("button").props.onClick()); + expect(state.open).toHaveBeenCalledOnce(); +}); +it("shows a retryable failure without opening externally", async () => { + state.preview.listLoaded = true; + state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("offline")))); + await render(); + expect(renderer!.root.findByProps({ role: "alert" }).children).toContain("offline"); + await act(() => renderer!.root.findByType("button").props.onClick()); + expect(state.open).toHaveBeenCalledTimes(2); +}); +it("does not request a browser when permission or support is unavailable", async () => { + state.preview.listLoaded = true; + state.canOperate = false; + await render(); + expect(state.open).not.toHaveBeenCalled(); + expect(renderer!.root.findAllByType("button")).toHaveLength(0); +}); + +it("retries a failed authoritative list instead of opening duplicate unlisted tabs", async () => { + state.listError = "Connection unavailable"; + await render(); + expect(renderer!.root.findByProps({ role: "alert" }).children).toContain( + "Connection unavailable", + ); + await act(() => renderer!.root.findByType("button").props.onClick()); + expect(state.refresh).toHaveBeenCalledOnce(); + expect(state.open).not.toHaveBeenCalled(); +}); + +it("waits for a real owner’s lifecycle state before automatically opening tabs", async () => { + state.thread = null; + state.preview.listLoaded = true; + await render(); + expect(state.open).not.toHaveBeenCalled(); + state.thread = { settledOverride: null, archivedAt: null }; + await render(); + expect(state.open).toHaveBeenCalledOnce(); +}); + +it("closes a newly created tab when its PR surface was closed while opening", async () => { + state.preview.listLoaded = true; + let finish!: (value: unknown) => void; + state.open.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const ready = vi.fn(() => true); + state.onReady = ready; + await render(); + ready.mockReturnValue(false); + await act(async () => { + finish(AsyncResult.success({ tabId: "late" })); + }); + expect(state.close).toHaveBeenCalledWith( + expect.objectContaining({ threadRef, tabId: "late", snapshot: { tabId: "late" } }), + ); +}); +it("hands an existing tab to the browser surface without reopening or closing it", async () => { + state.preview.listLoaded = true; + state.preview.sessions.existing = { tabId: "existing", navStatus: { _tag: "Success", url } }; + const ready = vi.fn(() => true); + state.onReady = ready; + await render(); + expect(ready).toHaveBeenCalledWith("existing"); + expect(state.open).not.toHaveBeenCalled(); + expect(state.close).not.toHaveBeenCalled(); +}); + +it("does not close a browser already handed off by its published snapshot", async () => { + state.preview.listLoaded = true; + let finish!: (value: unknown) => void; + state.open.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + let placeholderPresent = true; + const ready = vi.fn(() => { + if (!placeholderPresent) return false; + placeholderPresent = false; + return true; + }); + state.onReady = ready; + await render(); + state.preview.sessions.published = { + tabId: "published", + navStatus: { _tag: "Success", url }, + }; + await render(); + expect(ready).toHaveBeenCalledTimes(1); + expect(placeholderPresent).toBe(false); + await act(async () => { + finish(AsyncResult.success({ tabId: "published" })); + }); + expect(ready).toHaveBeenCalledTimes(1); + expect(state.close).not.toHaveBeenCalled(); +}); + +it("reopens a previously closed URL for a fresh explicit chat PR placeholder", async () => { + state.preview.listLoaded = true; + state.preview.recentlySeenUrls = [url]; + state.onReady = vi.fn(() => true); + await render(); + expect(state.open).toHaveBeenCalledOnce(); +}); + +it("does not reopen a settled chat's URL for a fresh PR placeholder", async () => { + state.preview.listLoaded = true; + state.preview.recentlySeenUrls = [url]; + state.thread = { settledOverride: "settled", archivedAt: null }; + state.onReady = vi.fn(() => true); + await render(); + expect(state.open).not.toHaveBeenCalled(); +}); + +it("does not reopen an archived chat's URL for a fresh PR placeholder", async () => { + state.preview.listLoaded = true; + state.preview.recentlySeenUrls = [url]; + state.thread = { settledOverride: null, archivedAt: "2026-10-14T10:00:00Z" }; + state.onReady = vi.fn(() => true); + await render(); + expect(state.open).not.toHaveBeenCalled(); +}); + +it("keeps a closed standalone PR embed closed on remount", async () => { + state.thread = null; + state.preview.listLoaded = true; + state.preview.recentlySeenUrls = [url]; + state.onReady = vi.fn(() => true); + const owner = { ...threadRef, threadId: ThreadId.make("mergebot-browser:work/app:12") }; + await render(owner); + await act(() => renderer?.unmount()); + renderer = null; + await render(owner); + expect(state.open).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/pullRequest/MergebotBrowserPanel.tsx b/apps/web/src/components/pullRequest/MergebotBrowserPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aaa762a1c136f7c4e17ab466f9f987f21ca2b247 --- /dev/null +++ b/apps/web/src/components/pullRequest/MergebotBrowserPanel.tsx @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + AuthPreviewOperateScope, + MERGEBOT_BROWSER_THREAD_ID, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { usePreviewAvailable } from "~/browser/previewRuntime"; +import { readThreadPreviewState, useThreadPreviewState } from "~/previewStateStore"; +import { useEnvironmentQuery } from "~/state/query"; +import { useThreadShell } from "~/state/entities"; +import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentScope } from "~/state/session"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Button } from "../ui/button"; +import { PreviewPanel } from "../preview/PreviewPanel"; +import { closePreviewSession } from "../preview/closePreviewSession"; +import { openPreviewSession } from "../preview/openPreviewSession"; +import { usePreviewSession } from "../preview/usePreviewSession"; + +export function MergebotBrowserPanel({ + threadRef, + url, + onReady, +}: { + threadRef: ScopedThreadRef; + url: string; + onReady?: ((tabId: string) => boolean) | undefined; +}) { + usePreviewSession(threadRef); + const preview = useThreadPreviewState(threadRef); + const list = useEnvironmentQuery( + previewEnvironment.list({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }), + ); + const thread = useThreadShell(threadRef); + const ownerKnown = + thread !== null || + threadRef.threadId === MERGEBOT_BROWSER_THREAD_ID || + threadRef.threadId.startsWith(`${MERGEBOT_BROWSER_THREAD_ID}:`); + const available = usePreviewAvailable(threadRef.environmentId); + const canOperate = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const closePreview = useAtomCommand(previewEnvironment.close); + const [tabId, setTabId] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const attempted = useRef(false); + const busy = useRef(false); + const handoffs = useRef(new Map()); + const matchingTab = Object.values(preview.sessions).find( + (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === url, + ); + const activeTabId = tabId && preview.sessions[tabId] ? tabId : (matchingTab?.tabId ?? null); + const isChatHandoff = onReady !== undefined && thread !== null; + + const handOff = useCallback( + (openedTabId: string) => { + const previous = handoffs.current.get(openedTabId); + if (previous !== undefined) return previous; + const accepted = onReady?.(openedTabId) ?? true; + handoffs.current.set(openedTabId, accepted); + return accepted; + }, + [onReady], + ); + + const open = useCallback(async () => { + if (!available || !canOperate || busy.current) return; + busy.current = true; + setPending(true); + setError(null); + try { + const matching = Object.values(readThreadPreviewState(threadRef).sessions).find( + (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === url, + ); + if (matching) { + handOff(matching.tabId); + setTabId(matching.tabId); + return; + } + const result = await openPreviewSession({ threadRef, url, openPreview }); + if (isAtomCommandInterrupted(result)) return; + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + if (!handOff(result.value.tabId)) { + await closePreviewSession({ + closePreview, + snapshot: result.value, + tabId: result.value.tabId, + threadRef, + }); + return; + } + setTabId(result.value.tabId); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Try opening Mergebot again."); + } finally { + busy.current = false; + setPending(false); + } + }, [available, canOperate, closePreview, handOff, openPreview, threadRef, url]); + + useLayoutEffect(() => { + if (activeTabId) handOff(activeTabId); + }, [activeTabId, handOff]); + + useEffect(() => { + if ( + !ownerKnown || + !preview.listLoaded || + activeTabId || + attempted.current || + !available || + !canOperate + ) + return; + attempted.current = true; + if ( + thread?.settledOverride === "settled" || + thread?.archivedAt != null || + (!isChatHandoff && preview.recentlySeenUrls.includes(url)) + ) + return; + void open(); + }, [ + activeTabId, + available, + canOperate, + isChatHandoff, + open, + ownerKnown, + preview.listLoaded, + preview.recentlySeenUrls, + thread?.archivedAt, + thread?.settledOverride, + url, + ]); + + if (activeTabId && available && canOperate) { + return ; + } + const displayedError = error ?? (!preview.listLoaded ? list.error : null); + return ( +
+

+ {displayedError ?? + (!canOperate + ? "Pair this client with browser access to open Mergebot." + : !available + ? "Connect to an environment with browser support to open Mergebot." + : pending || !preview.listLoaded + ? "Opening Mergebot…" + : "Mergebot is closed.")} +

+ {canOperate && available && (preview.listLoaded || displayedError) ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/MergebotQueueBadge.test.tsx b/apps/web/src/components/pullRequest/MergebotQueueBadge.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c8653902ff52917703996dfc7c4dea4354b479f7 --- /dev/null +++ b/apps/web/src/components/pullRequest/MergebotQueueBadge.test.tsx @@ -0,0 +1,106 @@ +import type { EnvironmentId, MergebotRepoQueue } from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ queue: null as MergebotRepoQueue | null, read: vi.fn() })); +vi.mock("../../state/environments", () => ({ + useEnvironment: () => ({ + serverConfig: { mergebot: { origin: "https://mergebot.example", repositories: ["work/app"] } }, + }), +})); +vi.mock("../../state/mergebot", () => ({ + useMergebotQueue: (...args: unknown[]) => { + state.read(...args); + return state.queue; + }, +})); +vi.mock("../ui/badge", () => ({ Badge: (props: object) => createElement("span", props) })); +vi.mock("../ui/tooltip", () => ({ + Tooltip: "div", + TooltipPopup: "aside", + TooltipTrigger: ({ + render, + children, + }: { + render: React.ReactElement; + children: React.ReactNode; + }) => createElement(render.type, render.props, children), +})); + +import { MergebotQueueBadge } from "./MergebotQueueBadge"; +const environmentId = "local" as EnvironmentId; +const repository = "work/app"; +let renderer: ReactTestRenderer | undefined; +function ready(position: number | null = 2): MergebotRepoQueue { + return { + repo: repository, + url: `https://mergebot.example/${repository}`, + status: "ready", + processing: true, + pullRequests: [ + { + number: 12, + headSha: "current", + lifecycle: "open", + queueStatus: "queued", + position, + mergeEtaAt: null, + url: `https://mergebot.example/${repository}/pull/12`, + }, + ], + }; +} +async function render(repo = repository, headSha = "current") { + await act(() => { + const badge = ( + + ); + if (renderer) renderer.update(badge); + else renderer = create(badge); + }); +} +function label() { + return renderer!.root.findByType("span").children.join(""); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.queue = ready(); + state.read.mockReset(); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it("does not mount queue polling for a repository Mergebot does not manage", async () => { + await render("pingdotgg/t3code"); + expect(state.read).not.toHaveBeenCalled(); + expect(renderer!.toJSON()).toBeNull(); + await render(); + expect(state.read).toHaveBeenCalledExactlyOnceWith(environmentId, repository); + expect(label()).toBe("Mergebot: Queued #2"); +}); +it("replaces a known queue position with Unknown after a head mismatch or unavailable observation", async () => { + await render(); + expect(label()).toBe("Mergebot: Queued #2"); + await render(repository, "new-push"); + expect(label()).toBe("Mergebot: Unknown"); + state.queue = null; + await render(); + expect(label()).toBe("Mergebot: Unknown"); + state.queue = ready(); + await render(); + expect(label()).toBe("Mergebot: Queued #2"); +}); +it("identifies a queue entry without an active position as held", async () => { + state.queue = ready(null); + await render(); + expect(label()).toBe("Mergebot: Queued (held)"); +}); diff --git a/apps/web/src/components/pullRequest/MergebotQueueBadge.tsx b/apps/web/src/components/pullRequest/MergebotQueueBadge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f73b9480705d03ff9dd5a59c768e5254cdafdbdb --- /dev/null +++ b/apps/web/src/components/pullRequest/MergebotQueueBadge.tsx @@ -0,0 +1,66 @@ +import { + isMergebotRepository, + mergebotPullRequestStatus, + type EnvironmentId, +} from "@t3tools/contracts"; + +import { useMergebotQueue } from "../../state/mergebot"; +import { useEnvironment } from "../../state/environments"; +import { Badge } from "../ui/badge"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const PHASE_LABEL = { + not_queued: "Not queued", + queued: "Queued", + updating_branch: "Updating branch", + waiting_ci: "Waiting for CI", + merging: "Merging", + ci_failed: "CI failed", +} as const; + +interface Props { + environmentId: EnvironmentId; + repository: string; + number: number; + headSha?: string | undefined; +} + +export function MergebotQueueBadge(props: Props) { + const configuration = useEnvironment(props.environmentId)?.serverConfig?.mergebot; + return isMergebotRepository(configuration, props.repository) ? ( + + ) : null; +} + +function SupportedQueueBadge({ environmentId, repository, number, headSha }: Props) { + const queue = useMergebotQueue(environmentId, repository); + const entry = mergebotPullRequestStatus(queue, number, headSha); + const phase = entry?.lifecycle === "open" ? entry.queueStatus : null; + const held = phase !== null && phase !== "not_queued" && entry?.position === null; + const label = phase + ? `${PHASE_LABEL[phase]}${entry?.position ? ` #${entry.position}` : held ? " (held)" : ""}` + : "Unknown"; + const variant = + phase === "ci_failed" + ? "error" + : held + ? "warning" + : phase && phase !== "not_queued" + ? "info" + : "secondary"; + + return ( + + + } + > + Mergebot: {label} + + + {phase ? `Mergebot: ${label}` : "Mergebot queue status unavailable for this revision."} + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx index f16bbd0aa58c7ffc8fda6ef6e95b5e5a03d7000f..3cdfadc9340e376d1a9e4d9683b9d3d3d281e282 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -13,10 +13,11 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; -const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() => ({ +const { newThread, prepareThread, refresh, browserState, Wrapper, Trigger } = vi.hoisted(() => ({ newThread: vi.fn(), prepareThread: vi.fn(), refresh: vi.fn(), + browserState: { configured: false }, Wrapper: ({ children }: { children?: ReactNode }) => children, Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( <> @@ -32,8 +33,25 @@ vi.mock("~/state/session", async (original) => ({ })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); -vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() })); +vi.mock("~/state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + browserState.configured + ? new Map([ + [ + "env-1", + { mergebot: { origin: "https://mergebot.example", repositories: ["Owner/Repo"] } }, + ], + ]) + : new Map(), +})); +vi.mock("./MergebotBrowserPanel", () => ({ + MergebotBrowserPanel: ({ threadRef, url }: { threadRef: ScopedThreadRef; url: string }) => ( +
+ ), +})); vi.mock("~/state/environments", () => ({ + useEnvironment: () => null, useEnvironments: () => ({ environments: [] }), usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"), })); @@ -226,6 +244,7 @@ const newDraftId = DraftId.make("new-draft"); let renderer: ReactTestRenderer; beforeEach(() => { + browserState.configured = false; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); useComposerDraftStore.setState({ draftsByThreadKey: {} }); @@ -436,3 +455,82 @@ it("changes the title only for the active tab's main scroller", async () => { await act(async () => mainScroll.props.onScrollCapture({ target: code })); expect(titleFold().props.inert).toBe(false); }); + +it.each([true, false])( + "opens the actual Mergebot UI before native detail hooks (chat owner: %s)", + async (owned) => { + browserState.configured = true; + await act(async () => { + renderer = create( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + />, + ); + }); + expect( + renderer.root.findByProps({ + "data-mergebot-url": "https://mergebot.example/Owner/Repo/pull/1", + }).props["data-mergebot-owner"], + ).toBe(owned ? "thread-1" : "mergebot-browser:owner/repo:1"); + expect(renderer.root.findAllByType("h1")).toHaveLength(0); + }, +); + +it.each(["back", "close", "both"])( + "preserves provided %s app navigation above Mergebot", + async (controls) => { + browserState.configured = true; + const back = vi.fn(); + const close = vi.fn(); + await act(async () => { + renderer = create( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + />, + ); + }); + if (controls !== "close") { + await click("Back to this thread's pull requests"); + expect(back).toHaveBeenCalledOnce(); + } else + expect( + renderer.root.findAllByProps({ "aria-label": "Back to this thread's pull requests" }), + ).toHaveLength(0); + if (controls !== "back") { + await click("Collapse pull request panel"); + expect(close).toHaveBeenCalledOnce(); + } else + expect( + renderer.root.findAllByProps({ "aria-label": "Collapse pull request panel" }), + ).toHaveLength(0); + expect( + renderer.root.findByProps({ + "data-mergebot-url": "https://mergebot.example/Owner/Repo/pull/1", + }), + ).toBeDefined(); + }, +); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e85ac9655139e08024e6970cc1d0a8984847c3f5..bb9d27b4e91fcb6aa39a01542709959a2d1d6945 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,9 @@ +import { + mergebotPullRequestUrl, + mergebotPullRequestBrowserThreadId, + ThreadId, +} from "@t3tools/contracts"; +import { MergebotQueueBadge } from "./MergebotQueueBadge"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; @@ -412,7 +418,68 @@ function PullRequestBaseFreshnessWarning({ ); } -export function PullRequestDetailPanel({ +const MergebotBrowserPanel = lazy(() => + import("./MergebotBrowserPanel").then((module) => ({ default: module.MergebotBrowserPanel })), +); + +export function PullRequestDetailPanel(props: Parameters[0]) { + const configurations = useServerConfigs(); + const projects = useProjects(); + const project = projects.find( + (candidate) => + candidate.id === props.reference.projectId && candidate.environmentId === props.environmentId, + ); + const reference = resolvePullRequestReferenceHost(props.reference, project?.repositoryIdentity); + const url = mergebotPullRequestUrl(configurations.get(props.environmentId)?.mergebot, reference); + return url ? ( +
+ {props.onBack || props.onClose ? ( +
+ {props.onBack ? ( + + ) : null} + {props.onClose ? ( +
+ +
+ ) : null} +
+ ) : null} + Opening Mergebot…
}> + + +
+ ) : ( + + ); +} + +function NativePullRequestDetailPanel({ environmentId, shortcutsEnabled, getShortcutContext, @@ -432,6 +499,7 @@ export function PullRequestDetailPanel({ shortcutsEnabled: boolean; getShortcutContext: () => ShortcutMatchContext; onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; + onMergebotReady?: ((tabId: string) => boolean) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull * request itself (check details, host permalinks) can open in that thread's @@ -2775,6 +2843,16 @@ export function PullRequestDetailPanel({ } /> ) : null} + {detail?.state === "open" && !condensed ? ( +
+ +
+ ) : null}
diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 23e877ee058aeb9996e89156e1d12f7c923cc142..c0a2755d699f9de9b5a21938c019269a708e1862 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -48,6 +48,7 @@ import { } from "./pullRequestPresentation"; import { PullRequestGlyph } from "./pullRequestIcons"; import { pullRequestQuickActions } from "./pullRequestList.logic"; +import { MergebotQueueBadge } from "./MergebotQueueBadge"; import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; const SOURCE_LABELS: Record = { @@ -206,6 +207,13 @@ function LinkRow({ } meta={ <> + {open ? ( + + ) : null} {stack ? ( void; -}) { - return ( - - - - {icon} - - } - /> - {label} - - - ); -} - export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { - const navigate = useNavigate(); const navigateToMainApp = useNavigateToMainApp(); const { isMobile, setOpenMobile } = useSidebar(); const isOnUtilityPage = useLocation({ select: (location) => isSidebarUtilityPage(location.pathname), }); - const pullRequestsSupported = usePullRequestsSupported(); const closeMobileSidebar = useCallback(() => { if (isMobile) { setOpenMobile(false); } }, [isMobile, setOpenMobile]); - const handlePullRequestsClick = useCallback(() => { - closeMobileSidebar(); - void navigate({ - to: "/pull-requests", - search: readPullRequestListPreferences(), - }); - }, [closeMobileSidebar, navigate]); const handleBackClick = useCallback(() => { closeMobileSidebar(); void navigateToMainApp(); @@ -125,15 +87,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { ) : ( - <> - {pullRequestsSupported ? ( - } - label="Pull Requests" - onClick={handlePullRequestsClick} - /> - ) : null} - + )} ); diff --git a/apps/web/src/components/sidebar/SidebarMergebotButton.test.tsx b/apps/web/src/components/sidebar/SidebarMergebotButton.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..558bed0b0aca1256b2e74ac379a6dae16dd29de5 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarMergebotButton.test.tsx @@ -0,0 +1,134 @@ +import { AsyncResult } from "effect/reactivity"; +import * as Cause from "effect/Cause"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + navigate: vi.fn(), + params: { environmentId: "local", threadId: "active" } as Record, + canPreview: true, + configured: true, + open: vi.fn(), + external: vi.fn(), + showBrowser: vi.fn(), + toast: vi.fn(), +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => state.navigate, + useParams: ({ select }: { select: (value: object) => unknown }) => select(state.params), +})); +vi.mock("../../state/environments", () => ({ + usePrimaryEnvironmentId: () => "local", + useEnvironment: () => ({ + serverConfig: { + mergebot: state.configured + ? { origin: "https://mergebot.example", repositories: ["work/app"] } + : undefined, + }, + }), +})); +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => state.canPreview })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../state/preview", () => ({ previewEnvironment: { open: {} } })); +vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); +vi.mock("../../rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openBrowser: state.showBrowser }) }, +})); +vi.mock("../../previewStateStore", () => ({ readThreadPreviewState: () => ({ sessions: {} }) })); +vi.mock("../../localApi", () => ({ + readLocalApi: () => ({ shell: { openExternal: state.external } }), +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); +vi.mock("../ui/sidebar", () => ({ + useSidebar: () => ({ isMobile: false, setOpenMobile: vi.fn() }), + SidebarMenuItem: "div", + SidebarMenuButton: "button", +})); +vi.mock("../ui/tooltip", () => ({ + Tooltip: "div", + TooltipPopup: "aside", + TooltipTrigger: ({ render }: { render: unknown }) => render, +})); + +import { SidebarMergebotButton } from "./SidebarMergebotButton"; +let renderer: ReactTestRenderer; +async function mount() { + await act(() => { + renderer = create(); + }); +} +const click = () => renderer.root.findByType("button").props.onClick(); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.params = { environmentId: "local", threadId: "active" }; + state.canPreview = true; + state.configured = true; + state.open.mockReset().mockResolvedValue(AsyncResult.success({ tabId: "mergebot" })); + state.navigate.mockReset().mockResolvedValue(undefined); + state.external.mockReset().mockResolvedValue(undefined); + state.showBrowser.mockReset(); + state.toast.mockReset(); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("opens the live queue beside the active chat, and rapid clicks create one tab", async () => { + let finish!: (result: unknown) => void; + state.open.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await mount(); + await act(() => { + click(); + click(); + }); + expect(state.open).toHaveBeenCalledTimes(1); + expect(state.open.mock.calls[0]![0]).toMatchObject({ + url: "https://mergebot.example/", + threadRef: { environmentId: "local", threadId: "active" }, + }); + await act(async () => { + finish(AsyncResult.success({ tabId: "mergebot" })); + }); + expect(state.showBrowser).toHaveBeenCalledExactlyOnceWith( + { environmentId: "local", threadId: "active" }, + "mergebot", + ); + expect(state.external).not.toHaveBeenCalled(); +}); +it("opens the queue inside the PR page without an active chat", async () => { + state.params = {}; + await mount(); + await act(() => click()); + expect(state.open).not.toHaveBeenCalled(); + expect(state.navigate).toHaveBeenCalledExactlyOnceWith({ + to: "/pull-requests", + search: { involvement: "all", state: "open", mergebot: "local" }, + }); + expect(state.external).not.toHaveBeenCalled(); +}); +it("reports an integrated-browser failure and allows retry", async () => { + state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("offline")))); + await mount(); + await act(() => click()); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: "offline" }), + ); + expect(state.external).not.toHaveBeenCalled(); + await act(() => click()); + expect(state.open).toHaveBeenCalledTimes(2); + expect(state.showBrowser).toHaveBeenCalledTimes(1); +}); + +it("hides the shortcut when the destination has no Mergebot configuration", async () => { + state.configured = false; + await mount(); + expect(renderer.toJSON()).toBeNull(); + expect(state.open).not.toHaveBeenCalled(); + expect(state.external).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/sidebar/SidebarMergebotButton.tsx b/apps/web/src/components/sidebar/SidebarMergebotButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2d4d97663f5409f5165d72fe60e84241b43777f4 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarMergebotButton.tsx @@ -0,0 +1,94 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { AuthPreviewOperateScope } from "@t3tools/contracts"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { ListOrderedIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { useRightPanelStore } from "../../rightPanelStore"; +import { previewEnvironment } from "../../state/preview"; +import { useEnvironmentScope } from "../../state/session"; +import { useEnvironment, usePrimaryEnvironmentId } from "../../state/environments"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveThreadRouteRef } from "../../threadRoutes"; +import { readThreadPreviewState } from "../../previewStateStore"; +import { openPreviewSession } from "../preview/openPreviewSession"; +import { SidebarMenuButton, SidebarMenuItem, useSidebar } from "../ui/sidebar"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function SidebarMergebotButton() { + const navigate = useNavigate(); + const threadRef = useParams({ strict: false, select: resolveThreadRouteRef }); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environment = useEnvironment(threadRef?.environmentId ?? primaryEnvironmentId); + const configuration = environment?.serverConfig?.mergebot; + const origin = configuration ? `${configuration.origin}/` : undefined; + const canPreview = useEnvironmentScope(threadRef?.environmentId ?? null, AuthPreviewOperateScope); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const { isMobile, setOpenMobile } = useSidebar(); + const busy = useRef(false); + const [pending, setPending] = useState(false); + const open = async () => { + if (!origin || busy.current) return; + busy.current = true; + setPending(true); + try { + if (threadRef && canPreview) { + const matching = Object.values(readThreadPreviewState(threadRef).sessions).find( + (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === origin, + ); + if (matching) useRightPanelStore.getState().openBrowser(threadRef, matching.tabId); + else { + const result = await openPreviewSession({ threadRef, url: origin, openPreview }); + if (isAtomCommandInterrupted(result)) return; + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + useRightPanelStore.getState().openBrowser(threadRef, result.value.tabId); + } + } else { + const environmentId = threadRef?.environmentId ?? primaryEnvironmentId; + if (!environmentId) return; + await navigate({ + to: "/pull-requests", + search: { involvement: "all", state: "open", mergebot: environmentId }, + }); + } + if (isMobile) setOpenMobile(false); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not open Mergebot", + description: + error instanceof Error ? error.message : "Try again when the environment is connected.", + }); + } finally { + busy.current = false; + setPending(false); + } + }; + + if (!origin) return null; + + return ( + + + void open()} + size="icon" + > + + + ); +} diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index 6719eaf89e405a0e3df285fc077dec2c5c9c10d1..5d8517602f9787636e8c119a40ba8b3319d9d0c7 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -1,5 +1,10 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ProjectId, type ScopedThreadRef } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + mergebotPullRequestUrl, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { useMemo } from "react"; import { @@ -9,7 +14,7 @@ import { } from "../components/pullRequest/pullRequestDetail.logic"; import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; -import { useProject } from "../state/entities"; +import { useProject, useServerConfigs } from "../state/entities"; import { pullRequestEnvironment } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; @@ -45,8 +50,13 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { ) : input; }, [requestedReference, supportsMultiplePullRequests, project?.repositoryIdentity]); - const detail = useEnvironmentQuery( + const configurations = useServerConfigs(); + const mergebotUrl = reference && environmentId + ? mergebotPullRequestUrl(configurations.get(environmentId)?.mergebot, reference) + : null; + const detail = useEnvironmentQuery( + reference && environmentId && !mergebotUrl ? pullRequestEnvironment.detail({ environmentId, input: reference, @@ -64,13 +74,17 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { : null, [environmentId, reference], ); - return reference - ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ?? + return ( + mergebotUrl ?? + (reference + ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference }) + ?.url ?? requestedReference?.url ?? gitHubPullRequestBrowserUrl( project?.repositoryIdentity, reference.repository, reference.number, )) - : undefined; + : undefined) + ); } diff --git a/apps/web/src/lib/openMergebotPullRequestLink.test.ts b/apps/web/src/lib/openMergebotPullRequestLink.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..16d196d51824935de1b3a4823bf09daa592e8ff1 --- /dev/null +++ b/apps/web/src/lib/openMergebotPullRequestLink.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; + +const state = vi.hoisted(() => ({ + navigate: vi.fn(), + panel: vi.fn(), + hasProject: true, +})); +vi.mock("react", async (original) => ({ + ...(await original()), + useCallback: (callback: unknown) => callback, + useMemo: (fn: () => unknown) => fn(), +})); +vi.mock("../components/ui/toast", () => ({ + toastManager: { add: vi.fn() }, + stackedThreadToast: (value: unknown) => value, +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); +vi.mock("../browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); +vi.mock("../state/server", () => ({ serverEnvironment: {} })); +vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "env" })); +vi.mock("../rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openPullRequest: state.panel }) }, +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => ({ projectId: "project" }), + useProjects: () => + state.hasProject + ? [ + { + id: "project", + environmentId: "env", + repositoryIdentity: { + provider: "github", + canonicalKey: "github.com/work/app", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/work/app.git", + }, + }, + }, + ] + : [], + useServerConfigs: () => + new Map([ + [ + "env", + { + mergebot: { origin: "https://mergebot.example", repositories: ["Work/App"] }, + environment: { capabilities: { pullRequests: true, threadPullRequests: true } }, + }, + ], + ]), +})); +import { useOpenChangeRequestLink } from "./openPullRequestLink"; +const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; +const event = () => ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + metaKey: false, + ctrlKey: false, +}); +beforeEach(() => { + state.hasProject = true; + state.navigate.mockReset(); + state.panel.mockReset(); +}); +it.each(["https://github.com/work/app/pull/12", "https://mergebot.example/work/app/pull/12"])( + "opens %s in the owned PR panel that embeds Mergebot", + (url) => { + expect(useOpenChangeRequestLink(ref)(event(), url)).toBe(true); + expect(state.panel).toHaveBeenCalledWith( + ref, + expect.objectContaining({ + projectId: ProjectId.make("project"), + host: "github.com", + repository: expect.stringMatching(/^work\/app$/i), + number: 12, + }), + ); + expect(state.navigate).not.toHaveBeenCalled(); + }, +); +it.each(["https://mergebot.example/work/app/pull/12", "https://github.com/work/app/pull/12"])( + "opens %s in the PR page without inventing a project or conversation", + (url) => { + state.hasProject = false; + expect(useOpenChangeRequestLink()(event(), url)).toBe(true); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/pull-requests", + search: expect.objectContaining({ + mergebot: "env", + repository: expect.stringMatching(/^work\/app$/i), + number: 12, + }), + }); + expect(state.panel).not.toHaveBeenCalled(); + }, +); +it("leaves lookalike Mergebot origins as ordinary links", () => { + expect( + useOpenChangeRequestLink(ref)(event(), "https://mergebot.example.evil/work/app/pull/12"), + ).toBe(false); + expect(state.panel).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 1c701fb4f0fe3e3ed0d3faf6f2b99e052a11b096..f6e849edd16283bbe9266a3bc1f73c5624c24622 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; +import { + parseMergebotPullRequestUrl, + mergebotPullRequestUrl, + type EnvironmentId, + type PullRequestRef, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent, useCallback, useMemo } from "react"; @@ -12,7 +18,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useRightPanelStore } from "../rightPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { useProjects, useServerConfigs } from "../state/entities"; +import { readThreadShell, useProjects, useServerConfigs } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; @@ -150,7 +156,18 @@ export function useOpenChangeRequestLink( if (shouldOpenPullRequestExternally(event)) return false; const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; - const parsed = parseChangeRequestUrl(targetUrl); + const requestedEnvironmentId = targetEnvironmentId ?? resolvedThreadRef?.environmentId; + const hostReference = parseChangeRequestUrl(targetUrl); + const mergebot = [...serverConfigs].flatMap(([environmentId, configuration]) => { + if (requestedEnvironmentId && environmentId !== requestedEnvironmentId) return []; + const reference = + parseMergebotPullRequestUrl(configuration.mergebot, targetUrl) ?? + (hostReference && mergebotPullRequestUrl(configuration.mergebot, hostReference) + ? hostReference + : null); + return reference ? [{ environmentId, reference }] : []; + })[0]; + const parsed: ChangeRequestLink | null = mergebot?.reference ?? hostReference; if (parsed === null) return false; const reads = (environmentId: string) => serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === @@ -173,6 +190,9 @@ export function useOpenChangeRequestLink( Number(left.environmentId === primaryEnvironmentId), ); const exactProject = findProjectForChangeRequest(projects, parsed); + const threadProjectId = resolvedThreadRef + ? readThreadShell(resolvedThreadRef)?.projectId + : undefined; const project = exactProject ?? (resolvedPanelRef @@ -184,8 +204,30 @@ export function useOpenChangeRequestLink( ), parsed, ) + : undefined) ?? + (mergebot && resolvedThreadRef + ? projects.find( + (candidate) => + candidate.environmentId === resolvedThreadRef.environmentId && + candidate.id === threadProjectId, + ) : undefined); - if (project === undefined || !reads(project.environmentId)) return false; + if (project === undefined && mergebot) { + event.preventDefault(); + event.stopPropagation(); + void navigate({ + to: "/pull-requests", + search: { + involvement: "all", + state: "all", + mergebot: mergebot.environmentId, + repository: parsed.repository, + number: parsed.number, + }, + }); + return true; + } + if (project === undefined || (!mergebot && !reads(project.environmentId))) return false; const repository = serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === true diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 13f9e1ca4ea6a696364027dd38ffd499291ec1af..d1d37641c84aa6eb07aa05a1ad091f54d8fb0788 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1240,3 +1240,54 @@ describe("rightPanelStore", () => { expect(store.getUserActionRevision(refA)).toBe(revision); }); }); + +describe("Mergebot browser handoff", () => { + it("atomically replaces a PR with its canonical browser while preserving the linked list", () => { + const store = useRightPanelStore.getState(); + const pr = pullRequestSurface({ + projectId: "project", + host: "github.com", + repository: "work/app", + number: 1, + }); + store.open(refA, "pull-requests"); + store.openPullRequest(refA, pr); + store.reconcileBrowserSurfaces(refA, ["mergebot"]); + const revision = store.getUserActionRevision(refA); + + expect(store.replacePullRequestWithBrowser(refA, pr.id, "mergebot")).toBe(true); + store.reconcileBrowserSurfaces(refA, ["mergebot"]); + + const panel = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(panel.surfaces.map((surface) => surface.id)).toEqual([ + "pull-requests", + "browser:mergebot", + ]); + expect(panel.activeSurfaceId).toBe("browser:mergebot"); + expect(store.getUserActionRevision(refA)).toBe(revision); + store.closeSurface(refA, "browser:mergebot"); + store.reconcileBrowserSurfaces(refA, []); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ), + ).toEqual(["pull-requests"]); + }); + it("rejects a delayed handoff after the user closes its PR and preserves another active surface", () => { + const store = useRightPanelStore.getState(); + const pr = pullRequestSurface({ projectId: "project", repository: "work/app", number: 1 }); + store.openPullRequest(refA, pr); + store.open(refA, "files"); + expect(store.replacePullRequestWithBrowser(refA, pr.id, "first")).toBe(true); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( + "files", + ); + store.closeSurface(refA, "browser:first"); + expect(store.replacePullRequestWithBrowser(refA, pr.id, "late")).toBe(false); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ), + ).toEqual(["files"]); + }); +}); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 0ab7794d87386ed209ab29c95074613678f9a280..bbc9e8ecf61868fa47f053e65425d5b39fa875d6 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -151,6 +151,11 @@ interface RightPanelStoreState { openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; + replacePullRequestWithBrowser: ( + ref: ScopedThreadRef, + surfaceId: string, + tabId: string, + ) => boolean; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; openPullRequest: ( @@ -692,6 +697,30 @@ export const useRightPanelStore = create()( return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), ), + replacePullRequestWithBrowser: (ref, surfaceId, tabId) => { + let replaced = false; + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { + if ( + !current.surfaces.some( + (surface) => surface.id === surfaceId && surface.kind === "pull-request", + ) + ) + return current; + replaced = true; + const browser = browserSurface(tabId); + return { + ...current, + surfaces: current.surfaces.flatMap((surface) => + surface.id === surfaceId ? [browser] : surface.id === browser.id ? [] : [surface], + ), + activeSurfaceId: + current.activeSurfaceId === surfaceId ? browser.id : current.activeSurfaceId, + }; + }), + ); + return replaced; + }, openPullRequest: (ref, target) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 30dfdc808d1dcf7119d0801d5bdb3ccc425f8ec2..35b1e77e49cfaa6a10cd4e308f9467315f76d81c 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,3 +1,14 @@ +import { + MERGEBOT_BROWSER_THREAD_ID, + ThreadId, + mergebotPullRequestUrl, + mergebotPullRequestBrowserThreadId, +} from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { MergebotBrowserPanel } from "../components/pullRequest/MergebotBrowserPanel"; +import { clearThreadPreviewState } from "../previewStateStore"; +import { previewEnvironment } from "../state/preview"; +import { useEnvironment } from "../state/environments"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { Spinner } from "~/components/ui/spinner"; import { useShortcutModifierState } from "~/shortcutModifierState"; @@ -153,6 +164,7 @@ import { selectThreadRightPanelState, useRightPanelStore, type PullRequestSurface, + type RightPanelSurface, } from "../rightPanelStore"; import { useDebouncedValue } from "../state/queries"; import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; @@ -185,6 +197,7 @@ function getShortcutContext() { } export interface PullRequestsSearch extends PullRequestListPreferences { + readonly mergebot?: EnvironmentId; /** * Narrows the list to one server. Absent means every connected one, which is the default the * page has now — so a link written before servers could be chosen still opens the whole list. @@ -306,6 +319,9 @@ function pullRequestSearchLabels(raw: unknown): Partial): PullRequestsSearch => ({ + ...(typeof raw.mergebot === "string" && raw.mergebot + ? { mergebot: raw.mergebot as EnvironmentId } + : {}), involvement: raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", state: @@ -356,6 +372,50 @@ export const Route = createFileRoute("/_chat/pull-requests")({ function PullRequestsRouteView() { useEscapeToGoBack(); + const search = Route.useSearch(); + const environment = useEnvironment(search.mergebot ?? null); + const navigate = useNavigate({ from: Route.fullPath }); + if (!search.mergebot) return ; + const configuration = environment?.serverConfig?.mergebot; + const url = + configuration && search.repository && search.number + ? mergebotPullRequestUrl(configuration, { + repository: search.repository, + number: search.number, + }) + : configuration + ? `${configuration.origin}/` + : undefined; + return ( + + + +

Mergebot

+
+ {url ? ( + + ) : ( + + )} +
+ ); +} + +function NativePullRequestsRouteView() { + const closeMergebotPreview = useAtomCommand(previewEnvironment.close); const modifiers = useShortcutModifierState(true); const speedMode = modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; @@ -2118,6 +2178,28 @@ function PullRequestsRouteView() { searchInput, sortMenu, filtersMenu, + mergebotControl: environments + .filter( + (environment) => + environment.serverConfig?.mergebot && + (scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId), + ) + .map((environment) => ( + + )), rightPanelControl: // Footprint reserve while the panel is closed: the toggle itself stays // mounted at the fixed titlebar inset in both states so it cannot move @@ -2165,34 +2247,51 @@ function PullRequestsRouteView() { useRightPanelStore.getState().activateSurface(rightPanelRef, surface.id); selectSurfaceInUrl(surface); }; - const closeSurface = (surface: PullRequestSurface) => { + const closeSurfaces = async (surfaces: readonly RightPanelSurface[]) => { if (rightPanelRef === null) return; - useRightPanelStore.getState().closeSurface(rightPanelRef, surface.id); + for (const surface of surfaces) + useRightPanelStore.getState().closeSurface(rightPanelRef, surface.id); + const closeRevision = useRightPanelStore.getState().getUserActionRevision(rightPanelRef); const next = selectActiveRightPanelSurface( useRightPanelStore.getState().byThreadKey, rightPanelRef, ); selectSurfaceInUrl(next?.kind === "pull-request" ? next : null); + + for (const surface of surfaces) { + if (surface.kind !== "pull-request") continue; + const environmentId = + (surface.environmentId as EnvironmentId | undefined) ?? panelEnvironmentId; + if (!environmentId) continue; + const configuration = environments.find( + (environment) => environment.environmentId === environmentId, + )?.serverConfig?.mergebot; + if (!mergebotPullRequestUrl(configuration, surface)) continue; + const owner = scopeThreadRef( + environmentId, + ThreadId.make(mergebotPullRequestBrowserThreadId(surface)), + ); + const result = await closeMergebotPreview({ + environmentId, + input: { threadId: owner.threadId }, + }); + if (result._tag === "Failure") { + const panels = useRightPanelStore.getState(); + if (panels.openProactive(rightPanelRef, surface, closeRevision)) + selectSurfaceInUrl(surface); + } else { + clearThreadPreviewState(owner); + } + } }; - const closeOtherSurfaces = (surface: PullRequestSurface) => { - if (rightPanelRef === null) return; - useRightPanelStore.getState().closeOtherSurfaces(rightPanelRef, surface.id); - selectSurfaceInUrl(surface); - }; + const closeSurface = (surface: PullRequestSurface) => void closeSurfaces([surface]); + const closeOtherSurfaces = (surface: PullRequestSurface) => + void closeSurfaces(rightPanelState.surfaces.filter((entry) => entry.id !== surface.id)); const closeSurfacesToRight = (surface: PullRequestSurface) => { - if (rightPanelRef === null) return; - useRightPanelStore.getState().closeSurfacesToRight(rightPanelRef, surface.id); - const next = selectActiveRightPanelSurface( - useRightPanelStore.getState().byThreadKey, - rightPanelRef, - ); - selectSurfaceInUrl(next?.kind === "pull-request" ? next : null); - }; - const closeAllSurfaces = () => { - if (rightPanelRef === null) return; - useRightPanelStore.getState().closeAllSurfaces(rightPanelRef); - selectSurfaceInUrl(null); + const index = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); + if (index >= 0) void closeSurfaces(rightPanelState.surfaces.slice(index + 1)); }; + const closeAllSurfaces = () => void closeSurfaces(rightPanelState.surfaces); // This page has no ChatView, so it handles the shared panel shortcuts itself. const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { @@ -2314,6 +2413,13 @@ function PullRequestsRouteView() { shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} key={renderedPullRequestSurface.id} environmentId={panelEnvironmentId} + onMergebotReady={() => + rightPanelRef !== null && + selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + rightPanelRef, + ).surfaces.some((surface) => surface.id === renderedPullRequestSurface.id) + } onGuideShown={onGuideShown} onSelectPullRequest={(reference) => { if (rightPanelRef === null) return; @@ -2574,6 +2680,7 @@ function PullRequestsColumn({ searchInput, sortMenu, filtersMenu, + mergebotControl, rightPanelControl, titlebarControls, rightPanelOpen, @@ -2594,6 +2701,7 @@ function PullRequestsColumn({ searchInput: ReactNode; sortMenu: ReactNode; filtersMenu: ReactNode; + mergebotControl: ReactNode; rightPanelControl: ReactNode; titlebarControls: ReactNode; rightPanelOpen: boolean; @@ -2752,6 +2860,7 @@ function PullRequestsColumn({ {sortMenu} {filtersMenu} + {mergebotControl} { + const { AsyncResult, Atom } = await import("effect/reactivity"); + const snapshot: MergebotRepoQueue = { + repo: "work/app", + url: "https://mergebot.example/work/app", + status: "ready", + processing: true, + pullRequests: [ + { + number: 123, + headSha: "head", + lifecycle: "open", + queueStatus: "waiting_ci", + position: 1, + mergeEtaAt: null, + url: "https://mergebot.example/work/app/pull/123", + }, + ], + }; + return { + snapshot, + queue: Atom.make>( + AsyncResult.success(snapshot), + ), + connection: Atom.make(AsyncResult.success({ phase: "connected" })), + }; +}); + +vi.mock("@t3tools/client-runtime/state/pull-requests", () => ({ + createMergebotQueueAtomFamily: () => () => state.queue, +})); +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined })); +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { stateAtom: () => state.connection }, +})); + +let registry: AtomRegistry.AtomRegistry; +let renderer: ReactTestRenderer; +let observed: MergebotRepoQueue | null; + +function Probe() { + const value = useMergebotQueue(EnvironmentId.make("local"), "work/app"); + useLayoutEffect(() => { + observed = value; + }); + return null; +} + +beforeEach(() => { + registry = AtomRegistry.make(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(() => { + act(() => renderer.unmount()); + registry.dispose(); + vi.unstubAllGlobals(); +}); + +it("keeps a same-query success visible during a routine refresh", () => { + expect(observed).toEqual(state.snapshot); + act(() => registry.set(state.queue, AsyncResult.success(state.snapshot, { waiting: true }))); + expect(observed).toEqual(state.snapshot); +}); + +it("drops a failed refresh even when AsyncResult retains the prior successful snapshot", () => { + const failure = AsyncResult.failureWithPrevious(Cause.fail(new Error("offline")), { + previous: Option.some(AsyncResult.success(state.snapshot)), + }); + expect(Option.isSome(AsyncResult.value(failure))).toBe(true); + act(() => registry.set(state.queue, failure)); + expect(observed).toBeNull(); +}); + +it("clears on disconnection and on a completed unavailable response", () => { + act(() => registry.set(state.queue, AsyncResult.success(state.snapshot, { waiting: true }))); + act(() => registry.set(state.connection, AsyncResult.success({ phase: "backoff" }))); + expect(observed).toBeNull(); + act(() => { + registry.set(state.connection, AsyncResult.success({ phase: "connected" })); + registry.set(state.queue, AsyncResult.success(null)); + }); + expect(observed).toBeNull(); +}); diff --git a/apps/web/src/state/mergebot.ts b/apps/web/src/state/mergebot.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c5992bc21e2f8a0f3c8b4b5005a9fe068d5aa2c --- /dev/null +++ b/apps/web/src/state/mergebot.ts @@ -0,0 +1,14 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createMergebotQueueAtomFamily } from "@t3tools/client-runtime/state/pull-requests"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +const mergebotQueue = createMergebotQueueAtomFamily(connectionAtomRuntime); + +export function useMergebotQueue(environmentId: EnvironmentId, repository: string) { + const result = useAtomValue(mergebotQueue({ environmentId, input: { repository } })); + const connection = useAtomValue(environmentCatalog.stateAtom(environmentId)); + if (connection._tag !== "Success" || connection.value.phase !== "connected") return null; + return result._tag === "Success" ? result.value : null; +} diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index b8a25179066b5e694bcf5baf18a4cda7311079d1..a5486babea066a7eda2416b561cd71a33b263ab4 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -68,6 +68,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.pullRequestsSubscribeRefreshes + | typeof WS_METHODS.mergebotSubscribe | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 0217cfd00078e008fe4b69f4a5590e6739095f70..ba428130e45b03f795e551fd25780f09be3f7474 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -9,6 +9,7 @@ import { PullRequestOperationError, WS_METHODS, type PullRequestStack, + type MergebotRepoQueue, type AuthSessionState, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; @@ -43,6 +44,7 @@ import { createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, createPullRequestStackAtomFamily, + createMergebotQueueAtomFamily, } from "./pullRequests.ts"; import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts"; import { executeAtomQuery } from "./runtime.ts"; @@ -690,6 +692,48 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( return { runtime, atoms, registry, environmentRegistry, supervisor, localSupervisor }; }); +it.effect("updates Mergebot badges from the stream and clears disconnected snapshots", () => + Effect.scoped( + Effect.gen(function* () { + const snapshots = yield* SubscriptionRef.make({ + repo: "acme/web", + url: "https://queue.example.test", + status: "ready", + processing: false, + pullRequests: [], + }); + const client = { + [WS_METHODS.mergebotSubscribe]: () => SubscriptionRef.changes(snapshots), + [WS_METHODS.mergebotQueue]: () => Effect.die("live badges must not poll"), + } as unknown as WsRpcProtocolClient; + const { runtime, registry } = yield* makeTestRuntime(client); + const queue = createMergebotQueueAtomFamily(runtime)({ + environmentId: TARGET.environmentId, + input: { repository: "acme/web" }, + }); + registry.mount(queue); + expect(yield* AtomRegistry.getResult(registry, queue)).toMatchObject({ processing: false }); + + const changed = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + const unsubscribe = registry.subscribe(queue, (result) => { + if (!AsyncResult.isSuccess(result)) return; + if (result.value?.processing) Deferred.doneUnsafe(changed, Effect.void); + if (result.value === null) Deferred.doneUnsafe(disconnected, Effect.void); + }); + yield* SubscriptionRef.update(snapshots, (snapshot) => + snapshot === null ? null : { ...snapshot, processing: true }, + ); + yield* Deferred.await(changed); + expect(yield* AtomRegistry.getResult(registry, queue)).toMatchObject({ processing: true }); + yield* SubscriptionRef.set(snapshots, null); + yield* Deferred.await(disconnected); + expect(yield* AtomRegistry.getResult(registry, queue)).toBeNull(); + unsubscribe(); + }), + ), +); + it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( "does not probe another environment with %s routing permission", (permission) => diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index a700f4de1032bfc56a0af21f45ccc2514a1abbbd..f6ba6a8079570a9b6a77965746f4feadf80fdc99 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -619,3 +619,12 @@ export function createPullRequestEnvironmentAtoms( }), }; } + +export function createMergebotQueueAtomFamily( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "mergebot:queue", + tag: WS_METHODS.mergebotSubscribe, + }); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 58f832664ae17e71ae297acd7bdfc8d21fd0d3ad..4e9bb7d5298709d7fba552eb62f0f060dbf81d3f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -65,8 +65,9 @@ export * from "./rpc.ts"; export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; export * from "./clientRpcPermissions.ts"; -export * from "./desktopTerminal.ts"; +export * from "./mergebot.ts"; export * from "./scriptProgress.ts"; +export * from "./desktopTerminal.ts"; export * from "./uiSettings.ts"; export * from "./sharedAccounts.ts"; diff --git a/packages/contracts/src/mergebot.test.ts b/packages/contracts/src/mergebot.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed737d0f2a4c071bffd1aa8cc31ff6004a210598 --- /dev/null +++ b/packages/contracts/src/mergebot.test.ts @@ -0,0 +1,158 @@ +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import { describe, expect, it } from "vite-plus/test"; +import { + MergebotPullRequestDetails, + isMergebotRepository, + mergebotPullRequestStatus, + mergebotPullRequestUrl, + mergebotPullRequestBrowserThreadId, + parseMergebotPullRequestUrl, + type MergebotRepoQueue, +} from "./mergebot.ts"; + +const decodeDetails = Schema.decodeSync(MergebotPullRequestDetails); + +const entry = { + number: 123, + headSha: "current-head", + lifecycle: "open", + queueStatus: "waiting_ci", + position: 1, + mergeEtaAt: null, + url: "https://mergebot.example/work/app/pull/123", +} as const; +const queue: MergebotRepoQueue = { + repo: "work/app", + status: "ready", + url: "https://mergebot.example/work/app", + processing: true, + pullRequests: [entry], +}; + +describe("mergebotPullRequestStatus", () => { + it("uses Mergebot's verified head unless a known T3 head disagrees", () => { + expect(mergebotPullRequestStatus(queue, 123)).toEqual(entry); + expect(mergebotPullRequestStatus(queue, 123, "current-head")).toEqual(entry); + expect(mergebotPullRequestStatus(queue, 123, "new-head")).toBeNull(); + }); + + it("keeps absent PRs, unknown queue heads and loading snapshots unknown", () => { + expect(mergebotPullRequestStatus(queue, 456)).toBeNull(); + expect(mergebotPullRequestStatus(null, 123)).toBeNull(); + expect( + mergebotPullRequestStatus({ ...queue, status: "loading", pullRequests: null }, 123), + ).toBeNull(); + expect( + mergebotPullRequestStatus({ ...queue, pullRequests: [{ ...entry, queueStatus: null }] }, 123), + ).toBeNull(); + }); +}); + +const configuration = { + origin: "https://mergebot.example", + repositories: ["work/app", "work/research.sh", "work/research"], +}; + +it("gives each standalone PR its own browser owner with case-insensitive repository identity", () => { + const owner = mergebotPullRequestBrowserThreadId({ repository: "Work/App", number: 12 }); + expect(owner).toBe(mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 12 })); + expect(owner).not.toBe( + mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 13 }), + ); + expect(owner).not.toBe( + mergebotPullRequestBrowserThreadId({ repository: "work/other", number: 12 }), + ); +}); + +describe("isMergebotRepository", () => { + it("scopes native badges to configured work repositories", () => { + expect(isMergebotRepository(undefined, "work/app")).toBe(false); + expect(isMergebotRepository(configuration, "WORK/APP")).toBe(true); + expect(isMergebotRepository(configuration, "work/app")).toBe(true); + expect(isMergebotRepository(configuration, "work/research.sh")).toBe(true); + expect(isMergebotRepository(configuration, "work/research")).toBe(true); + expect(isMergebotRepository(configuration, "work/mergebot-test")).toBe(false); + expect(isMergebotRepository(configuration, "another/app")).toBe(false); + expect(isMergebotRepository(configuration, "work/app/extra")).toBe(false); + }); +}); + +describe("MergebotPullRequestDetails", () => { + it("preserves check rerun identity and lifecycle timestamps without inventing absent dates", () => { + const details = { + ...entry, + title: "Ship change", + createdAt: "2026-10-14T10:00:00Z", + author: null, + additions: 3, + deletions: 1, + changedFilesCount: 2, + labels: [], + comments: [], + observedAt: 1791972120000, + body: null, + updatedAt: "2026-10-14T10:02:00Z", + mergedAt: "2026-10-14T10:01:00Z", + closedAt: null, + draft: false, + baseBranch: "main", + headBranch: "feature", + ciStatus: "failed", + hasConflicts: false, + checks: [ + { + id: "check:42", + name: "Tests", + workflowName: "CI", + status: "completed", + conclusion: "failure", + url: null, + startedAt: null, + completedAt: "2026-10-14T10:00:00Z", + }, + ], + } as const; + expect(decodeDetails(details)).toEqual(details); + const withoutDates = Struct.omit(details, ["mergedAt", "closedAt"]); + expect(decodeDetails(withoutDates)).not.toHaveProperty("mergedAt"); + }); +}); + +describe("mergebotPullRequestUrl", () => { + const config = { origin: "https://mergebot.example", repositories: ["Work/App"] }; + it("uses configured casing and matches GitHub and repositories case-insensitively", () => { + expect( + mergebotPullRequestUrl(config, { host: "GitHub.Com", repository: "work/APP", number: 12 }), + ).toBe("https://mergebot.example/Work/App/pull/12"); + }); + it("rejects lookalike and enterprise hosts, unconfigured repositories and invalid numbers", () => { + for (const host of ["github.com.example", "example.com", "github.com:443"]) { + expect( + mergebotPullRequestUrl(config, { host, repository: "work/app", number: 12 }), + ).toBeNull(); + } + expect(mergebotPullRequestUrl(undefined, { repository: "work/app", number: 12 })).toBeNull(); + expect(mergebotPullRequestUrl(config, { repository: "work/other", number: 12 })).toBeNull(); + expect(mergebotPullRequestUrl(config, { repository: "work/app", number: 0 })).toBeNull(); + }); +}); + +describe("parseMergebotPullRequestUrl", () => { + const config = { origin: "https://mergebot.example", repositories: ["Work/App"] }; + it("recognizes configured Mergebot PR URLs as their GitHub repository", () => { + expect( + parseMergebotPullRequestUrl(config, "https://mergebot.example/work/APP/pull/12#checks"), + ).toEqual({ host: "github.com", repository: "Work/App", number: 12 }); + }); + it("leaves lookalike origins, credentials and unconfigured repository paths alone", () => { + for (const url of [ + "https://mergebot.example.evil/work/app/pull/12", + "http://mergebot.example/work/app/pull/12", + "https://mergebot.example:444/work/app/pull/12", + "https://user@mergebot.example/work/app/pull/12", + "https://mergebot.example/work/other/pull/12", + ]) + expect(parseMergebotPullRequestUrl(config, url)).toBeNull(); + }); +}); diff --git a/packages/contracts/src/mergebot.ts b/packages/contracts/src/mergebot.ts new file mode 100644 index 0000000000000000000000000000000000000000..564ad0cfdba6af0e897b32849571ba6aa65cb1e9 --- /dev/null +++ b/packages/contracts/src/mergebot.ts @@ -0,0 +1,156 @@ +import * as Schema from "effect/Schema"; +import { PullRequestActor, PullRequestComment, PullRequestLabel } from "./pullRequest.ts"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const MergebotConfiguration = Schema.Struct({ + origin: TrimmedNonEmptyString.check(Schema.isPattern(/^https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?$/)), + repositories: Schema.Array( + TrimmedNonEmptyString.check(Schema.isPattern(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/)), + ), +}); +export type MergebotConfiguration = typeof MergebotConfiguration.Type; + +export function isMergebotRepository( + configuration: MergebotConfiguration | undefined, + repository: string, +): boolean { + return ( + configuration?.repositories.some((repo) => repo.toLowerCase() === repository.toLowerCase()) ?? + false + ); +} + +export const MergebotQueueInput = Schema.Struct({ repository: TrimmedNonEmptyString }); +export type MergebotQueueInput = typeof MergebotQueueInput.Type; + +export const MergebotPullRequestStatus = Schema.Struct({ + number: PositiveInt, + headSha: TrimmedNonEmptyString, + lifecycle: Schema.Literals(["open", "closed", "merged"]), + queueStatus: Schema.NullOr( + Schema.Literals([ + "not_queued", + "queued", + "updating_branch", + "waiting_ci", + "merging", + "ci_failed", + ]), + ), + position: Schema.NullOr(PositiveInt), + mergeEtaAt: Schema.NullOr(Schema.String), + url: Schema.String, +}); +export type MergebotPullRequestStatus = typeof MergebotPullRequestStatus.Type; + +export const MergebotRepoQueue = Schema.Struct({ + repo: TrimmedNonEmptyString, + url: Schema.String, + status: Schema.Literals(["loading", "ready"]), + processing: Schema.Boolean, + pullRequests: Schema.NullOr(Schema.Array(MergebotPullRequestStatus)), +}); +export type MergebotRepoQueue = typeof MergebotRepoQueue.Type; + +export function mergebotPullRequestStatus( + queue: MergebotRepoQueue | null, + number: number, + headSha?: string, +): MergebotPullRequestStatus | null { + if (queue?.status !== "ready") return null; + const entry = queue.pullRequests?.find((pullRequest) => pullRequest.number === number); + if (!entry || entry.queueStatus === null || (headSha !== undefined && entry.headSha !== headSha)) + return null; + return entry; +} + +export const MergebotPullRequestInput = Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, +}); +export type MergebotPullRequestInput = typeof MergebotPullRequestInput.Type; + +export const MergebotPullRequestCheck = Schema.Struct({ + id: TrimmedNonEmptyString, + name: Schema.String, + workflowName: Schema.NullOr(Schema.String), + status: Schema.String, + conclusion: Schema.NullOr(Schema.String), + url: Schema.NullOr(Schema.String), + startedAt: Schema.NullOr(Schema.String), + completedAt: Schema.NullOr(Schema.String), +}); +export type MergebotPullRequestCheck = typeof MergebotPullRequestCheck.Type; + +export const MergebotPullRequestDetails = Schema.Struct({ + ...MergebotPullRequestStatus.fields, + title: Schema.String, + createdAt: Schema.String, + author: Schema.NullOr(PullRequestActor), + additions: Schema.NullOr(Schema.Number), + deletions: Schema.NullOr(Schema.Number), + changedFilesCount: Schema.NullOr(Schema.Number), + labels: Schema.Array(PullRequestLabel), + comments: Schema.Array(PullRequestComment), + observedAt: Schema.Number, + updatedAt: Schema.String, + mergedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), + closedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), + body: Schema.NullOr(Schema.String), + draft: Schema.Boolean, + baseBranch: Schema.String, + headBranch: Schema.String, + ciStatus: Schema.NullOr(Schema.Literals(["passed", "pending", "failed", "unknown"])), + hasConflicts: Schema.NullOr(Schema.Boolean), + checks: Schema.Array(MergebotPullRequestCheck), +}); +export type MergebotPullRequestDetails = typeof MergebotPullRequestDetails.Type; + +export const MERGEBOT_BROWSER_THREAD_ID = "mergebot-browser"; + +export function mergebotPullRequestBrowserThreadId(reference: { + readonly repository: string; + readonly number: number; +}): string { + return `${MERGEBOT_BROWSER_THREAD_ID}:${reference.repository.toLowerCase()}:${reference.number}`; +} + +export function mergebotPullRequestUrl( + configuration: MergebotConfiguration | undefined, + reference: { + readonly host?: string | undefined; + readonly repository: string; + readonly number: number; + }, +): string | null { + if (!configuration || (reference.host ?? "github.com").toLowerCase() !== "github.com") + return null; + const repository = configuration.repositories.find( + (candidate) => candidate.toLowerCase() === reference.repository.toLowerCase(), + ); + return repository && Number.isSafeInteger(reference.number) && reference.number > 0 + ? `${configuration.origin}/${repository}/pull/${reference.number}` + : null; +} + +export function parseMergebotPullRequestUrl( + configuration: MergebotConfiguration | undefined, + targetUrl: string, +): { host: "github.com"; repository: string; number: number } | null { + if (!configuration) return null; + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + if (url.origin !== configuration.origin || url.username || url.password) return null; + const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); + const repository = configuration.repositories.find( + (candidate) => candidate.toLowerCase() === match?.[1]?.toLowerCase(), + ); + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { host: "github.com", repository, number } + : null; +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 82ab52615f4cb7b9c78f87bb39dd962c11c54d57..8e9ed9df40eb9ed7ce19f264c37c55aa9a914325 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,4 @@ +import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; import { UiSettingsProfile, UiSettingsPatchInput, @@ -544,6 +545,8 @@ export const WS_METHODS = { cloudInstallRelayClient: "cloud.installRelayClient", // Pull request methods + mergebotQueue: "mergebot.queue", + mergebotSubscribe: "mergebot.subscribe", pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", pullRequestsSummary: "pullRequests.summary", @@ -990,6 +993,19 @@ const PullRequestRpcError = Schema.Union([ EnvironmentAuthorizationError, ]); +const WsMergebotQueueRpc = Rpc.make(WS_METHODS.mergebotQueue, { + payload: MergebotQueueInput, + success: Schema.NullOr(MergebotRepoQueue), + error: EnvironmentAuthorizationError, +}); + +const WsMergebotSubscribeRpc = Rpc.make(WS_METHODS.mergebotSubscribe, { + payload: MergebotQueueInput, + success: Schema.NullOr(MergebotRepoQueue), + error: EnvironmentAuthorizationError, + stream: true, +}); + const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { payload: PullRequestListInput, success: PullRequestListResult, @@ -2004,6 +2020,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsMergebotQueueRpc, + WsMergebotSubscribeRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, WsPullRequestsSummaryRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 985e6bbc126ea84d2173b6f1caef07cc4e73146d..6162b494ce28798d2a1d493c61990449252c887f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -28,6 +28,7 @@ import { RuntimeMode } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; import { ServerSettings } from "./settings.ts"; +import { MergebotConfiguration } from "./mergebot.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -673,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; export const ServerConfig = Schema.Struct({ + mergebot: Schema.optionalKey(MergebotConfiguration), environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, cwd: TrimmedNonEmptyString, -- 2.54.0