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,